diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 9b49cc1f3..c3ee1e827 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -14,6 +14,7 @@ * [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query * [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer * [#4996](https://github.com/netbox-community/netbox/issues/4996) - Add "connect" buttons to individual device component views +* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically ### Bug Fixes diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 01fbfb62c..0bcb2d280 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class CircuitsRootView(routers.APIRootView): - """ - Circuits API root view - """ - def get_view_name(self): - return 'Circuits' - - -router = routers.DefaultRouter() -router.APIRootView = CircuitsRootView +router = OrderedDefaultRouter() +router.APIRootView = views.CircuitsRootView # Providers router.register('providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index fec22f604..746ee02f6 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -2,6 +2,7 @@ from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.routers import APIRootView from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit @@ -12,6 +13,14 @@ from utilities.api import ModelViewSet from . import serializers +class CircuitsRootView(APIRootView): + """ + Circuits API root view + """ + def get_view_name(self): + return 'Circuits' + + # # Providers # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index f989d817c..e8c4fbe1d 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class DCIMRootView(routers.APIRootView): - """ - DCIM API root view - """ - def get_view_name(self): - return 'DCIM' - - -router = routers.DefaultRouter() -router.APIRootView = DCIMRootView +router = OrderedDefaultRouter() +router.APIRootView = views.DCIMRootView # Sites router.register('regions', views.RegionViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d7bb8b09c..f5b37021d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin from rest_framework.response import Response +from rest_framework.routers import APIRootView from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit @@ -36,6 +37,14 @@ from . import serializers from .exceptions import MissingFilterException +class DCIMRootView(APIRootView): + """ + DCIM API root view + """ + def get_view_name(self): + return 'DCIM' + + # Mixins class CableTraceMixin(object): diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 9927215df..9c50c9a45 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class ExtrasRootView(routers.APIRootView): - """ - Extras API root view - """ - def get_view_name(self): - return 'Extras' - - -router = routers.DefaultRouter() -router.APIRootView = ExtrasRootView +router = OrderedDefaultRouter() +router.APIRootView = views.ExtrasRootView # Custom field choices router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1e4886634..289a51c83 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -8,6 +8,7 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response +from rest_framework.routers import APIRootView from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rq import Worker @@ -25,6 +26,14 @@ from utilities.utils import copy_safe_request from . import serializers +class ExtrasRootView(APIRootView): + """ + Extras API root view + """ + def get_view_name(self): + return 'Extras' + + # # Custom field choices # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index ff0ea32a8..e297d6451 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class IPAMRootView(routers.APIRootView): - """ - IPAM API root view - """ - def get_view_name(self): - return 'IPAM' - - -router = routers.DefaultRouter() -router.APIRootView = IPAMRootView +router = OrderedDefaultRouter() +router.APIRootView = views.IPAMRootView # VRFs router.register('vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 8abf2e551..0d273e4d8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,11 +1,12 @@ from django.conf import settings -from django.db.models import Count, Prefetch +from django.db.models import Count from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filters @@ -16,6 +17,14 @@ from utilities.utils import get_subquery from . import serializers +class IPAMRootView(APIRootView): + """ + IPAM API root view + """ + def get_view_name(self): + return 'IPAM' + + # # VRFs # diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 7ae2ae9ac..5ad05b09e 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class SecretsRootView(routers.APIRootView): - """ - Secrets API root view - """ - def get_view_name(self): - return 'Secrets' - - -router = routers.DefaultRouter() -router.APIRootView = SecretsRootView +router = OrderedDefaultRouter() +router.APIRootView = views.SecretsRootView # Secrets router.register('secret-roles', views.SecretRoleViewSet) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 4c88e30ea..7db6f92b6 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -6,6 +6,7 @@ from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet from secrets import filters @@ -20,6 +21,14 @@ ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." +class SecretsRootView(APIRootView): + """ + Secrets API root view + """ + def get_view_name(self): + return 'Secrets' + + # # Secret Roles # diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 645cc2edc..ad4424005 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class TenancyRootView(routers.APIRootView): - """ - Tenancy API root view - """ - def get_view_name(self): - return 'Tenancy' - - -router = routers.DefaultRouter() -router.APIRootView = TenancyRootView +router = OrderedDefaultRouter() +router.APIRootView = views.TenancyRootView # Tenants router.register('tenant-groups', views.TenantGroupViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 652544b21..065d3a9f3 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,3 +1,5 @@ +from rest_framework.routers import APIRootView + from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet @@ -10,6 +12,14 @@ from virtualization.models import VirtualMachine from . import serializers +class TenancyRootView(APIRootView): + """ + Tenancy API root view + """ + def get_view_name(self): + return 'Tenancy' + + # # Tenant Groups # diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index a81b56bed..c52c6c87f 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class UsersRootView(routers.APIRootView): - """ - Users API root view - """ - def get_view_name(self): - return 'Users' - - -router = routers.DefaultRouter() -router.APIRootView = UsersRootView +router = OrderedDefaultRouter() +router.APIRootView = views.UsersRootView # Users and groups router.register('users', views.UserViewSet) diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index a971592e0..a3536e960 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import Group, User from django.db.models import Count +from rest_framework.routers import APIRootView from users import filters from users.models import ObjectPermission @@ -8,6 +9,14 @@ from utilities.querysets import RestrictedQuerySet from . import serializers +class UsersRootView(APIRootView): + """ + Users API root view + """ + def get_view_name(self): + return 'Users' + + # # Users and groups # diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 1a0f912ba..cc9789161 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -13,6 +13,7 @@ from rest_framework.exceptions import APIException, ValidationError from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.response import Response +from rest_framework.routers import DefaultRouter from rest_framework.viewsets import ModelViewSet as _ModelViewSet from .utils import dict_to_filter_params, dynamic_import @@ -399,3 +400,21 @@ class ModelViewSet(_ModelViewSet): logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) + + +# +# Routers +# + +class OrderedDefaultRouter(DefaultRouter): + + def get_api_root_view(self, api_urls=None): + """ + Wrap DRF's DefaultRouter to return an alphabetized list of endpoints. + """ + api_root_dict = OrderedDict() + list_name = self.routes[0].name + for prefix, viewset, basename in sorted(self.registry, key=lambda x: x[0]): + api_root_dict[prefix] = list_name.format(basename=basename) + + return self.APIRootView.as_view(api_root_dict=api_root_dict) diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 3f6c56a48..c40202a7d 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class VirtualizationRootView(routers.APIRootView): - """ - Virtualization API root view - """ - def get_view_name(self): - return 'Virtualization' - - -router = routers.DefaultRouter() -router.APIRootView = VirtualizationRootView +router = OrderedDefaultRouter() +router.APIRootView = views.VirtualizationRootView # Clusters router.register('cluster-types', views.ClusterTypeViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 62a551df3..1bf41c2b7 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -2,6 +2,7 @@ from django.db.models import Count from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.routers import APIRootView from dcim.models import Device from extras.api.serializers import RenderedGraphSerializer @@ -14,6 +15,14 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac from . import serializers +class VirtualizationRootView(APIRootView): + """ + Virtualization API root view + """ + def get_view_name(self): + return 'Virtualization' + + # # Clusters #