Repository: nautobot/nautobot

Issue:
Code changes needed to update to django-rest-framework latest version
### Environment
* Nautobot version (Docker tag too if applicable): 2.2.0

### Steps to Reproduce
1. `poetry add django-rest-framework~3.15.0`
2. Build docker container
3. Start Nautobot

<!-- What did you expect to happen? -->
### Expected Behavior

Nautobot to start successfully

<!-- What happened instead? -->
### Observed Behavior

```
Traceback (most recent call last):
  File "/usr/local/bin/nautobot-server", line 6, in <module>
    sys.exit(main())
  File "/source/nautobot/core/cli/__init__.py", line 286, in main
    execute_from_command_line([sys.argv[0], *unparsed_args])
  File "/usr/local/lib/python3.8/site-packages/django/core/management/__init__.py", line 419, in execute_from_command_line
    utility.execute()
  File "/usr/local/lib/python3.8/site-packages/django/core/management/__init__.py", line 395, in execute
    django.setup()
  File "/usr/local/lib/python3.8/site-packages/django/__init__.py", line 24, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/usr/local/lib/python3.8/site-packages/django/apps/registry.py", line 122, in populate
    app_config.ready()
  File "/source/nautobot/circuits/apps.py", line 14, in ready
    super().ready()
  File "/source/nautobot/core/apps/__init__.py", line 57, in ready
    register_menu_items(menu_items)
  File "/source/nautobot/core/apps/__init__.py", line 174, in register_menu_items
    item.link = reverse(item.link, args=item.args, kwargs=item.kwargs)
  File "/usr/local/lib/python3.8/site-packages/django/urls/base.py", line 54, in reverse
    app_list = resolver.app_dict[ns]
  File "/usr/local/lib/python3.8/site-packages/django/urls/resolvers.py", line 530, in app_dict
    self._populate()
  File "/usr/local/lib/python3.8/site-packages/django/urls/resolvers.py", line 464, in _populate
    for url_pattern in reversed(self.url_patterns):
  File "/usr/local/lib/python3.8/site-packages/django/utils/functional.py", line 48, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/usr/local/lib/python3.8/site-packages/django/urls/resolvers.py", line 602, in url_patterns
    patterns = getattr(self.urlconf_module, "urlpatterns", self.urlconf_module)
  File "/usr/local/lib/python3.8/site-packages/django/utils/functional.py", line 48, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/usr/local/lib/python3.8/site-packages/django/urls/resolvers.py", line 595, in urlconf_module
    return import_module(self.urlconf_name)
  File "/usr/local/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 843, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/source/nautobot/core/urls.py", line 42, in <module>
    path("api/", include("nautobot.core.api.urls")),
  File "/usr/local/lib/python3.8/site-packages/django/urls/conf.py", line 34, in include
    urlconf_module = import_module(urlconf_module)
  File "/usr/local/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 843, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/source/nautobot/core/api/urls.py", line 49, in <module>
    path("extras/", include("nautobot.extras.api.urls")),
  File "/usr/local/lib/python3.8/site-packages/django/urls/conf.py", line 34, in include
    urlconf_module = import_module(urlconf_module)
  File "/usr/local/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 843, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/source/nautobot/extras/api/urls.py", line 54, in <module>
    router.register("jobs", views.JobByNameViewSet)
  File "/usr/local/lib/python3.8/site-packages/rest_framework/routers.py", line 59, in register
    raise ImproperlyConfigured(msg)
django.core.exceptions.ImproperlyConfigured: Router with basename "job" is already registered. Please provide a unique basename for viewset "<class 'nautobot.extras.api.views.JobByNameViewSet'>"
```

This appears to due to https://github.com/nautobot/nautobot/blob/develop/nautobot/extras/api/urls.py#L53-L54, where we're registering two different viewsets under the `/api/extras/jobs/` URL endpoint. This was permitted and valid in DRF 3.14 and earlier; it appears that this new restriction was added in 3.15 by https://github.com/encode/django-rest-framework/pull/8438.

