diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 076883282..2d3457d2c 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ad5e609e4..88890bf95 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -6,8 +6,8 @@ from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSe from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from .nested_serializers import * diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 0bcb2d280..b496796fe 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 516831983..f64dbc2dd 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -5,7 +5,7 @@ from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet from . import serializers diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 159540ece..d63d32d68 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim import models -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCableSerializer', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6da5a5e3..6008188bb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -18,12 +18,13 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN +from netbox.api import ( + ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, +) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, get_serializer_for_model, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer from .nested_serializers import * diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index e8c4fbe1d..689cb7aa1 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b14c67e65..08105460f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -25,11 +25,12 @@ from dcim.models import ( ) from extras.api.views import CustomFieldModelViewSet from ipam.models import Prefix, VLAN -from utilities.api import ( - get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, -) +from netbox.api.views import ModelViewSet +from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.exceptions import ServiceUnavailable +from netbox.api.metadata import ContentTypeMetadata +from utilities.api import get_serializer_for_model from utilities.utils import get_subquery -from utilities.metadata import ContentTypeMetadata from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 52c9a18ab..a5f11fde6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -6,7 +6,7 @@ from rest_framework.fields import CreateOnlyDefault, Field from extras.choices import * from extras.models import CustomField -from utilities.api import ValidatedModelSerializer +from netbox.api import ValidatedModelSerializer # diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 95c980768..762bfb0d9 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from extras import choices, models +from netbox.api import ChoiceField, WritableNestedSerializer from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ChoiceField, WritableNestedSerializer __all__ = [ 'NestedConfigContextSerializer', diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0071791fa..268a5d7c0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,13 +13,12 @@ from extras.models import ( ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, ) from extras.utils import FeatureQuery +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api.exceptions import SerializerNotFound from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField, - ValidatedModelSerializer, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index d5d7e15b6..917aedca5 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 75a501862..dc0c6ad7c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -15,9 +15,10 @@ from extras.choices import JobResultStatusChoices from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script -from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet +from netbox.api.views import ModelViewSet +from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.metadata import ContentTypeMetadata from utilities.exceptions import RQWorkerNotRunningException -from utilities.metadata import ContentTypeMetadata from utilities.utils import copy_safe_request from . import serializers diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 004ac070c..660db2b22 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from ipam import models -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ed8680a72..31f708c86 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -11,10 +11,9 @@ from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, get_serializer_for_model, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index a8cbf7a29..13a1bc770 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 449ef3245..0d50b6ea7 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -11,7 +11,7 @@ from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery from . import serializers diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py deleted file mode 100644 index 4b60084c6..000000000 --- a/netbox/netbox/api.py +++ /dev/null @@ -1,207 +0,0 @@ -from django.conf import settings -from django.db.models import QuerySet -from rest_framework import authentication, exceptions -from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS -from rest_framework.renderers import BrowsableAPIRenderer -from rest_framework.schemas import coreapi -from rest_framework.utils import formatting - -from users.models import Token - - -def is_custom_action(action): - return action not in { - # Default actions - 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', - # Bulk operations - 'bulk_update', 'bulk_partial_update', 'bulk_destroy', - } - - -# Monkey-patch DRF to treat bulk_destroy() as a non-custom action (see #3436) -coreapi.is_custom_action = is_custom_action - - -# -# Renderers -# - -class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): - """ - Override the built-in BrowsableAPIRenderer to disable HTML forms. - """ - def show_form_for_method(self, *args, **kwargs): - return False - - def get_filter_form(self, data, view, request): - return None - - -# -# Authentication -# - -class TokenAuthentication(authentication.TokenAuthentication): - """ - A custom authentication scheme which enforces Token expiration times. - """ - model = Token - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.prefetch_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") - - # Enforce the Token's expiration time, if one has been set. - if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") - - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - - return token.user, token - - -class TokenPermissions(DjangoObjectPermissions): - """ - Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability - for unsafe requests (POST/PUT/PATCH/DELETE). - """ - # Override the stock perm_map to enforce view permissions - perms_map = { - 'GET': ['%(app_label)s.view_%(model_name)s'], - 'OPTIONS': [], - 'HEAD': ['%(app_label)s.view_%(model_name)s'], - 'POST': ['%(app_label)s.add_%(model_name)s'], - 'PUT': ['%(app_label)s.change_%(model_name)s'], - 'PATCH': ['%(app_label)s.change_%(model_name)s'], - 'DELETE': ['%(app_label)s.delete_%(model_name)s'], - } - - def __init__(self): - - # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. - self.authenticated_users_only = settings.LOGIN_REQUIRED - - super().__init__() - - def _verify_write_permission(self, request): - - # If token authentication is in use, verify that the token allows write operations (for unsafe methods). - if request.method in SAFE_METHODS or request.auth.write_enabled: - return True - - def has_permission(self, request, view): - - # Enforce Token write ability - if isinstance(request.auth, Token) and not self._verify_write_permission(request): - return False - - return super().has_permission(request, view) - - def has_object_permission(self, request, view, obj): - - # Enforce Token write ability - if isinstance(request.auth, Token) and not self._verify_write_permission(request): - return False - - return super().has_object_permission(request, view, obj) - - -# -# Pagination -# - -class OptionalLimitOffsetPagination(LimitOffsetPagination): - """ - Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects - matching a query, but retains the same format as a paginated request. The limit can only be disabled if - MAX_PAGE_SIZE has been set to 0 or None. - """ - - def paginate_queryset(self, queryset, request, view=None): - - if isinstance(queryset, QuerySet): - self.count = queryset.count() - else: - # We're dealing with an iterable, not a QuerySet - self.count = len(queryset) - - self.limit = self.get_limit(request) - self.offset = self.get_offset(request) - self.request = request - - if self.limit and self.count > self.limit and self.template is not None: - self.display_page_controls = True - - if self.count == 0 or self.offset > self.count: - return list() - - if self.limit: - return list(queryset[self.offset:self.offset + self.limit]) - else: - return list(queryset[self.offset:]) - - def get_limit(self, request): - - if self.limit_query_param: - try: - limit = int(request.query_params[self.limit_query_param]) - if limit < 0: - raise ValueError() - # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) - return limit - except (KeyError, ValueError): - pass - - return self.default_limit - - def get_next_link(self): - - # Pagination has been disabled - if not self.limit: - return None - - return super().get_next_link() - - def get_previous_link(self): - - # Pagination has been disabled - if not self.limit: - return None - - return super().get_previous_link() - - -# -# Miscellaneous -# - -def get_view_name(view, suffix=None): - """ - Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. - """ - if hasattr(view, 'queryset'): - # Determine the model name from the queryset. - name = view.queryset.model._meta.verbose_name - name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word - - else: - # Replicate DRF's built-in behavior. - name = view.__class__.__name__ - name = formatting.remove_trailing_string(name, 'View') - name = formatting.remove_trailing_string(name, 'ViewSet') - name = formatting.camelcase_to_spaces(name) - - if suffix: - name += ' ' + suffix - - return name diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py new file mode 100644 index 000000000..afb2a6803 --- /dev/null +++ b/netbox/netbox/api/__init__.py @@ -0,0 +1,30 @@ +from rest_framework.schemas import coreapi + +from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField +from .routers import OrderedDefaultRouter +from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer + + +__all__ = ( + 'BulkOperationSerializer', + 'ChoiceField', + 'ContentTypeField', + 'OrderedDefaultRouter', + 'SerializedPKRelatedField', + 'TimeZoneField', + 'ValidatedModelSerializer', + 'WritableNestedSerializer', +) + + +def is_custom_action(action): + return action not in { + # Default actions + 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', + # Bulk operations + 'bulk_update', 'bulk_partial_update', 'bulk_destroy', + } + + +# Monkey-patch DRF to treat bulk_destroy() as a non-custom action (see #3436) +coreapi.is_custom_action = is_custom_action diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py new file mode 100644 index 000000000..1cb32c1e4 --- /dev/null +++ b/netbox/netbox/api/authentication.py @@ -0,0 +1,84 @@ +from django.conf import settings +from rest_framework import authentication, exceptions +from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS + +from users.models import Token + + +class TokenAuthentication(authentication.TokenAuthentication): + """ + A custom authentication scheme which enforces Token expiration times. + """ + model = Token + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.prefetch_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid token") + + # Enforce the Token's expiration time, if one has been set. + if token.is_expired: + raise exceptions.AuthenticationFailed("Token expired") + + if not token.user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return token.user, token + + +class TokenPermissions(DjangoObjectPermissions): + """ + Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability + for unsafe requests (POST/PUT/PATCH/DELETE). + """ + # Override the stock perm_map to enforce view permissions + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + def __init__(self): + + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. + self.authenticated_users_only = settings.LOGIN_REQUIRED + + super().__init__() + + def _verify_write_permission(self, request): + + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method in SAFE_METHODS or request.auth.write_enabled: + return True + + def has_permission(self, request, view): + + # Enforce Token write ability + if isinstance(request.auth, Token) and not self._verify_write_permission(request): + return False + + return super().has_permission(request, view) + + def has_object_permission(self, request, view, obj): + + # Enforce Token write ability + if isinstance(request.auth, Token) and not self._verify_write_permission(request): + return False + + return super().has_object_permission(request, view, obj) + + +class IsAuthenticatedOrLoginNotRequired(BasePermission): + """ + Returns True if the user is authenticated or LOGIN_REQUIRED is False. + """ + def has_permission(self, request, view): + if not settings.LOGIN_REQUIRED: + return True + return request.user.is_authenticated diff --git a/netbox/netbox/api/exceptions.py b/netbox/netbox/api/exceptions.py new file mode 100644 index 000000000..8c62eee4c --- /dev/null +++ b/netbox/netbox/api/exceptions.py @@ -0,0 +1,10 @@ +from rest_framework.exceptions import APIException + + +class ServiceUnavailable(APIException): + status_code = 503 + default_detail = "Service temporarily unavailable, please try again later." + + +class SerializerNotFound(Exception): + pass diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py new file mode 100644 index 000000000..ca66d97d4 --- /dev/null +++ b/netbox/netbox/api/fields.py @@ -0,0 +1,133 @@ +from collections import OrderedDict + +import pytz +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.relations import PrimaryKeyRelatedField, RelatedField + + +class ChoiceField(serializers.Field): + """ + Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. + + :param choices: An iterable of choices in the form (value, key). + :param allow_blank: Allow blank values in addition to the listed choices. + """ + def __init__(self, choices, allow_blank=False, **kwargs): + self.choiceset = choices + self.allow_blank = allow_blank + self._choices = dict() + + # Unpack grouped choices + for k, v in choices: + if type(v) in [list, tuple]: + for k2, v2 in v: + self._choices[k2] = v2 + else: + self._choices[k] = v + + super().__init__(**kwargs) + + def validate_empty_values(self, data): + # Convert null to an empty string unless allow_null == True + if data is None: + if self.allow_null: + return True, None + else: + data = '' + return super().validate_empty_values(data) + + def to_representation(self, obj): + if obj is '': + return None + return OrderedDict([ + ('value', obj), + ('label', self._choices[obj]) + ]) + + def to_internal_value(self, data): + if data is '': + if self.allow_blank: + return data + raise ValidationError("This field may not be blank.") + + # Provide an explicit error message if the request is trying to write a dict or list + if isinstance(data, (dict, list)): + raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') + + # Check for string representations of boolean/integer values + if hasattr(data, 'lower'): + if data.lower() == 'true': + data = True + elif data.lower() == 'false': + data = False + else: + try: + data = int(data) + except ValueError: + pass + + try: + if data in self._choices: + return data + except TypeError: # Input is an unhashable type + pass + + raise ValidationError(f"{data} is not a valid choice.") + + @property + def choices(self): + return self._choices + + +class ContentTypeField(RelatedField): + """ + Represent a ContentType as '.' + """ + default_error_messages = { + "does_not_exist": "Invalid content type: {content_type}", + "invalid": "Invalid value. Specify a content type as '.'.", + } + + def to_internal_value(self, data): + try: + app_label, model = data.split('.') + return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) + except ObjectDoesNotExist: + self.fail('does_not_exist', content_type=data) + except (TypeError, ValueError): + self.fail('invalid') + + def to_representation(self, obj): + return "{}.{}".format(obj.app_label, obj.model) + + +class TimeZoneField(serializers.Field): + """ + Represent a pytz time zone. + """ + def to_representation(self, obj): + return obj.zone if obj else None + + def to_internal_value(self, data): + if not data: + return "" + if data not in pytz.common_timezones: + raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) + return pytz.timezone(data) + + +class SerializedPKRelatedField(PrimaryKeyRelatedField): + """ + Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related + objects in a ManyToManyField while still allowing a set of primary keys to be written. + """ + def __init__(self, serializer, **kwargs): + self.serializer = serializer + self.pk_field = kwargs.pop('pk_field', None) + super().__init__(**kwargs) + + def to_representation(self, value): + return self.serializer(value, context={'request': self.context['request']}).data diff --git a/netbox/utilities/metadata.py b/netbox/netbox/api/metadata.py similarity index 94% rename from netbox/utilities/metadata.py rename to netbox/netbox/api/metadata.py index 8fd664d5a..1d0397e4d 100644 --- a/netbox/utilities/metadata.py +++ b/netbox/netbox/api/metadata.py @@ -1,6 +1,7 @@ -from rest_framework.metadata import SimpleMetadata from django.utils.encoding import force_str -from utilities.api import ContentTypeField +from rest_framework.metadata import SimpleMetadata + +from netbox.api import ContentTypeField class ContentTypeMetadata(SimpleMetadata): diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py new file mode 100644 index 000000000..d489ce951 --- /dev/null +++ b/netbox/netbox/api/pagination.py @@ -0,0 +1,69 @@ +from django.conf import settings +from django.db.models import QuerySet +from rest_framework.pagination import LimitOffsetPagination + + +class OptionalLimitOffsetPagination(LimitOffsetPagination): + """ + Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects + matching a query, but retains the same format as a paginated request. The limit can only be disabled if + MAX_PAGE_SIZE has been set to 0 or None. + """ + + def paginate_queryset(self, queryset, request, view=None): + + if isinstance(queryset, QuerySet): + self.count = queryset.count() + else: + # We're dealing with an iterable, not a QuerySet + self.count = len(queryset) + + self.limit = self.get_limit(request) + self.offset = self.get_offset(request) + self.request = request + + if self.limit and self.count > self.limit and self.template is not None: + self.display_page_controls = True + + if self.count == 0 or self.offset > self.count: + return list() + + if self.limit: + return list(queryset[self.offset:self.offset + self.limit]) + else: + return list(queryset[self.offset:]) + + def get_limit(self, request): + + if self.limit_query_param: + try: + limit = int(request.query_params[self.limit_query_param]) + if limit < 0: + raise ValueError() + # Enforce maximum page size, if defined + if settings.MAX_PAGE_SIZE: + if limit == 0: + return settings.MAX_PAGE_SIZE + else: + return min(limit, settings.MAX_PAGE_SIZE) + return limit + except (KeyError, ValueError): + pass + + return self.default_limit + + def get_next_link(self): + + # Pagination has been disabled + if not self.limit: + return None + + return super().get_next_link() + + def get_previous_link(self): + + # Pagination has been disabled + if not self.limit: + return None + + return super().get_previous_link() diff --git a/netbox/netbox/api/renderers.py b/netbox/netbox/api/renderers.py new file mode 100644 index 000000000..c492510fb --- /dev/null +++ b/netbox/netbox/api/renderers.py @@ -0,0 +1,12 @@ +from rest_framework.renderers import BrowsableAPIRenderer + + +class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): + """ + Override the built-in BrowsableAPIRenderer to disable HTML forms. + """ + def show_form_for_method(self, *args, **kwargs): + return False + + def get_filter_form(self, data, view, request): + return None diff --git a/netbox/netbox/api/routers.py b/netbox/netbox/api/routers.py new file mode 100644 index 000000000..71df1796e --- /dev/null +++ b/netbox/netbox/api/routers.py @@ -0,0 +1,27 @@ +from collections import OrderedDict + +from rest_framework.routers import DefaultRouter + + +class OrderedDefaultRouter(DefaultRouter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Extend the list view mappings to support the DELETE operation + self.routes[0].mapping.update({ + 'put': 'bulk_update', + 'patch': 'bulk_partial_update', + 'delete': 'bulk_destroy', + }) + + 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/netbox/api/serializers.py b/netbox/netbox/api/serializers.py new file mode 100644 index 000000000..c5ecf9372 --- /dev/null +++ b/netbox/netbox/api/serializers.py @@ -0,0 +1,91 @@ +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import ManyToManyField +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from utilities.utils import dict_to_filter_params + + +# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant +# way to enforce model validation on the serializer. +class ValidatedModelSerializer(serializers.ModelSerializer): + """ + Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. + """ + def validate(self, data): + + # Remove custom fields data and tags (if any) prior to model validation + attrs = data.copy() + attrs.pop('custom_fields', None) + attrs.pop('tags', None) + + # Skip ManyToManyFields + for field in self.Meta.model._meta.get_fields(): + if isinstance(field, ManyToManyField): + attrs.pop(field.name, None) + + # Run clean() on an instance of the model + if self.instance is None: + instance = self.Meta.model(**attrs) + else: + instance = self.instance + for k, v in attrs.items(): + setattr(instance, k, v) + instance.clean() + instance.validate_unique() + + return data + + +class WritableNestedSerializer(serializers.ModelSerializer): + """ + Returns a nested representation of an object on read, but accepts only a primary key on write. + """ + + def to_internal_value(self, data): + + if data is None: + return None + + # Dictionary of related object attributes + if isinstance(data, dict): + params = dict_to_filter_params(data) + queryset = self.Meta.model.objects + try: + return queryset.get(**params) + except ObjectDoesNotExist: + raise ValidationError( + "Related object not found using the provided attributes: {}".format(params) + ) + except MultipleObjectsReturned: + raise ValidationError( + "Multiple objects match the provided attributes: {}".format(params) + ) + except FieldError as e: + raise ValidationError(e) + + # Integer PK of related object + if isinstance(data, int): + pk = data + else: + try: + # PK might have been mistakenly passed as a string + pk = int(data) + except (TypeError, ValueError): + raise ValidationError( + "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " + "unrecognized value: {}".format(data) + ) + + # Look up object by PK + queryset = self.Meta.model.objects + try: + return queryset.get(pk=int(data)) + except ObjectDoesNotExist: + raise ValidationError( + "Related object not found using the provided numeric ID: {}".format(pk) + ) + + +class BulkOperationSerializer(serializers.Serializer): + id = serializers.IntegerField() diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py new file mode 100644 index 000000000..3dd512205 --- /dev/null +++ b/netbox/netbox/api/views.py @@ -0,0 +1,220 @@ +import logging + +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import transaction +from django.db.models import ProtectedError +from rest_framework import mixins, status +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from netbox.api import BulkOperationSerializer +from netbox.api.exceptions import SerializerNotFound +from utilities.api import get_serializer_for_model + +HTTP_ACTIONS = { + 'GET': 'view', + 'OPTIONS': None, + 'HEAD': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', +} + + +# +# Mixins +# + +class BulkUpdateModelMixin: + """ + Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. + For example: + + PATCH /api/dcim/sites/ + [ + { + "id": 123, + "name": "New name" + }, + { + "id": 456, + "status": "planned" + } + ] + """ + def bulk_update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + serializer = BulkOperationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) + + # Map update data by object ID + update_data = { + obj.pop('id'): obj for obj in request.data + } + + self.perform_bulk_update(qs, update_data, partial=partial) + + return Response(status=status.HTTP_200_OK) + + def perform_bulk_update(self, objects, update_data, partial): + with transaction.atomic(): + for obj in objects: + data = update_data.get(obj.id) + serializer = self.get_serializer(obj, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + def bulk_partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.bulk_update(request, *args, **kwargs) + + +class BulkDestroyModelMixin: + """ + Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be deleted. For example: + + DELETE /api/dcim/sites/ + [ + {"id": 123}, + {"id": 456} + ] + """ + def bulk_destroy(self, request, *args, **kwargs): + serializer = BulkOperationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) + + self.perform_bulk_destroy(qs) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def perform_bulk_destroy(self, objects): + with transaction.atomic(): + for obj in objects: + self.perform_destroy(obj) + + +# +# Viewsets +# + +class ModelViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + BulkUpdateModelMixin, + BulkDestroyModelMixin, + GenericViewSet): + """ + Accept either a single object or a list of objects to create. + """ + def get_serializer(self, *args, **kwargs): + + # If a list of objects has been provided, initialize the serializer with many=True + if isinstance(kwargs.get('data', {}), list): + kwargs['many'] = True + + return super().get_serializer(*args, **kwargs) + + def get_serializer_class(self): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + + # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one + # exists + request = self.get_serializer_context()['request'] + if request.query_params.get('brief'): + logger.debug("Request is for 'brief' format; initializing nested serializer") + try: + serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') + logger.debug(f"Using serializer {serializer}") + return serializer + except SerializerNotFound: + pass + + # Fall back to the hard-coded serializer class + logger.debug(f"Using serializer {self.serializer_class}") + return self.serializer_class + + 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): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + + try: + return super().dispatch(request, *args, **kwargs) + except ProtectedError as e: + protected_objects = list(e.protected_objects) + msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' + msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) + logger.warning(msg) + return self.finalize_response( + request, + Response({'detail': msg}, status=409), + *args, + **kwargs + ) + + def _validate_objects(self, instance): + """ + Check that the provided instance or list of instances are matched by the current queryset. This confirms that + any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. + """ + if type(instance) is list: + # Check that all instances are still included in the view's queryset + conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() + if conforming_count != len(instance): + raise ObjectDoesNotExist + else: + # Check that the instance is matched by the view's queryset + self.queryset.get(pk=instance.pk) + + def perform_create(self, serializer): + model = self.queryset.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Creating new {model._meta.verbose_name}") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() + + def perform_update(self, serializer): + model = self.queryset.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() + + def perform_destroy(self, instance): + model = self.queryset.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") + + return super().perform_destroy(instance) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 353c9d356..ed64a71a0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -461,18 +461,18 @@ REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', - 'netbox.api.TokenAuthentication', + 'netbox.api.authentication.TokenAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), - 'DEFAULT_PAGINATION_CLASS': 'netbox.api.OptionalLimitOffsetPagination', + 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( - 'netbox.api.TokenPermissions', + 'netbox.api.authentication.TokenPermissions', ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - 'netbox.api.FormlessBrowsableAPIRenderer', + 'netbox.api.renderers.FormlessBrowsableAPIRenderer', ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', @@ -484,7 +484,7 @@ REST_FRAMEWORK = { # Custom operations 'bulk_destroy': 'bulk_delete', }, - 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', + 'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name', } diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py index 13c016c18..aaec27c1f 100644 --- a/netbox/secrets/api/nested_serializers.py +++ b/netbox/secrets/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from netbox.api import WritableNestedSerializer from secrets.models import Secret, SecretRole -from utilities.api import WritableNestedSerializer __all__ = [ 'NestedSecretRoleSerializer', diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 1fd3f19ef..b08b87bc5 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -6,7 +6,8 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole -from utilities.api import ContentTypeField, ValidatedModelSerializer, get_serializer_for_model +from netbox.api import ContentTypeField, ValidatedModelSerializer +from utilities.api import get_serializer_for_model from .nested_serializers import * diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 5ad05b09e..4000177b2 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 33cddea2b..940d23a0b 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -9,10 +9,10 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet +from netbox.api.views import ModelViewSet from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import ModelViewSet from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 369d5eb1b..7b227c123 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from netbox.api import WritableNestedSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import WritableNestedSerializer __all__ = [ 'NestedTenantGroupSerializer', diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 4467b050b..05e83853e 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,8 +2,8 @@ from rest_framework import serializers from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ValidatedModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer from .nested_serializers import * diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index ad4424005..32540879d 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 065d3a9f3..34be4991e 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,9 +4,9 @@ from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF +from netbox.api.views import ModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index f1bcf3b37..3b43ca7c9 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -2,8 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from netbox.api import ContentTypeField, WritableNestedSerializer from users.models import ObjectPermission -from utilities.api import ContentTypeField, WritableNestedSerializer __all__ = [ 'NestedGroupSerializer', diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1f338d6e4..31e7915b8 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,8 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from users.models import ObjectPermission -from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from .nested_serializers import * diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index c52c6c87f..4176cc806 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index a3536e960..b799bee19 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -2,9 +2,9 @@ from django.contrib.auth.models import Group, User from django.db.models import Count from rest_framework.routers import APIRootView +from netbox.api.views import ModelViewSet from users import filters from users.models import ObjectPermission -from utilities.api import ModelViewSet from utilities.querysets import RestrictedQuerySet from . import serializers diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index e652656d7..958cdf9a3 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,41 +1,8 @@ -import logging -from collections import OrderedDict - -import pytz -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied -from django.db import transaction -from django.db.models import ManyToManyField, ProtectedError from django.urls import reverse -from rest_framework import mixins, serializers, status -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 GenericViewSet +from rest_framework.utils import formatting -from .utils import dict_to_filter_params, dynamic_import - -HTTP_ACTIONS = { - 'GET': 'view', - 'OPTIONS': None, - 'HEAD': 'view', - 'POST': 'add', - 'PUT': 'change', - 'PATCH': 'change', - 'DELETE': 'delete', -} - - -class ServiceUnavailable(APIException): - status_code = 503 - default_detail = "Service temporarily unavailable, please try again later." - - -class SerializerNotFound(Exception): - pass +from netbox.api.exceptions import SerializerNotFound +from .utils import dynamic_import def get_serializer_for_model(model, prefix=''): @@ -63,459 +30,23 @@ def is_api_request(request): return request.path_info.startswith(api_path) -# -# Authentication -# - -class IsAuthenticatedOrLoginNotRequired(BasePermission): +def get_view_name(view, suffix=None): """ - Returns True if the user is authenticated or LOGIN_REQUIRED is False. + Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. """ - def has_permission(self, request, view): - if not settings.LOGIN_REQUIRED: - return True - return request.user.is_authenticated - - -# -# Fields -# - -class ChoiceField(serializers.Field): - """ - Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. - - :param choices: An iterable of choices in the form (value, key). - :param allow_blank: Allow blank values in addition to the listed choices. - """ - def __init__(self, choices, allow_blank=False, **kwargs): - self.choiceset = choices - self.allow_blank = allow_blank - self._choices = dict() - - # Unpack grouped choices - for k, v in choices: - if type(v) in [list, tuple]: - for k2, v2 in v: - self._choices[k2] = v2 - else: - self._choices[k] = v - - super().__init__(**kwargs) - - def validate_empty_values(self, data): - # Convert null to an empty string unless allow_null == True - if data is None: - if self.allow_null: - return True, None - else: - data = '' - return super().validate_empty_values(data) - - def to_representation(self, obj): - if obj is '': - return None - return OrderedDict([ - ('value', obj), - ('label', self._choices[obj]) - ]) - - def to_internal_value(self, data): - if data is '': - if self.allow_blank: - return data - raise ValidationError("This field may not be blank.") - - # Provide an explicit error message if the request is trying to write a dict or list - if isinstance(data, (dict, list)): - raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') - - # Check for string representations of boolean/integer values - if hasattr(data, 'lower'): - if data.lower() == 'true': - data = True - elif data.lower() == 'false': - data = False - else: - try: - data = int(data) - except ValueError: - pass - - try: - if data in self._choices: - return data - except TypeError: # Input is an unhashable type - pass - - raise ValidationError(f"{data} is not a valid choice.") - - @property - def choices(self): - return self._choices - - -class ContentTypeField(RelatedField): - """ - Represent a ContentType as '.' - """ - default_error_messages = { - "does_not_exist": "Invalid content type: {content_type}", - "invalid": "Invalid value. Specify a content type as '.'.", - } - - def to_internal_value(self, data): - try: - app_label, model = data.split('.') - return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) - except ObjectDoesNotExist: - self.fail('does_not_exist', content_type=data) - except (TypeError, ValueError): - self.fail('invalid') - - def to_representation(self, obj): - return "{}.{}".format(obj.app_label, obj.model) - - -class TimeZoneField(serializers.Field): - """ - Represent a pytz time zone. - """ - def to_representation(self, obj): - return obj.zone if obj else None - - def to_internal_value(self, data): - if not data: - return "" - if data not in pytz.common_timezones: - raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) - return pytz.timezone(data) - - -class SerializedPKRelatedField(PrimaryKeyRelatedField): - """ - Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related - objects in a ManyToManyField while still allowing a set of primary keys to be written. - """ - def __init__(self, serializer, **kwargs): - self.serializer = serializer - self.pk_field = kwargs.pop('pk_field', None) - super().__init__(**kwargs) - - def to_representation(self, value): - return self.serializer(value, context={'request': self.context['request']}).data - - -# -# Serializers -# - -# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant -# way to enforce model validation on the serializer. -class ValidatedModelSerializer(serializers.ModelSerializer): - """ - Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. - """ - def validate(self, data): - - # Remove custom fields data and tags (if any) prior to model validation - attrs = data.copy() - attrs.pop('custom_fields', None) - attrs.pop('tags', None) - - # Skip ManyToManyFields - for field in self.Meta.model._meta.get_fields(): - if isinstance(field, ManyToManyField): - attrs.pop(field.name, None) - - # Run clean() on an instance of the model - if self.instance is None: - instance = self.Meta.model(**attrs) - else: - instance = self.instance - for k, v in attrs.items(): - setattr(instance, k, v) - instance.clean() - instance.validate_unique() - - return data - - -class WritableNestedSerializer(serializers.ModelSerializer): - """ - Returns a nested representation of an object on read, but accepts only a primary key on write. - """ - - def to_internal_value(self, data): - - if data is None: - return None - - # Dictionary of related object attributes - if isinstance(data, dict): - params = dict_to_filter_params(data) - queryset = self.Meta.model.objects - try: - return queryset.get(**params) - except ObjectDoesNotExist: - raise ValidationError( - "Related object not found using the provided attributes: {}".format(params) - ) - except MultipleObjectsReturned: - raise ValidationError( - "Multiple objects match the provided attributes: {}".format(params) - ) - except FieldError as e: - raise ValidationError(e) - - # Integer PK of related object - if isinstance(data, int): - pk = data - else: - try: - # PK might have been mistakenly passed as a string - pk = int(data) - except (TypeError, ValueError): - raise ValidationError( - "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " - "unrecognized value: {}".format(data) - ) - - # Look up object by PK - queryset = self.Meta.model.objects - try: - return queryset.get(pk=int(data)) - except ObjectDoesNotExist: - raise ValidationError( - "Related object not found using the provided numeric ID: {}".format(pk) - ) - - -class BulkOperationSerializer(serializers.Serializer): - id = serializers.IntegerField() - - -# -# Mixins -# - -class BulkUpdateModelMixin: - """ - Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one - or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. - For example: - - PATCH /api/dcim/sites/ - [ - { - "id": 123, - "name": "New name" - }, - { - "id": 456, - "status": "planned" - } - ] - """ - def bulk_update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - serializer = BulkOperationSerializer(data=request.data, many=True) - serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( - pk__in=[o['id'] for o in serializer.data] - ) - - # Map update data by object ID - update_data = { - obj.pop('id'): obj for obj in request.data - } - - self.perform_bulk_update(qs, update_data, partial=partial) - - return Response(status=status.HTTP_200_OK) - - def perform_bulk_update(self, objects, update_data, partial): - with transaction.atomic(): - for obj in objects: - data = update_data.get(obj.id) - serializer = self.get_serializer(obj, data=data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - - def bulk_partial_update(self, request, *args, **kwargs): - kwargs['partial'] = True - return self.bulk_update(request, *args, **kwargs) - - -class BulkDestroyModelMixin: - """ - Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one - or more JSON objects, each specifying the numeric ID of an object to be deleted. For example: - - DELETE /api/dcim/sites/ - [ - {"id": 123}, - {"id": 456} - ] - """ - def bulk_destroy(self, request, *args, **kwargs): - serializer = BulkOperationSerializer(data=request.data, many=True) - serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( - pk__in=[o['id'] for o in serializer.data] - ) - - self.perform_bulk_destroy(qs) - - return Response(status=status.HTTP_204_NO_CONTENT) - - def perform_bulk_destroy(self, objects): - with transaction.atomic(): - for obj in objects: - self.perform_destroy(obj) - - -# -# Viewsets -# - -class ModelViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - BulkUpdateModelMixin, - BulkDestroyModelMixin, - GenericViewSet): - """ - Accept either a single object or a list of objects to create. - """ - def get_serializer(self, *args, **kwargs): - - # If a list of objects has been provided, initialize the serializer with many=True - if isinstance(kwargs.get('data', {}), list): - kwargs['many'] = True - - return super().get_serializer(*args, **kwargs) - - def get_serializer_class(self): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one - # exists - request = self.get_serializer_context()['request'] - if request.query_params.get('brief'): - logger.debug("Request is for 'brief' format; initializing nested serializer") - try: - serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') - logger.debug(f"Using serializer {serializer}") - return serializer - except SerializerNotFound: - pass - - # Fall back to the hard-coded serializer class - logger.debug(f"Using serializer {self.serializer_class}") - return self.serializer_class - - 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): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - try: - return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - protected_objects = list(e.protected_objects) - msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' - msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) - logger.warning(msg) - return self.finalize_response( - request, - Response({'detail': msg}, status=409), - *args, - **kwargs - ) - - def _validate_objects(self, instance): - """ - Check that the provided instance or list of instances are matched by the current queryset. This confirms that - any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. - """ - if type(instance) is list: - # Check that all instances are still included in the view's queryset - conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() - if conforming_count != len(instance): - raise ObjectDoesNotExist - else: - # Check that the instance is matched by the view's queryset - self.queryset.get(pk=instance.pk) - - def perform_create(self, serializer): - model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Creating new {model._meta.verbose_name}") - - # Enforce object-level permissions on save() - try: - with transaction.atomic(): - instance = serializer.save() - self._validate_objects(instance) - except ObjectDoesNotExist: - raise PermissionDenied() - - def perform_update(self, serializer): - model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") - - # Enforce object-level permissions on save() - try: - with transaction.atomic(): - instance = serializer.save() - self._validate_objects(instance) - except ObjectDoesNotExist: - raise PermissionDenied() - - def perform_destroy(self, instance): - model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") - - return super().perform_destroy(instance) - - -# -# Routers -# - -class OrderedDefaultRouter(DefaultRouter): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Extend the list view mappings to support the DELETE operation - self.routes[0].mapping.update({ - 'put': 'bulk_update', - 'patch': 'bulk_partial_update', - 'delete': 'bulk_destroy', - }) - - 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) + if hasattr(view, 'queryset'): + # Determine the model name from the queryset. + name = view.queryset.model._meta.verbose_name + name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word + + else: + # Replicate DRF's built-in behavior. + name = view.__class__.__name__ + name = formatting.remove_trailing_string(name, 'View') + name = formatting.remove_trailing_string(name, 'ViewSet') + name = formatting.camelcase_to_spaces(name) + + if suffix: + name += ' ' + suffix + + return name diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 063d30016..1b931155f 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -6,7 +6,7 @@ from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField from extras.api.customfields import CustomFieldsDataField -from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index de56e6e6a..7763f0ef4 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.models import Interface -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine __all__ = [ diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 711e1359e..3e977d94d 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -7,8 +7,8 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN +from netbox.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .nested_serializers import * diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index c40202a7d..d9df2fcfe 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 9d210459e..a8462ff1d 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView from dcim.models import Device from extras.api.views import CustomFieldModelViewSet -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet from utilities.utils import get_subquery from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface