Repository: django-extensions/django-extensions
Pull request #1759 (2022-08-24 18:02:22)

Ensure consistent output in show_urls for django 4.0+
Updates `show_urls` to properly show the view class for class-based
views in django 4.0 and later. There was a regression in django 4.0
that was fixed in https://github.com/django/django/commit/f4b06a3cc1e54888ff86f36a1f9a3ddf21292314
that resulted in `view` being output in `show_urls` instead of the
actual class name.

The tests are essentially returned to the state there were in before https://github.com/django-extensions/django-extensions/commit/c8d4c4acdd92090e7bc86a8e663431d8631de2a4.


Patch:
diff --git a/django_extensions/__init__.py b/django_extensions/__init__.py
index 9322f237..7e291624 100644
--- a/django_extensions/__init__.py
+++ b/django_extensions/__init__.py
@@ -18,9 +18,3 @@ def get_version(version):
 
 
 __version__ = get_version(VERSION)
-
-try:
-    default_app_config = 'django_extensions.apps.DjangoExtensionsConfig'
-except ModuleNotFoundError:
-    # this part is useful for allow setup.py to be used for version checks
-    pass
diff --git a/django_extensions/management/commands/clean_pyc.py b/django_extensions/management/commands/clean_pyc.py
index f710eb51..a3197a9d 100644
--- a/django_extensions/management/commands/clean_pyc.py
+++ b/django_extensions/management/commands/clean_pyc.py
@@ -2,6 +2,7 @@
 import fnmatch
 import os
 from os.path import join as _j
+from typing import List
 
 from django.conf import settings
 from django.core.management.base import BaseCommand, CommandError
@@ -12,7 +13,7 @@ from django_extensions.management.utils import signalcommand
 class Command(BaseCommand):
     help = "Removes all python bytecode compiled files from the project."
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     def add_arguments(self, parser):
         parser.add_argument(
diff --git a/django_extensions/management/commands/compile_pyc.py b/django_extensions/management/commands/compile_pyc.py
index 1b702fd7..0bcd1b46 100644
--- a/django_extensions/management/commands/compile_pyc.py
+++ b/django_extensions/management/commands/compile_pyc.py
@@ -3,6 +3,7 @@ import fnmatch
 import os
 import py_compile
 from os.path import join as _j
+from typing import List
 
 from django.conf import settings
 from django.core.management.base import BaseCommand, CommandError
@@ -12,7 +13,7 @@ from django_extensions.management.utils import signalcommand
 
 class Command(BaseCommand):
     help = "Compile python bytecode files for the project."
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     def add_arguments(self, parser):
         parser.add_argument('--path', '-p', action='store', dest='path',
diff --git a/django_extensions/management/commands/create_command.py b/django_extensions/management/commands/create_command.py
index 5844f91e..d48c8dd9 100644
--- a/django_extensions/management/commands/create_command.py
+++ b/django_extensions/management/commands/create_command.py
@@ -2,6 +2,7 @@
 import os
 import sys
 import shutil
+from typing import List
 
 from django.core.management.base import AppCommand
 from django.core.management.color import color_style
@@ -12,7 +13,7 @@ from django_extensions.management.utils import _make_writeable, signalcommand
 class Command(AppCommand):
     help = "Creates a Django management command directory structure for the given app name in the app's directory."
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
     # Can't import settings during this command, because they haven't
     # necessarily been created.
     can_import_settings = True
diff --git a/django_extensions/management/commands/create_jobs.py b/django_extensions/management/commands/create_jobs.py
index 30c7e0cc..aa9aff1e 100644
--- a/django_extensions/management/commands/create_jobs.py
+++ b/django_extensions/management/commands/create_jobs.py
@@ -2,6 +2,7 @@
 import os
 import sys
 import shutil
+from typing import List
 
 from django.core.management.base import AppCommand
 from django.core.management.color import color_style
@@ -12,7 +13,7 @@ from django_extensions.management.utils import _make_writeable, signalcommand
 class Command(AppCommand):
     help = "Creates a Django jobs command directory structure for the given app name in the current directory."
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
     # Can't import settings during this command, because they haven't
     # necessarily been created.
     can_import_settings = True
diff --git a/django_extensions/management/commands/create_template_tags.py b/django_extensions/management/commands/create_template_tags.py
index c444e43f..83ab9cf9 100644
--- a/django_extensions/management/commands/create_template_tags.py
+++ b/django_extensions/management/commands/create_template_tags.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 import os
 import sys
+from typing import List
 
 from django.core.management.base import AppCommand
 
@@ -21,7 +22,7 @@ class Command(AppCommand):
             help='The name to use for the template tag base name. Defaults to `appname`_tags.'
         )
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
     # Can't import settings during this command, because they haven't
     # necessarily been created.
     can_import_settings = True
diff --git a/django_extensions/management/commands/dumpscript.py b/django_extensions/management/commands/dumpscript.py
index 98392f4c..d31ddcbd 100644
--- a/django_extensions/management/commands/dumpscript.py
+++ b/django_extensions/management/commands/dumpscript.py
@@ -70,7 +70,8 @@ def orm_item_locator(orm_obj):
         v = clean_dict[key]
         if v is not None:
             if isinstance(v, datetime.datetime):
-                v = timezone.make_aware(v)
+                if not timezone.is_aware(v):
+                    v = timezone.make_aware(v)
                 clean_dict[key] = StrToCodeChanger('dateutil.parser.parse("%s")' % v.isoformat())
             elif not isinstance(v, (str, int, float)):
                 clean_dict[key] = str("%s" % v)
diff --git a/django_extensions/management/commands/generate_password.py b/django_extensions/management/commands/generate_password.py
index e5236948..071cbb27 100644
--- a/django_extensions/management/commands/generate_password.py
+++ b/django_extensions/management/commands/generate_password.py
@@ -1,4 +1,6 @@
 # -*- coding: utf-8 -*-
+from typing import List
+
 try:
     from django.contrib.auth.base_user import BaseUserManager
 except ImportError:
@@ -10,7 +12,7 @@ from django_extensions.management.utils import signalcommand
 class Command(BaseCommand):
     help = "Generates a new password that can be used for a user password. This uses Django core's default password generator `BaseUserManager.make_random_password()`."
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     def add_arguments(self, parser):
         parser.add_argument(
diff --git a/django_extensions/management/commands/generate_secret_key.py b/django_extensions/management/commands/generate_secret_key.py
index f4823244..0ec12911 100644
--- a/django_extensions/management/commands/generate_secret_key.py
+++ b/django_extensions/management/commands/generate_secret_key.py
@@ -1,4 +1,6 @@
 # -*- coding: utf-8 -*-
+from typing import List
+
 from django.core.management.base import BaseCommand
 from django.core.management.utils import get_random_secret_key
 
@@ -8,7 +10,7 @@ from django_extensions.management.utils import signalcommand
 class Command(BaseCommand):
     help = "Generates a new SECRET_KEY that can be used in a project settings file."
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     @signalcommand
     def handle(self, *args, **options):
diff --git a/django_extensions/management/commands/mail_debug.py b/django_extensions/management/commands/mail_debug.py
index 4bf6b0ba..7a8424dc 100644
--- a/django_extensions/management/commands/mail_debug.py
+++ b/django_extensions/management/commands/mail_debug.py
@@ -3,6 +3,7 @@ import asyncore
 import sys
 from logging import getLogger
 from smtpd import SMTPServer
+from typing import List
 
 from django.core.management.base import BaseCommand, CommandError
 
@@ -33,7 +34,7 @@ class Command(BaseCommand):
     help = "Starts a test mail server for development."
     args = '[optional port number or ippaddr:port]'
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     def add_arguments(self, parser):
         super().add_arguments(parser)
diff --git a/django_extensions/management/commands/runserver_plus.py b/django_extensions/management/commands/runserver_plus.py
index 867e85fb..e8f104c5 100644
--- a/django_extensions/management/commands/runserver_plus.py
+++ b/django_extensions/management/commands/runserver_plus.py
@@ -7,7 +7,7 @@ import sys
 import traceback
 import webbrowser
 import functools
-from typing import Set
+from typing import List, Set
 
 import django
 from django.conf import settings
@@ -128,7 +128,7 @@ class Command(BaseCommand):
     help = "Starts a lightweight Web server for development."
 
     # Validation is called explicitly each time the server is reloaded.
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
     DEFAULT_CRT_EXTENSION = ".crt"
     DEFAULT_KEY_EXTENSION = ".key"
 
@@ -165,6 +165,8 @@ class Command(BaseCommand):
                                  'Either --cert-file or --key-file must be provided to use SSL.')
         parser.add_argument('--extra-file', dest='extra_files', action="append", type=str, default=[],
                             help='auto-reload whenever the given file changes too (can be specified multiple times)')
+        parser.add_argument('--exclude-pattern', dest='exclude_patterns', action="append", type=str, default=[],
+                            help='ignore reload on changes to files matching this pattern (can be specified multiple times)')
         parser.add_argument('--reloader-interval', dest='reloader_interval', action="store", type=int, default=DEFAULT_POLLER_RELOADER_INTERVAL,
                             help='After how many seconds auto-reload should scan for updates in poller-mode [default=%s]' % DEFAULT_POLLER_RELOADER_INTERVAL)
         parser.add_argument('--reloader-type', dest='reloader_type', action="store", type=str, default=DEFAULT_POLLER_RELOADER_TYPE,
@@ -333,6 +335,7 @@ class Command(BaseCommand):
         reloader_interval = options['reloader_interval']
         reloader_type = options['reloader_type']
         self.extra_files = set(options['extra_files'])
+        exclude_patterns = set(options['exclude_patterns'])
 
         self.nopin = options['nopin']
 
@@ -392,6 +395,8 @@ class Command(BaseCommand):
         if getattr(settings, 'RUNSERVER_PLUS_EXTRA_FILES', []):
             self.extra_files |= set(settings.RUNSERVER_PLUS_EXTRA_FILES)
 
+        exclude_patterns |= set(getattr(settings, 'RUNSERVER_PLUS_EXCLUDE_PATTERNS', []))
+
         # Werkzeug needs to be clued in its the main instance if running
         # without reloader or else it won't show key.
         # https://git.io/vVIgo
@@ -413,6 +418,7 @@ class Command(BaseCommand):
             use_reloader=use_reloader,
             use_debugger=True,
             extra_files=self.extra_files,
+            exclude_patterns=exclude_patterns,
             reloader_interval=reloader_interval,
             reloader_type=reloader_type,
             threaded=threaded,
diff --git a/django_extensions/management/commands/set_fake_emails.py b/django_extensions/management/commands/set_fake_emails.py
index 4eb4a9c8..4a5e3436 100644
--- a/django_extensions/management/commands/set_fake_emails.py
+++ b/django_extensions/management/commands/set_fake_emails.py
@@ -8,6 +8,8 @@ set_fake_emails.py
 
 """
 
+from typing import List
+
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand, CommandError
@@ -19,7 +21,7 @@ DEFAULT_FAKE_EMAIL = '%(username)s@example.com'
 
 class Command(BaseCommand):
     help = '''DEBUG only: give all users a new email based on their account data ("%s" by default). Possible parameters are: username, first_name, last_name''' % (DEFAULT_FAKE_EMAIL, )
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     def add_arguments(self, parser):
         super().add_arguments(parser)
diff --git a/django_extensions/management/commands/set_fake_passwords.py b/django_extensions/management/commands/set_fake_passwords.py
index 5d619bd0..1d03fe03 100644
--- a/django_extensions/management/commands/set_fake_passwords.py
+++ b/django_extensions/management/commands/set_fake_passwords.py
@@ -7,6 +7,8 @@ set_fake_passwords.py
     setting.DEBUG is True.
 
 """
+from typing import List
+
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand, CommandError
@@ -18,7 +20,7 @@ DEFAULT_FAKE_PASSWORD = 'password'
 
 class Command(BaseCommand):
     help = 'DEBUG only: sets all user passwords to a common value ("%s" by default)' % (DEFAULT_FAKE_PASSWORD, )
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
 
     def add_arguments(self, parser):
         super().add_arguments(parser)
diff --git a/django_extensions/management/commands/show_urls.py b/django_extensions/management/commands/show_urls.py
index cddca120..9f60023b 100644
--- a/django_extensions/management/commands/show_urls.py
+++ b/django_extensions/management/commands/show_urls.py
@@ -125,6 +125,8 @@ class Command(BaseCommand):
                 func = func.func
                 decorators.insert(0, 'functools.partial')
 
+            if hasattr(func, 'view_class'):
+                func = func.view_class
             if hasattr(func, '__name__'):
                 func_name = func.__name__
             elif hasattr(func, '__class__'):
diff --git a/django_extensions/management/commands/sqlcreate.py b/django_extensions/management/commands/sqlcreate.py
index a0af1754..9a579af3 100644
--- a/django_extensions/management/commands/sqlcreate.py
+++ b/django_extensions/management/commands/sqlcreate.py
@@ -2,6 +2,7 @@
 import socket
 import sys
 import warnings
+from typing import List
 
 from django.conf import settings
 from django.core.management.base import BaseCommand, CommandError
@@ -19,7 +20,7 @@ The envisioned use case is something like this:
     ./manage.py sqlcreate [--database=<databasename>] | mysql -u <db_administrator> -p
     ./manage.py sqlcreate [--database=<databasname>] | psql -U <db_administrator> -W"""
 
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
     can_import_settings = True
 
     def add_arguments(self, parser):
diff --git a/django_extensions/management/commands/sqldiff.py b/django_extensions/management/commands/sqldiff.py
index 550ed8f7..20849dad 100644
--- a/django_extensions/management/commands/sqldiff.py
+++ b/django_extensions/management/commands/sqldiff.py
@@ -30,6 +30,7 @@ from django.core.management import BaseCommand, CommandError
 from django.core.management.base import OutputWrapper
 from django.core.management.color import no_style
 from django.db import connection, transaction, models
+from django.db.models import UniqueConstraint
 from django.db.models.fields import AutoField, IntegerField
 from django.db.models.options import normalize_together
 
@@ -381,6 +382,23 @@ class SQLDiff:
             return field_type.split(" ")[0].split("(")[0].lower()
         return field_type
 
+    def get_index_together(self, meta):
+        indexes_normalized = list(normalize_together(meta.index_together))
+
+        for idx in meta.indexes:
+            indexes_normalized.append(idx.fields)
+
+        return self.expand_together(indexes_normalized, meta)
+
+    def get_unique_together(self, meta):
+        unique_normalized = list(normalize_together(meta.unique_together))
+
+        for constraint in meta.constraints:
+            if isinstance(constraint, UniqueConstraint):
+                unique_normalized.append(constraint.fields)
+
+        return self.expand_together(unique_normalized, meta)
+
     def expand_together(self, together, meta):
         new_together = []
         for fields in normalize_together(together):
@@ -411,7 +429,7 @@ class SQLDiff:
                 if db_type.startswith('text'):
                     self.add_difference('index-missing-in-db', table_name, [attname], index_name + '_like', ' text_pattern_ops')
 
-        unique_together = self.expand_together(meta.unique_together, meta)
+        unique_together = self.get_unique_together(meta)
         db_unique_columns = normalize_together([v['columns'] for v in table_constraints.values() if v['unique'] and not v['index']])
 
         for unique_columns in unique_together:
@@ -427,7 +445,7 @@ class SQLDiff:
 
     def find_unique_missing_in_model(self, meta, table_indexes, table_constraints, table_name):
         fields = dict([(field.column, field) for field in all_local_fields(meta)])
-        unique_together = self.expand_together(meta.unique_together, meta)
+        unique_together = self.get_unique_together(meta)
 
         for constraint_name, constraint in table_constraints.items():
             if not constraint['unique']:
@@ -463,7 +481,7 @@ class SQLDiff:
                     if db_type.startswith('text'):
                         self.add_difference('index-missing-in-db', table_name, [attname], index_name + '_like', ' text_pattern_ops')
 
-        index_together = self.expand_together(meta.index_together, meta)
+        index_together = self.get_index_together(meta)
         db_index_together = normalize_together([v['columns'] for v in table_constraints.values() if v['index'] and not v['unique']])
         for columns in index_together:
             if columns in db_index_together:
@@ -478,7 +496,7 @@ class SQLDiff:
     def find_index_missing_in_model(self, meta, table_indexes, table_constraints, table_name):
         fields = dict([(field.column, field) for field in all_local_fields(meta)])
         meta_index_names = [idx.name for idx in meta.indexes]
-        index_together = self.expand_together(meta.index_together, meta)
+        index_together = self.get_index_together(meta)
 
         for constraint_name, constraint in table_constraints.items():
             if constraint_name in meta_index_names:
@@ -642,7 +660,7 @@ class SQLDiff:
                 transaction.rollback()  # reset transaction
                 continue
 
-            # map table_contraints into table_indexes
+            # map table_constraints into table_indexes
             table_indexes = {}
             for contraint_name, dct in table_constraints.items():
 
@@ -838,8 +856,8 @@ class MySQLDiff(SQLDiff):
     def find_index_missing_in_model(self, meta, table_indexes, table_constraints, table_name):
         fields = dict([(field.column, field) for field in all_local_fields(meta)])
         meta_index_names = [idx.name for idx in meta.indexes]
-        index_together = self.expand_together(meta.index_together, meta)
-        unique_together = self.expand_together(meta.unique_together, meta)
+        index_together = self.get_index_together(meta)
+        unique_together = self.get_unique_together(meta)
 
         for constraint_name, constraint in table_constraints.items():
             if constraint_name in meta_index_names:
@@ -904,7 +922,7 @@ class MySQLDiff(SQLDiff):
                 if db_type.startswith('text'):
                     self.add_difference('index-missing-in-db', table_name, [attname], index_name + '_like', ' text_pattern_ops')
 
-        unique_together = self.expand_together(meta.unique_together, meta)
+        unique_together = self.get_unique_together(meta)
 
         # This comparison changed from superclass - otherwise function is the same
         db_unique_columns = normalize_together([v['columns'] for v in table_constraints.values() if v['unique']])
@@ -953,7 +971,7 @@ class SqliteSQLDiff(SQLDiff):
                 if column in unique_columns and (constraint['unique'] or constraint['primary_key']):
                     skip_list.append(column)
 
-        unique_together = self.expand_together(meta.unique_together, meta)
+        unique_together = self.get_unique_together(meta)
         db_unique_columns = normalize_together([v['columns'] for v in table_constraints.values() if v['unique']])
 
         for unique_columns in unique_together:
@@ -1192,7 +1210,7 @@ class PostgresqlSQLDiff(SQLDiff):
                 #       constraints for the type in `get_data_type_arrayfield` which instantiates
                 #       the array base_field or maybe even better restructure sqldiff entirely
                 #       to be based around the concrete type yielded by the code below. That gives
-                #       the complete type the database uses, why not use thie much earlier in the
+                #       the complete type the database uses, why not use this much earlier in the
                 #       process to compare to whatever django spits out as the desired database type ?
                 attname = field.db_column or field.attname
                 introspect_db_type = self.sql_to_dict(
diff --git a/django_extensions/management/commands/sqldsn.py b/django_extensions/management/commands/sqldsn.py
index 32d732fb..e5795205 100644
--- a/django_extensions/management/commands/sqldsn.py
+++ b/django_extensions/management/commands/sqldsn.py
@@ -7,6 +7,7 @@ Prints Data Source Name on stdout
 
 import sys
 import warnings
+from typing import List
 
 from django.conf import settings
 from django.core.management.base import BaseCommand, CommandError
@@ -84,7 +85,7 @@ _FORMATTERS = [
 
 class Command(BaseCommand):
     help = "Prints DSN on stdout, as specified in settings.py"
-    requires_system_checks = False
+    requires_system_checks: List[str] = []
     can_import_settings = True
 
     def add_arguments(self, parser):
diff --git a/django_extensions/mongodb/fields/__init__.py b/django_extensions/mongodb/fields/__init__.py
index 857a5dba..ee28484d 100644
--- a/django_extensions/mongodb/fields/__init__.py
+++ b/django_extensions/mongodb/fields/__init__.py
@@ -125,7 +125,7 @@ class AutoSlugField(SlugField):
         if model_instance.pk:
             queryset = queryset.exclude(pk=model_instance.pk)
 
-        # form a kwarg dict used to impliment any unique_together contraints
+        # form a kwarg dict used to impliment any unique_together constraints
         kwargs = {}
         for params in model_instance._meta.unique_together:
             if self.attname in params:
diff --git a/django_extensions/templatetags/syntax_color.py b/django_extensions/templatetags/syntax_color.py
index 986b5a91..de72016e 100644
--- a/django_extensions/templatetags/syntax_color.py
+++ b/django_extensions/templatetags/syntax_color.py
@@ -21,7 +21,7 @@ less efficient compared to specifying the lexer to use.
 
     {{ code_string|colorize }}
 
-You may also render the syntax highlighed text with line numbers.
+You may also render the syntax highlighted text with line numbers.
 
     {% load syntax_color %}
     {{ some_code|colorize_table:"html+django" }}
diff --git a/docs/runserver_plus.rst b/docs/runserver_plus.rst
index 9b8e051b..11a24c13 100644
--- a/docs/runserver_plus.rst
+++ b/docs/runserver_plus.rst
@@ -206,6 +206,9 @@ Other configuration options and their defaults include:
   # Add extra files to watch
   RUNSERVER_PLUS_EXTRA_FILES = []
 
+  # Do not watch files matching any of these patterns
+  RUNSERVER_PLUS_EXCLUDE_PATTERNS = []
+
 
 IO Calls and CPU Usage
 ^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/validate_templates.rst b/docs/validate_templates.rst
index 9bbf83c6..956d1b3e 100644
--- a/docs/validate_templates.rst
+++ b/docs/validate_templates.rst
@@ -27,7 +27,7 @@ break
 ~~~~~
 Do not continue scanning other templates after the first failure.
 
-ignore_app
+ignore-app
 ~~~~~~~~~~
 Ignore this app (can be used multiple times).
 
@@ -35,7 +35,7 @@ includes
 ~~~~~~~~
 Use -i (can be used multiple times) to add directories to the TEMPLATE DIRS.
 
-no_apps
+no-apps
 ~~~~~~~
 Do not automatically include app template directories.
 
diff --git a/setup.py b/setup.py
index bcbc1df7..6a2232f8 100644
--- a/setup.py
+++ b/setup.py
@@ -115,6 +115,7 @@ setup(
         'Framework :: Django',
         'Framework :: Django :: 3.2',
         'Framework :: Django :: 4.0',
+        'Framework :: Django :: 4.1',
         'Intended Audience :: Developers',
         'License :: OSI Approved :: MIT License',
         'Operating System :: OS Independent',
diff --git a/tox.ini b/tox.ini
index 57ede169..51c777bf 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,11 +11,14 @@ envlist =
     {py37,py38,py39,py310}-flake8
     {py36,py37,py38,py39,py310,pypy}-dj32
     {py38,py39,py310,pypy}-dj40
+    {py38,py39,py310,pypy}-dj41
     {py38,py39,py310,pypy}-djmaster
     py310-dj32-postgres
     py310-dj40-postgres
+    py310-dj41-postgres
     py310-dj32-mysql
     py310-dj40-mysql
+    py310-dj41-mysql
     py310-djmaster-postgres
 
 [testenv]
@@ -40,6 +43,7 @@ deps =
     -rrequirements-dev.txt
     dj32: Django>=3.2,<4.0
     dj40: Django>=4.0,<4.1
+    dj41: Django>=4.1,<4.2
     djmaster: https://github.com/django/django/archive/refs/heads/main.zip
     postgres: psycopg2-binary
     mysql: mysqlclient


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 25fe24d2..2ee24002 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -14,7 +14,6 @@ jobs:
       max-parallel: 4
       matrix:
         python-version:
-          - 3.6
           - 3.7
           - 3.8
           - 3.9
@@ -23,20 +22,25 @@ jobs:
         tox-django-version:
           - "32"
           - "40"
+          - "41"
           # GH Actions don't support something like allow-failure ?
           # - "master"
         exclude:
-          - python-version: "3.6"
-            tox-django-version: "40"
           - python-version: "3.7"
             tox-django-version: "40"
           - python-version: "pypy3"
             tox-django-version: "40"
+          - python-version: "3.6"
+            tox-django-version: "41"
+          - python-version: "3.7"
+            tox-django-version: "41"
+          - python-version: "pypy3"
+            tox-django-version: "41"
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
       - run: python -m pip install tox
@@ -69,9 +73,9 @@ jobs:
           - "32"
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
       - run: python -m pip install tox
diff --git a/tests/management/commands/test_show_urls.py b/tests/management/commands/test_show_urls.py
index aed4287b..1b14abb8 100644
--- a/tests/management/commands/test_show_urls.py
+++ b/tests/management/commands/test_show_urls.py
@@ -7,7 +7,6 @@ from django.http import HttpResponse
 from django.test import TestCase
 from django.test.utils import override_settings
 from django.views.generic.base import View
-from django import VERSION
 
 from unittest.mock import Mock, patch
 
@@ -58,23 +57,14 @@ class ShowUrlsTests(TestCase):
         lines = m_stdout.getvalue().splitlines()
         self.assertIn('/lambda/view\ttests.management.commands.test_show_urls.<lambda>', lines[0])
         self.assertIn('/function/based/\ttests.management.commands.test_show_urls.function_based_view\tfunction-based-view', lines[1])
-
-        if VERSION >= (4, 0):
-            self.assertIn('/class/based/\ttests.management.commands.test_show_urls.view\tclass-based-view', lines[2])
-        else:
-            self.assertIn('/class/based/\ttests.management.commands.test_show_urls.ClassView\tclass-based-view', lines[2])
+        self.assertIn('/class/based/\ttests.management.commands.test_show_urls.ClassView\tclass-based-view', lines[2])
 
     @patch('sys.stdout', new_callable=StringIO)
     def test_should_show_urls_sorted_alphabetically(self, m_stdout):
         call_command('show_urls', verbosity=3)
 
         lines = m_stdout.getvalue().splitlines()
-
-        if VERSION >= (4, 0):
-            self.assertEqual('/class/based/\ttests.management.commands.test_show_urls.view\tclass-based-view', lines[0])
-        else:
-            self.assertEqual('/class/based/\ttests.management.commands.test_show_urls.ClassView\tclass-based-view', lines[0])
-
+        self.assertEqual('/class/based/\ttests.management.commands.test_show_urls.ClassView\tclass-based-view', lines[0])
         self.assertEqual('/function/based/\ttests.management.commands.test_show_urls.function_based_view\tfunction-based-view', lines[1])
         self.assertEqual('/lambda/view\ttests.management.commands.test_show_urls.<lambda>', lines[2])
 
@@ -82,45 +72,29 @@ class ShowUrlsTests(TestCase):
     def test_should_show_urls_in_json_format(self, m_stdout):
         call_command('show_urls', '--format=json')
 
-        json = [
+        self.assertJSONEqual(m_stdout.getvalue(), [
             {"url": "/lambda/view", "module": "tests.management.commands.test_show_urls.<lambda>", "name": "", "decorators": ""},
-            {"url": "/function/based/", "module": "tests.management.commands.test_show_urls.function_based_view", "name": "function-based-view", "decorators": ""}
-        ]
-
-        if VERSION >= (4, 0):
-            json.append({"url": "/class/based/", "module": "tests.management.commands.test_show_urls.view", "name": "class-based-view", "decorators": ""})
-        else:
-            json.append({"url": "/class/based/", "module": "tests.management.commands.test_show_urls.ClassView", "name": "class-based-view", "decorators": ""})
-
-        self.assertJSONEqual(m_stdout.getvalue(), json)
+            {"url": "/function/based/", "module": "tests.management.commands.test_show_urls.function_based_view", "name": "function-based-view", "decorators": ""},
+            {"url": "/class/based/", "module": "tests.management.commands.test_show_urls.ClassView", "name": "class-based-view", "decorators": ""}
+        ])
         self.assertEqual(len(m_stdout.getvalue().splitlines()), 1)
 
     @patch('sys.stdout', new_callable=StringIO)
     def test_should_show_urls_in_pretty_json_format(self, m_stdout):
         call_command('show_urls', '--format=pretty-json')
 
-        json = [
+        self.assertJSONEqual(m_stdout.getvalue(), [
             {"url": "/lambda/view", "module": "tests.management.commands.test_show_urls.<lambda>", "name": "", "decorators": ""},
-            {"url": "/function/based/", "module": "tests.management.commands.test_show_urls.function_based_view", "name": "function-based-view", "decorators": ""}
-        ]
-
-        if VERSION >= (4, 0):
-            json.append({"url": "/class/based/", "module": "tests.management.commands.test_show_urls.view", "name": "class-based-view", "decorators": ""})
-        else:
-            json.append({"url": "/class/based/", "module": "tests.management.commands.test_show_urls.ClassView", "name": "class-based-view", "decorators": ""})
-
-        self.assertJSONEqual(m_stdout.getvalue(), json)
+            {"url": "/function/based/", "module": "tests.management.commands.test_show_urls.function_based_view", "name": "function-based-view", "decorators": ""},
+            {"url": "/class/based/", "module": "tests.management.commands.test_show_urls.ClassView", "name": "class-based-view", "decorators": ""}
+        ])
         self.assertEqual(len(m_stdout.getvalue().splitlines()), 20)
 
     @patch('sys.stdout', new_callable=StringIO)
     def test_should_show_urls_in_table_format(self, m_stdout):
         call_command('show_urls', '--format=table')
 
-        if VERSION >= (4, 0):
-            self.assertIn('/class/based/    | tests.management.commands.test_show_urls.view                | class-based-view    |', m_stdout.getvalue())
-        else:
-            self.assertIn('/class/based/    | tests.management.commands.test_show_urls.ClassView           | class-based-view    |', m_stdout.getvalue())
-
+        self.assertIn('/class/based/    | tests.management.commands.test_show_urls.ClassView           | class-based-view    |', m_stdout.getvalue())
         self.assertIn('/function/based/ | tests.management.commands.test_show_urls.function_based_view | function-based-view |', m_stdout.getvalue())
         self.assertIn('/lambda/view     | tests.management.commands.test_show_urls.<lambda>            |                     |', m_stdout.getvalue())
 
@@ -129,12 +103,7 @@ class ShowUrlsTests(TestCase):
         call_command('show_urls', '--format=aligned')
 
         lines = m_stdout.getvalue().splitlines()
-
-        if VERSION >= (4, 0):
-            self.assertEqual('/class/based/      tests.management.commands.test_show_urls.view                  class-based-view      ', lines[0])
-        else:
-            self.assertEqual('/class/based/      tests.management.commands.test_show_urls.ClassView             class-based-view      ', lines[0])
-
+        self.assertEqual('/class/based/      tests.management.commands.test_show_urls.ClassView             class-based-view      ', lines[0])
         self.assertEqual('/function/based/   tests.management.commands.test_show_urls.function_based_view   function-based-view   ', lines[1])
         self.assertEqual('/lambda/view       tests.management.commands.test_show_urls.<lambda>                                    ', lines[2])
 
@@ -143,11 +112,6 @@ class ShowUrlsTests(TestCase):
         call_command('show_urls', '--no-color')
 
         lines = m_stdout.getvalue().splitlines()
-
-        if VERSION >= (4, 0):
-            self.assertEqual('/class/based/\ttests.management.commands.test_show_urls.view\tclass-based-view', lines[0])
-        else:
-            self.assertEqual('/class/based/\ttests.management.commands.test_show_urls.ClassView\tclass-based-view', lines[0])
-
+        self.assertEqual('/class/based/\ttests.management.commands.test_show_urls.ClassView\tclass-based-view', lines[0])
         self.assertEqual('/function/based/\ttests.management.commands.test_show_urls.function_based_view\tfunction-based-view', lines[1])
         self.assertEqual('/lambda/view\ttests.management.commands.test_show_urls.<lambda>', lines[2])
diff --git a/tests/test_admin_filter.py b/tests/test_admin_filter.py
index 9f299881..27d22ae3 100644
--- a/tests/test_admin_filter.py
+++ b/tests/test_admin_filter.py
@@ -35,7 +35,7 @@ class NullFieldListFilterTests(BaseFieldFilter):
 
         result = filter_spec.queryset(self.request, self.qs)
 
-        self.assertQuerysetEqual(self.qs, map(repr, result), ordered=False)
+        self.assertCountEqual(self.qs, result)
 
     def test_should_return_objects_with_empty_text_if_yes_lookup_selected(self):
         expected_result = Secret.objects.filter(text__isnull=True)
@@ -44,7 +44,7 @@ class NullFieldListFilterTests(BaseFieldFilter):
 
         result = filter_spec.queryset(self.request, self.qs)
 
-        self.assertQuerysetEqual(expected_result, map(repr, result), ordered=False)
+        self.assertCountEqual(expected_result, result)
 
     def test_should_return_objects_with_not_empty_text_value_if_no_lookup_selected(self):
         expected_result = Secret.objects.filter(text__isnull=False)
@@ -53,7 +53,7 @@ class NullFieldListFilterTests(BaseFieldFilter):
 
         result = filter_spec.queryset(self.request, self.qs)
 
-        self.assertQuerysetEqual(expected_result, map(repr, result), ordered=False)
+        self.assertCountEqual(expected_result, result)
 
     def test_choices(self):
         expected_result = [
@@ -79,4 +79,4 @@ class NotNullFieldListFilterTests(BaseFieldFilter):
 
         result = filter_spec.queryset(self.request, self.qs)
 
-        self.assertQuerysetEqual(self.qs, map(repr, result), ordered=False)
+        self.assertCountEqual(self.qs, result)
diff --git a/tests/test_sqldiff.py b/tests/test_sqldiff.py
index 24789c5c..bd7faf71 100644
--- a/tests/test_sqldiff.py
+++ b/tests/test_sqldiff.py
@@ -9,6 +9,8 @@ from django.test import TestCase
 
 # from django.core.management import call_command
 from django_extensions.management.commands.sqldiff import SqliteSQLDiff, Command, MySQLDiff, PostgresqlSQLDiff
+from tests.testapp.models import PostWithUniqField, SluggedWithUniqueTogetherTestModel, \
+    RandomCharTestModelUniqueTogether, SqlDiffUniqueTogether, SqlDiff, SqlDiffIndexes
 
 
 class SqlDiffTests(TestCase):
@@ -53,6 +55,29 @@ class SqlDiffTests(TestCase):
         expected_field_name = ['name', 'email', 'address']
         self.assertEqual(instance.format_field_names(['Name', 'EMAIL', 'aDDress']), expected_field_name)
 
+    def test_get_index_together(self):
+        instance = MySQLDiff(
+            apps.get_models(include_auto_created=True),
+            vars(self.options),
+            stdout=self.tmp_out,
+            stderr=self.tmp_err,
+        )
+        self.assertEqual(instance.get_index_together(SqlDiff._meta), [('number', 'creator')])
+        self.assertEqual(instance.get_index_together(SqlDiffIndexes._meta), [('first', 'second')])
+
+    def test_get_unique_together(self):
+        instance = MySQLDiff(
+            apps.get_models(include_auto_created=True),
+            vars(self.options),
+            stdout=self.tmp_out,
+            stderr=self.tmp_err,
+        )
+        self.assertEqual(instance.get_unique_together(SluggedWithUniqueTogetherTestModel._meta), [('slug', 'category')])
+        self.assertEqual(instance.get_unique_together(RandomCharTestModelUniqueTogether._meta),
+                         [('random_char_field', 'common_field')])
+        self.assertEqual(instance.get_unique_together(SqlDiffUniqueTogether._meta), [('aaa', 'bbb')])
+        self.assertEqual(instance.get_unique_together(PostWithUniqField._meta), [('common_field', 'uniq_field')])
+
     @pytest.mark.skipif(settings.DATABASES['default']['ENGINE'] != 'django.db.backends.mysql', reason="Test can only run on mysql")
     def test_mysql_to_dict(self):
         mysql_instance = MySQLDiff(
diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py
index e633c3b3..e69de29b 100644
--- a/tests/testapp/__init__.py
+++ b/tests/testapp/__init__.py
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-default_app_config = 'tests.testapp.apps.TestAppConfig'
diff --git a/tests/testapp/models.py b/tests/testapp/models.py
index e044babc..088b796d 100644
--- a/tests/testapp/models.py
+++ b/tests/testapp/models.py
@@ -474,6 +474,19 @@ class SqlDiff(models.Model):
     number = models.CharField(max_length=40, null=True, verbose_name='Chargennummer')
     creator = models.CharField(max_length=20, null=True, blank=True)
 
+    class Meta:
+        index_together = ['number', 'creator']
+
+
+class SqlDiffIndexes(models.Model):
+    first = models.CharField(max_length=40, null=True, verbose_name='Chargennummer')
+    second = models.CharField(max_length=20, null=True, blank=True)
+
+    class Meta:
+        indexes = [
+            models.Index(fields=['first', 'second']),
+        ]
+
 
 class SqlDiffUniqueTogether(models.Model):
     aaa = models.CharField(max_length=20)
diff --git a/tests/testapp_with_appconfig/__init__.py b/tests/testapp_with_appconfig/__init__.py
index ff08595b..e69de29b 100644
--- a/tests/testapp_with_appconfig/__init__.py
+++ b/tests/testapp_with_appconfig/__init__.py
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-default_app_config = 'tests.testapp.apps.TestappWithAppConfigConfig'