Note that providing a unique basename for one of these viewsets would be a breaking change for us, as it would change the reversible URL names for the updated viewset. Most likely, to preserve the existing URL names, we'll have to figure out a way to merge the two viewset classes into a single combined viewset class; an alternate possibility might be to override the `BaseRouter.is_already_registered()` function to bypass this check and restore the prior functionality?


Discussion:

Resolving patch:
diff --git a/nautobot/core/api/routers.py b/nautobot/core/api/routers.py
--- a/nautobot/core/api/routers.py
+++ b/nautobot/core/api/routers.py
@@ -27,6 +27,31 @@ def __init__(self, *args, view_name=None, view_description=None, **kwargs):
             }
         )
 
+    def register(self, prefix, viewset, basename=None):
+        """
+        Override DRF's BaseRouter.register() to bypass an unnecessary restriction added in version 3.15.0.
+
+        (Reference: https://github.com/encode/django-rest-framework/pull/8438)
+        """
+        if basename is None:
+            basename = self.get_default_basename(viewset)
+
+        # DRF:
+        # if self.is_already_registered(basename):
+        #     msg = (f'Router with basename "{basename}" is already registered. '
+        #            f'Please provide a unique basename for viewset "{viewset}"')
+        #     raise ImproperlyConfigured(msg)
+        #
+        # We bypass this because we have at least one use case (/api/extras/jobs/) where we are *intentionally*
+        # registering two viewsets with the same basename, but have carefully defined them so as not to conflict.
+
+        # resuming standard DRF code...
+        self.registry.append((prefix, viewset, basename))
+
+        # invalidate the urls cache
+        if hasattr(self, "_urls"):
+            del self._urls
+
     def get_api_root_view(self, api_urls=None):
         """
         Wrap DRF's DefaultRouter to return an alphabetized list of endpoints.
diff --git a/nautobot/core/utils/lookup.py b/nautobot/core/utils/lookup.py
--- a/nautobot/core/utils/lookup.py
+++ b/nautobot/core/utils/lookup.py
@@ -335,6 +335,8 @@ def get_url_for_url_pattern(url_pattern):
     url = re.sub(r"<str:\w+>", "string", url)
     # "/dcim/locations/<uuid:pk>/"
     url = re.sub(r"<uuid:\w+>", "00000000-0000-0000-0000-000000000000", url)
+    # "/api/circuits/<drf_format_suffix:format>"
+    url = re.sub(r"<drf_format_suffix:\w+>", ".json", url)
     # tokens in regexp-style router urls, including REST and NautobotUIViewSet:
     # "/extras/^external-integrations/(?P<pk>[^/.]+)/$"
     # "/api/virtualization/^interfaces/(?P<pk>[^/.]+)/$"
diff --git a/nautobot/ipam/api/serializers.py b/nautobot/ipam/api/serializers.py
--- a/nautobot/ipam/api/serializers.py
+++ b/nautobot/ipam/api/serializers.py
@@ -495,6 +495,7 @@ class ServiceSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
     class Meta:
         model = Service
         fields = "__all__"
+        validators = []
         extra_kwargs = {
             "device": {"help_text": "Required if no virtual_machine is specified"},
             "virtual_machine": {"help_text": "Required if no device is specified"},
@@ -504,3 +505,12 @@ class Meta:
         # `device`.
         # list_display_fields = ["name", "parent", "protocol", "ports", "description"]
         list_display_fields = ["name", "device", "protocol", "ports", "description"]
+
+    def validate(self, data):
+        if data.get("device"):
+            validator = UniqueTogetherValidator(queryset=Service.objects.all(), fields=("name", "device"))
+            validator(data, self)
+        if data.get("virtual_machine"):
+            validator = UniqueTogetherValidator(queryset=Service.objects.all(), fields=("name", "virtual_machine"))
+            validator(data, self)
+        return super().validate(data)


Test patch: