Skip to content

Commit 7accdd5

Browse files
committed
Closes #11611: Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
1 parent 2669068 commit 7accdd5

File tree

3 files changed

+134
-84
lines changed

3 files changed

+134
-84
lines changed

docs/release-notes/version-3.5.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313

1414
* [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template
1515
* [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`)
16+
* [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
+51-84
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
11
import logging
22

3-
from django.contrib.contenttypes.models import ContentType
43
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
54
from django.db import transaction
65
from django.db.models import ProtectedError
7-
from django.http import Http404
6+
from rest_framework import mixins as drf_mixins
87
from rest_framework.response import Response
9-
from rest_framework.viewsets import ModelViewSet
8+
from rest_framework.viewsets import GenericViewSet
109

11-
from extras.models import ExportTemplate
12-
from netbox.api.exceptions import SerializerNotFound
13-
from netbox.constants import NESTED_SERIALIZER_PREFIX
14-
from utilities.api import get_serializer_for_model
1510
from utilities.exceptions import AbortRequest
16-
from .mixins import *
11+
from . import mixins
1712

1813
__all__ = (
14+
'NetBoxReadOnlyModelViewSet',
1915
'NetBoxModelViewSet',
2016
)
2117

@@ -30,13 +26,47 @@
3026
}
3127

3228

33-
class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet):
29+
class BaseViewSet(GenericViewSet):
3430
"""
35-
Extend DRF's ModelViewSet to support bulk update and delete functions.
31+
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
3632
"""
37-
brief = False
38-
brief_prefetch_fields = []
33+
def initial(self, request, *args, **kwargs):
34+
super().initial(request, *args, **kwargs)
3935

36+
# Restrict the view's QuerySet to allow only the permitted objects
37+
if request.user.is_authenticated:
38+
if action := HTTP_ACTIONS[request.method]:
39+
self.queryset = self.queryset.restrict(request.user, action)
40+
41+
42+
class NetBoxReadOnlyModelViewSet(
43+
mixins.BriefModeMixin,
44+
mixins.CustomFieldsMixin,
45+
mixins.ExportTemplatesMixin,
46+
drf_mixins.RetrieveModelMixin,
47+
drf_mixins.ListModelMixin,
48+
BaseViewSet
49+
):
50+
pass
51+
52+
53+
class NetBoxModelViewSet(
54+
mixins.BulkUpdateModelMixin,
55+
mixins.BulkDestroyModelMixin,
56+
mixins.ObjectValidationMixin,
57+
mixins.BriefModeMixin,
58+
mixins.CustomFieldsMixin,
59+
mixins.ExportTemplatesMixin,
60+
drf_mixins.CreateModelMixin,
61+
drf_mixins.RetrieveModelMixin,
62+
drf_mixins.UpdateModelMixin,
63+
drf_mixins.DestroyModelMixin,
64+
drf_mixins.ListModelMixin,
65+
BaseViewSet
66+
):
67+
"""
68+
Extend DRF's ModelViewSet to support bulk update and delete functions.
69+
"""
4070
def get_object_with_snapshot(self):
4171
"""
4272
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
@@ -48,71 +78,14 @@ def get_object_with_snapshot(self):
4878
return obj
4979

5080
def get_serializer(self, *args, **kwargs):
51-
5281
# If a list of objects has been provided, initialize the serializer with many=True
5382
if isinstance(kwargs.get('data', {}), list):
5483
kwargs['many'] = True
5584

5685
return super().get_serializer(*args, **kwargs)
5786

58-
def get_serializer_class(self):
59-
logger = logging.getLogger('netbox.api.views.ModelViewSet')
60-
61-
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
62-
if self.brief:
63-
logger.debug("Request is for 'brief' format; initializing nested serializer")
64-
try:
65-
serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
66-
logger.debug(f"Using serializer {serializer}")
67-
return serializer
68-
except SerializerNotFound:
69-
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
70-
71-
# Fall back to the hard-coded serializer class
72-
logger.debug(f"Using serializer {self.serializer_class}")
73-
return self.serializer_class
74-
75-
def get_serializer_context(self):
76-
"""
77-
For models which support custom fields, populate the `custom_fields` context.
78-
"""
79-
context = super().get_serializer_context()
80-
81-
if hasattr(self.queryset.model, 'custom_fields'):
82-
content_type = ContentType.objects.get_for_model(self.queryset.model)
83-
context.update({
84-
'custom_fields': content_type.custom_fields.all(),
85-
})
86-
87-
return context
88-
89-
def get_queryset(self):
90-
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
91-
if self.brief:
92-
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
93-
94-
return super().get_queryset()
95-
96-
def initialize_request(self, request, *args, **kwargs):
97-
# Check if brief=True has been passed
98-
if request.method == 'GET' and request.GET.get('brief'):
99-
self.brief = True
100-
101-
return super().initialize_request(request, *args, **kwargs)
102-
103-
def initial(self, request, *args, **kwargs):
104-
super().initial(request, *args, **kwargs)
105-
106-
if not request.user.is_authenticated:
107-
return
108-
109-
# Restrict the view's QuerySet to allow only the permitted objects
110-
action = HTTP_ACTIONS[request.method]
111-
if action:
112-
self.queryset = self.queryset.restrict(request.user, action)
113-
11487
def dispatch(self, request, *args, **kwargs):
115-
logger = logging.getLogger('netbox.api.views.ModelViewSet')
88+
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
11689

11790
try:
11891
return super().dispatch(request, *args, **kwargs)
@@ -136,21 +109,11 @@ def dispatch(self, request, *args, **kwargs):
136109
**kwargs
137110
)
138111

139-
def list(self, request, *args, **kwargs):
140-
# Overrides ListModelMixin to allow processing ExportTemplates.
141-
if 'export' in request.GET:
142-
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
143-
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
144-
if et is None:
145-
raise Http404
146-
queryset = self.filter_queryset(self.get_queryset())
147-
return et.render_to_response(queryset)
148-
149-
return super().list(request, *args, **kwargs)
112+
# Creates
150113

151114
def perform_create(self, serializer):
152115
model = self.queryset.model
153-
logger = logging.getLogger('netbox.api.views.ModelViewSet')
116+
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
154117
logger.info(f"Creating new {model._meta.verbose_name}")
155118

156119
# Enforce object-level permissions on save()
@@ -161,14 +124,16 @@ def perform_create(self, serializer):
161124
except ObjectDoesNotExist:
162125
raise PermissionDenied()
163126

127+
# Updates
128+
164129
def update(self, request, *args, **kwargs):
165130
# Hotwire get_object() to ensure we save a pre-change snapshot
166131
self.get_object = self.get_object_with_snapshot
167132
return super().update(request, *args, **kwargs)
168133

169134
def perform_update(self, serializer):
170135
model = self.queryset.model
171-
logger = logging.getLogger('netbox.api.views.ModelViewSet')
136+
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
172137
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
173138

174139
# Enforce object-level permissions on save()
@@ -179,14 +144,16 @@ def perform_update(self, serializer):
179144
except ObjectDoesNotExist:
180145
raise PermissionDenied()
181146

147+
# Deletes
148+
182149
def destroy(self, request, *args, **kwargs):
183150
# Hotwire get_object() to ensure we save a pre-change snapshot
184151
self.get_object = self.get_object_with_snapshot
185152
return super().destroy(request, *args, **kwargs)
186153

187154
def perform_destroy(self, instance):
188155
model = self.queryset.model
189-
logger = logging.getLogger('netbox.api.views.ModelViewSet')
156+
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
190157
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
191158

192159
return super().perform_destroy(instance)

netbox/netbox/api/viewsets/mixins.py

+82
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,99 @@
1+
import logging
2+
3+
from django.contrib.contenttypes.models import ContentType
14
from django.core.exceptions import ObjectDoesNotExist
25
from django.db import transaction
6+
from django.http import Http404
37
from rest_framework import status
48
from rest_framework.response import Response
59

10+
from extras.models import ExportTemplate
11+
from netbox.api.exceptions import SerializerNotFound
612
from netbox.api.serializers import BulkOperationSerializer
13+
from netbox.constants import NESTED_SERIALIZER_PREFIX
14+
from utilities.api import get_serializer_for_model
715

816
__all__ = (
17+
'BriefModeMixin',
918
'BulkUpdateModelMixin',
19+
'CustomFieldsMixin',
20+
'ExportTemplatesMixin',
1021
'BulkDestroyModelMixin',
1122
'ObjectValidationMixin',
1223
)
1324

1425

26+
class BriefModeMixin:
27+
"""
28+
Enables brief mode support, so that the client can invoke a model's nested serializer by passing e.g.
29+
GET /api/dcim/sites/?brief=True
30+
"""
31+
brief = False
32+
brief_prefetch_fields = []
33+
34+
def initialize_request(self, request, *args, **kwargs):
35+
# Annotate whether brief mode is active
36+
self.brief = request.method == 'GET' and request.GET.get('brief')
37+
38+
return super().initialize_request(request, *args, **kwargs)
39+
40+
def get_serializer_class(self):
41+
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
42+
43+
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
44+
if self.brief:
45+
logger.debug("Request is for 'brief' format; initializing nested serializer")
46+
try:
47+
return get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
48+
except SerializerNotFound:
49+
logger.debug(
50+
f"Nested serializer for {self.queryset.model} not found! Using serializer {self.serializer_class}"
51+
)
52+
53+
return self.serializer_class
54+
55+
def get_queryset(self):
56+
qs = super().get_queryset()
57+
58+
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
59+
if self.brief:
60+
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
61+
62+
return qs
63+
64+
65+
class CustomFieldsMixin:
66+
"""
67+
For models which support custom fields, populate the `custom_fields` context.
68+
"""
69+
def get_serializer_context(self):
70+
context = super().get_serializer_context()
71+
72+
if hasattr(self.queryset.model, 'custom_fields'):
73+
content_type = ContentType.objects.get_for_model(self.queryset.model)
74+
context.update({
75+
'custom_fields': content_type.custom_fields.all(),
76+
})
77+
78+
return context
79+
80+
81+
class ExportTemplatesMixin:
82+
"""
83+
Enable ExportTemplate support for list views.
84+
"""
85+
def list(self, request, *args, **kwargs):
86+
if 'export' in request.GET:
87+
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
88+
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
89+
if et is None:
90+
raise Http404
91+
queryset = self.filter_queryset(self.get_queryset())
92+
return et.render_to_response(queryset)
93+
94+
return super().list(request, *args, **kwargs)
95+
96+
1597
class BulkUpdateModelMixin:
1698
"""
1799
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one

0 commit comments

Comments
 (0)