mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
Closes #11611: Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
This commit is contained in:
parent
2669068429
commit
7accdd52d8
@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
* [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template
|
* [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template
|
||||||
* [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`)
|
* [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`)
|
||||||
|
* [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
|
||||||
|
@ -1,21 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import ProtectedError
|
from django.db.models import ProtectedError
|
||||||
from django.http import Http404
|
from rest_framework import mixins as drf_mixins
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
|
|
||||||
from extras.models import ExportTemplate
|
|
||||||
from netbox.api.exceptions import SerializerNotFound
|
|
||||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
|
||||||
from utilities.api import get_serializer_for_model
|
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from .mixins import *
|
from . import mixins
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'NetBoxReadOnlyModelViewSet',
|
||||||
'NetBoxModelViewSet',
|
'NetBoxModelViewSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,13 +26,47 @@ HTTP_ACTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet):
|
class BaseViewSet(GenericViewSet):
|
||||||
|
"""
|
||||||
|
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
|
||||||
|
"""
|
||||||
|
def initial(self, request, *args, **kwargs):
|
||||||
|
super().initial(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Restrict the view's QuerySet to allow only the permitted objects
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
if action := HTTP_ACTIONS[request.method]:
|
||||||
|
self.queryset = self.queryset.restrict(request.user, action)
|
||||||
|
|
||||||
|
|
||||||
|
class NetBoxReadOnlyModelViewSet(
|
||||||
|
mixins.BriefModeMixin,
|
||||||
|
mixins.CustomFieldsMixin,
|
||||||
|
mixins.ExportTemplatesMixin,
|
||||||
|
drf_mixins.RetrieveModelMixin,
|
||||||
|
drf_mixins.ListModelMixin,
|
||||||
|
BaseViewSet
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NetBoxModelViewSet(
|
||||||
|
mixins.BulkUpdateModelMixin,
|
||||||
|
mixins.BulkDestroyModelMixin,
|
||||||
|
mixins.ObjectValidationMixin,
|
||||||
|
mixins.BriefModeMixin,
|
||||||
|
mixins.CustomFieldsMixin,
|
||||||
|
mixins.ExportTemplatesMixin,
|
||||||
|
drf_mixins.CreateModelMixin,
|
||||||
|
drf_mixins.RetrieveModelMixin,
|
||||||
|
drf_mixins.UpdateModelMixin,
|
||||||
|
drf_mixins.DestroyModelMixin,
|
||||||
|
drf_mixins.ListModelMixin,
|
||||||
|
BaseViewSet
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
||||||
"""
|
"""
|
||||||
brief = False
|
|
||||||
brief_prefetch_fields = []
|
|
||||||
|
|
||||||
def get_object_with_snapshot(self):
|
def get_object_with_snapshot(self):
|
||||||
"""
|
"""
|
||||||
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
|
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
|
||||||
@ -48,71 +78,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
# If a list of objects has been provided, initialize the serializer with many=True
|
# If a list of objects has been provided, initialize the serializer with many=True
|
||||||
if isinstance(kwargs.get('data', {}), list):
|
if isinstance(kwargs.get('data', {}), list):
|
||||||
kwargs['many'] = True
|
kwargs['many'] = True
|
||||||
|
|
||||||
return super().get_serializer(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
|
||||||
|
|
||||||
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
|
|
||||||
if self.brief:
|
|
||||||
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
|
||||||
try:
|
|
||||||
serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
|
|
||||||
logger.debug(f"Using serializer {serializer}")
|
|
||||||
return serializer
|
|
||||||
except SerializerNotFound:
|
|
||||||
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
|
|
||||||
|
|
||||||
# Fall back to the hard-coded serializer class
|
|
||||||
logger.debug(f"Using serializer {self.serializer_class}")
|
|
||||||
return self.serializer_class
|
|
||||||
|
|
||||||
def get_serializer_context(self):
|
|
||||||
"""
|
|
||||||
For models which support custom fields, populate the `custom_fields` context.
|
|
||||||
"""
|
|
||||||
context = super().get_serializer_context()
|
|
||||||
|
|
||||||
if hasattr(self.queryset.model, 'custom_fields'):
|
|
||||||
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
|
||||||
context.update({
|
|
||||||
'custom_fields': content_type.custom_fields.all(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
|
||||||
if self.brief:
|
|
||||||
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
|
||||||
|
|
||||||
return super().get_queryset()
|
|
||||||
|
|
||||||
def initialize_request(self, request, *args, **kwargs):
|
|
||||||
# Check if brief=True has been passed
|
|
||||||
if request.method == 'GET' and request.GET.get('brief'):
|
|
||||||
self.brief = True
|
|
||||||
|
|
||||||
return super().initialize_request(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def initial(self, request, *args, **kwargs):
|
|
||||||
super().initial(request, *args, **kwargs)
|
|
||||||
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Restrict the view's QuerySet to allow only the permitted objects
|
|
||||||
action = HTTP_ACTIONS[request.method]
|
|
||||||
if action:
|
|
||||||
self.queryset = self.queryset.restrict(request.user, action)
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
@ -136,21 +109,11 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
# Creates
|
||||||
# Overrides ListModelMixin to allow processing ExportTemplates.
|
|
||||||
if 'export' in request.GET:
|
|
||||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
|
||||||
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
|
|
||||||
if et is None:
|
|
||||||
raise Http404
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
return et.render_to_response(queryset)
|
|
||||||
|
|
||||||
return super().list(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
model = self.queryset.model
|
model = self.queryset.model
|
||||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||||
logger.info(f"Creating new {model._meta.verbose_name}")
|
logger.info(f"Creating new {model._meta.verbose_name}")
|
||||||
|
|
||||||
# Enforce object-level permissions on save()
|
# Enforce object-level permissions on save()
|
||||||
@ -161,6 +124,8 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
# Updates
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||||
self.get_object = self.get_object_with_snapshot
|
self.get_object = self.get_object_with_snapshot
|
||||||
@ -168,7 +133,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
model = self.queryset.model
|
model = self.queryset.model
|
||||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||||
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
|
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
|
||||||
|
|
||||||
# Enforce object-level permissions on save()
|
# Enforce object-level permissions on save()
|
||||||
@ -179,6 +144,8 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
# Deletes
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||||
self.get_object = self.get_object_with_snapshot
|
self.get_object = self.get_object_with_snapshot
|
||||||
@ -186,7 +153,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
model = self.queryset.model
|
model = self.queryset.model
|
||||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||||
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
||||||
|
|
||||||
return super().perform_destroy(instance)
|
return super().perform_destroy(instance)
|
||||||
|
@ -1,17 +1,99 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.http import Http404
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from extras.models import ExportTemplate
|
||||||
|
from netbox.api.exceptions import SerializerNotFound
|
||||||
from netbox.api.serializers import BulkOperationSerializer
|
from netbox.api.serializers import BulkOperationSerializer
|
||||||
|
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||||
|
from utilities.api import get_serializer_for_model
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BriefModeMixin',
|
||||||
'BulkUpdateModelMixin',
|
'BulkUpdateModelMixin',
|
||||||
|
'CustomFieldsMixin',
|
||||||
|
'ExportTemplatesMixin',
|
||||||
'BulkDestroyModelMixin',
|
'BulkDestroyModelMixin',
|
||||||
'ObjectValidationMixin',
|
'ObjectValidationMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BriefModeMixin:
|
||||||
|
"""
|
||||||
|
Enables brief mode support, so that the client can invoke a model's nested serializer by passing e.g.
|
||||||
|
GET /api/dcim/sites/?brief=True
|
||||||
|
"""
|
||||||
|
brief = False
|
||||||
|
brief_prefetch_fields = []
|
||||||
|
|
||||||
|
def initialize_request(self, request, *args, **kwargs):
|
||||||
|
# Annotate whether brief mode is active
|
||||||
|
self.brief = request.method == 'GET' and request.GET.get('brief')
|
||||||
|
|
||||||
|
return super().initialize_request(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||||
|
|
||||||
|
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
|
||||||
|
if self.brief:
|
||||||
|
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
||||||
|
try:
|
||||||
|
return get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
|
||||||
|
except SerializerNotFound:
|
||||||
|
logger.debug(
|
||||||
|
f"Nested serializer for {self.queryset.model} not found! Using serializer {self.serializer_class}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.serializer_class
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
|
||||||
|
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||||
|
if self.brief:
|
||||||
|
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldsMixin:
|
||||||
|
"""
|
||||||
|
For models which support custom fields, populate the `custom_fields` context.
|
||||||
|
"""
|
||||||
|
def get_serializer_context(self):
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
|
||||||
|
if hasattr(self.queryset.model, 'custom_fields'):
|
||||||
|
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||||
|
context.update({
|
||||||
|
'custom_fields': content_type.custom_fields.all(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ExportTemplatesMixin:
|
||||||
|
"""
|
||||||
|
Enable ExportTemplate support for list views.
|
||||||
|
"""
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
if 'export' in request.GET:
|
||||||
|
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||||
|
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
|
||||||
|
if et is None:
|
||||||
|
raise Http404
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
return et.render_to_response(queryset)
|
||||||
|
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class BulkUpdateModelMixin:
|
class BulkUpdateModelMixin:
|
||||||
"""
|
"""
|
||||||
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
|
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
|
||||||
|
Loading…
Reference in New Issue
Block a user