소스 검색

Merge branch 'develop' into feature

jeremystretch 2 년 전
부모
커밋
59a6b3e71b

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.4.7
+      placeholder: v3.4.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.4.7
+      placeholder: v3.4.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 33 - 33
base_requirements.txt

@@ -1,5 +1,5 @@
 # HTML sanitizer
 # HTML sanitizer
-# https://github.com/mozilla/bleach
+# https://github.com/mozilla/bleach/blob/main/CHANGES
 bleach<6.0
 bleach<6.0
 
 
 # Python client for Amazon AWS API
 # Python client for Amazon AWS API
@@ -7,55 +7,55 @@ bleach<6.0
 boto3
 boto3
 
 
 # The Python web framework on which NetBox is built
 # The Python web framework on which NetBox is built
-# https://github.com/django/django
+# https://docs.djangoproject.com/en/stable/releases/
 Django<4.2
 Django<4.2
 
 
 # Django middleware which permits cross-domain API requests
 # Django middleware which permits cross-domain API requests
-# https://github.com/OttoYiu/django-cors-headers
+# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
 django-cors-headers
 django-cors-headers
 
 
 # Runtime UI tool for debugging Django
 # Runtime UI tool for debugging Django
-# https://github.com/jazzband/django-debug-toolbar
+# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
 django-debug-toolbar
 django-debug-toolbar
 
 
 # Library for writing reusable URL query filters
 # Library for writing reusable URL query filters
-# https://github.com/carltongibson/django-filter
+# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
 django-filter
 django-filter
 
 
 # Django debug toolbar extension with support for GraphiQL
 # Django debug toolbar extension with support for GraphiQL
-# https://github.com/flavors/django-graphiql-debug-toolbar/
+# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
 django-graphiql-debug-toolbar
 django-graphiql-debug-toolbar
 
 
 # Modified Preorder Tree Traversal (recursive nesting of objects)
 # Modified Preorder Tree Traversal (recursive nesting of objects)
-# https://github.com/django-mptt/django-mptt
+# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
 django-mptt
 django-mptt
 
 
 # Context managers for PostgreSQL advisory locks
 # Context managers for PostgreSQL advisory locks
-# https://github.com/Xof/django-pglocks
+# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 django-pglocks
 django-pglocks
 
 
 # Prometheus metrics library for Django
 # Prometheus metrics library for Django
-# https://github.com/korfuri/django-prometheus
+# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
 django-prometheus
 django-prometheus
 
 
 # Django caching backend using Redis
 # Django caching backend using Redis
-# https://github.com/jazzband/django-redis
+# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
 django-redis
 django-redis
 
 
 # Django extensions for Rich (terminal text rendering)
 # Django extensions for Rich (terminal text rendering)
-# https://github.com/adamchainz/django-rich
+# https://github.com/adamchainz/django-rich/blob/main/CHANGELOG.rst
 django-rich
 django-rich
 
 
 # Django integration for RQ (Reqis queuing)
 # Django integration for RQ (Reqis queuing)
-# https://github.com/rq/django-rq
+# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
 django-rq
 django-rq
 
 
 # Abstraction models for rendering and paginating HTML tables
 # Abstraction models for rendering and paginating HTML tables
-# https://github.com/jieter/django-tables2
+# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
 django-tables2
 django-tables2
 
 
 # User-defined tags for objects
 # User-defined tags for objects
-# https://github.com/alex/django-taggit
+# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
 django-taggit
 django-taggit
 
 
 # A Django field for representing time zones
 # A Django field for representing time zones
@@ -63,11 +63,11 @@ django-taggit
 django-timezone-field
 django-timezone-field
 
 
 # A REST API framework for Django projects
 # A REST API framework for Django projects
-# https://github.com/encode/django-rest-framework
+# https://www.django-rest-framework.org/community/release-notes/
 djangorestframework
 djangorestframework
 
 
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
-# https://github.com/tfranzel/drf-spectacular
+# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
 drf-spectacular
 drf-spectacular
 
 
 # Serve self-contained distribution builds of Swagger UI and Redoc with Django.
 # Serve self-contained distribution builds of Swagger UI and Redoc with Django.
@@ -75,23 +75,23 @@ drf-spectacular
 drf-spectacular-sidecar
 drf-spectacular-sidecar
 
 
 # RSS feed parser
 # RSS feed parser
-# https://github.com/kurtmckee/feedparser
+# https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst
 feedparser
 feedparser
 
 
 # Django wrapper for Graphene (GraphQL support)
 # Django wrapper for Graphene (GraphQL support)
-# https://github.com/graphql-python/graphene-django
+# https://github.com/graphql-python/graphene-django/releases
 graphene_django
 graphene_django
 
 
 # WSGI HTTP server
 # WSGI HTTP server
-# https://gunicorn.org/
+# https://docs.gunicorn.org/en/latest/news.html
 gunicorn
 gunicorn
 
 
 # Platform-agnostic template rendering engine
 # Platform-agnostic template rendering engine
-# https://github.com/pallets/jinja
+# https://jinja.palletsprojects.com/changes/
 Jinja2
 Jinja2
 
 
 # Simple markup language for rendering HTML
 # Simple markup language for rendering HTML
-# https://github.com/Python-Markdown/markdown
+# https://python-markdown.github.io/change_log/
 # mkdocs currently requires Markdown v3.3
 # mkdocs currently requires Markdown v3.3
 Markdown<3.4
 Markdown<3.4
 
 
@@ -100,50 +100,50 @@ Markdown<3.4
 markdown-include
 markdown-include
 
 
 # MkDocs Material theme (for documentation build)
 # MkDocs Material theme (for documentation build)
-# https://github.com/squidfunk/mkdocs-material
+# https://squidfunk.github.io/mkdocs-material/changelog/
 mkdocs-material
 mkdocs-material
 
 
 # Introspection for embedded code
 # Introspection for embedded code
-# https://github.com/mkdocstrings/mkdocstrings
+# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md
 mkdocstrings[python-legacy]
 mkdocstrings[python-legacy]
 
 
 # Library for manipulating IP prefixes and addresses
 # Library for manipulating IP prefixes and addresses
-# https://github.com/netaddr/netaddr
+# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
 netaddr
 netaddr
 
 
 # Fork of PIL (Python Imaging Library) for image processing
 # Fork of PIL (Python Imaging Library) for image processing
-# https://github.com/python-pillow/Pillow
+# https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst
 Pillow
 Pillow
 
 
 # PostgreSQL database adapter for Python
 # PostgreSQL database adapter for Python
-# https://github.com/psycopg/psycopg2
+# https://www.psycopg.org/docs/news.html
 psycopg2-binary
 psycopg2-binary
 
 
 # YAML rendering library
 # YAML rendering library
-# https://github.com/yaml/pyyaml
+# https://github.com/yaml/pyyaml/blob/master/CHANGES
 PyYAML
 PyYAML
 
 
 # Sentry SDK
 # Sentry SDK
-# https://github.com/getsentry/sentry-python
+# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
 sentry-sdk
 sentry-sdk
 
 
 # Social authentication framework
 # Social authentication framework
-# https://github.com/python-social-auth/social-core
+# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
 social-auth-core
 social-auth-core
 
 
 # Django app for social-auth-core
 # Django app for social-auth-core
-# https://github.com/python-social-auth/social-app-django
+# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
 # See https://github.com/python-social-auth/social-app-django/issues/429
 # See https://github.com/python-social-auth/social-app-django/issues/429
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 
 
 # SVG image rendering (used for rack elevations)
 # SVG image rendering (used for rack elevations)
-# https://github.com/mozman/svgwrite
+# hhttps://github.com/mozman/svgwrite/blob/master/NEWS.rst
 svgwrite
 svgwrite
 
 
 # Tabular dataset library (for table-based exports)
 # Tabular dataset library (for table-based exports)
-# https://github.com/jazzband/tablib
+# https://github.com/jazzband/tablib/blob/master/HISTORY.md
 tablib
 tablib
 
 
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # Timezone data (required by django-timezone-field on Python 3.9+)
-# https://github.com/python/tzdata
+# https://github.com/python/tzdata/blob/master/NEWS.md
 tzdata
 tzdata

+ 1 - 1
docs/configuration/development.md

@@ -18,4 +18,4 @@ interface.
 
 
 Default: False
 Default: False
 
 
-This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
+This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.

+ 1 - 1
docs/customization/reports.md

@@ -130,7 +130,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
 !!! note
 !!! note
     To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
     To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
 
 
-    ![Adding the run action to a permission](/media/admin_ui_run_permission.png)
+    ![Adding the run action to a permission](../media/admin_ui_run_permission.png)
 
 
 ### Via the Web UI
 ### Via the Web UI
 
 

+ 9 - 0
docs/integrations/rest-api.md

@@ -586,6 +586,15 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
 
 
 Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
 Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
 
 
+#### Creating Tokens for Other Users
+
+It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
+
+![Adding the grant action to a permission](../media/admin_ui_grant_permission.png)
+
+!!! warning "Exercise Caution"
+    The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
+
 ### Authenticating to the API
 ### Authenticating to the API
 
 
 An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token:
 An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token:

BIN
docs/media/admin_ui_grant_permission.png


+ 16 - 1
docs/release-notes/version-3.4.md

@@ -1,16 +1,30 @@
 # NetBox v3.4
 # NetBox v3.4
 
 
-## v3.4.8 (FUTURE)
+## v3.4.9 (FUTURE)
+
+---
+
+## v3.4.8 (2023-04-12)
 
 
 ### Enhancements
 ### Enhancements
 
 
+* [#10414](https://github.com/netbox-community/netbox/issues/10414) - Enable general purpose image attachments for device types
+* [#10600](https://github.com/netbox-community/netbox/issues/10600) - Allow custom object fields to reference a user or group
+* [#11015](https://github.com/netbox-community/netbox/issues/11015) - Remove unit from commit rate column header in circuits table
+* [#11431](https://github.com/netbox-community/netbox/issues/11431) - Disallow changing custom field type after creation
+* [#11453](https://github.com/netbox-community/netbox/issues/11453) - Display a warning banner when `DEBUG` is enabled
 * [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
 * [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
 * [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
 * [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
+* [#12207](https://github.com/netbox-community/netbox/issues/12207) - Introduce the `grant_token` permission for controlling the creation of API tokens on behalf of other users
 
 
 ### Bug Fixes
 ### Bug Fixes
 
 
+* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests
+* [#11432](https://github.com/netbox-community/netbox/issues/11432) - Prevent existing components & component templates from being reassigned to different devices/device types via the REST API
+* [#11454](https://github.com/netbox-community/netbox/issues/11454) - Raise validation error if generic foreign key assignment does not specify both object type and ID
 * [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
 * [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
 * [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
 * [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
+* [#12040](https://github.com/netbox-community/netbox/issues/12040) - Display relevant UI tab upon bulk import validation failure
 * [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
 * [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
 * [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
 * [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
 * [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
 * [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
@@ -18,6 +32,7 @@
 * [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
 * [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
 * [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
 * [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
 * [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
 * [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
+* [#12227](https://github.com/netbox-community/netbox/issues/12227) - Fix tenant assignment on bulk import of L2VPNs
 
 
 ---
 ---
 
 

+ 3 - 1
netbox/circuits/tables/circuits.py

@@ -64,7 +64,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         template_code=CIRCUITTERMINATION_LINK,
         template_code=CIRCUITTERMINATION_LINK,
         verbose_name='Side Z'
         verbose_name='Side Z'
     )
     )
-    commit_rate = CommitRateColumn()
+    commit_rate = CommitRateColumn(
+        verbose_name='Commit Rate'
+    )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
         url_name='circuits:circuit_list'

+ 14 - 0
netbox/dcim/filtersets.py

@@ -1694,12 +1694,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         field_name='terminations__termination_type'
         field_name='terminations__termination_type'
     )
     )
     termination_a_id = MultiValueNumberFilter(
     termination_a_id = MultiValueNumberFilter(
+        method='filter_by_cable_end_a',
         field_name='terminations__termination_id'
         field_name='terminations__termination_id'
     )
     )
     termination_b_type = ContentTypeFilter(
     termination_b_type = ContentTypeFilter(
         field_name='terminations__termination_type'
         field_name='terminations__termination_type'
     )
     )
     termination_b_id = MultiValueNumberFilter(
     termination_b_id = MultiValueNumberFilter(
+        method='filter_by_cable_end_b',
         field_name='terminations__termination_id'
         field_name='terminations__termination_id'
     )
     )
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1757,6 +1759,18 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         # Supported objects: device, rack, location, site
         # Supported objects: device, rack, location, site
         return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
         return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
 
 
+    def filter_by_cable_end(self, queryset, name, value, side):
+        # Filter by termination id and cable_end type
+        return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct()
+
+    def filter_by_cable_end_a(self, queryset, name, value):
+        # Filter by termination id and cable_end type
+        return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A)
+
+    def filter_by_cable_end_b(self, queryset, name, value):
+        # Filter by termination id and cable_end type
+        return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
+
 
 
 class CableTerminationFilterSet(BaseFilterSet):
 class CableTerminationFilterSet(BaseFilterSet):
     termination_type = ContentTypeFilter()
     termination_type = ContentTypeFilter()

+ 11 - 0
netbox/dcim/models/device_component_templates.py

@@ -119,6 +119,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
             ),
             ),
         )
         )
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache the original DeviceType ID for reference under clean()
+        self._original_device_type = self.device_type_id
+
     def to_objectchange(self, action):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange = super().to_objectchange(action)
         if self.device_type is not None:
         if self.device_type is not None:
@@ -130,6 +136,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        if self.pk is not None and self._original_device_type != self.device_type_id:
+            raise ValidationError({
+                "device_type": "Component templates cannot be moved to a different device type."
+            })
+
         # A component template must belong to a DeviceType *or* to a ModuleType
         # A component template must belong to a DeviceType *or* to a ModuleType
         if self.device_type and self.module_type:
         if self.device_type and self.module_type:
             raise ValidationError(
             raise ValidationError(

+ 14 - 0
netbox/dcim/models/device_components.py

@@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel):
             ),
             ),
         )
         )
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache the original Device ID for reference under clean()
+        self._original_device = self.device_id
+
     def __str__(self):
     def __str__(self):
         if self.label:
         if self.label:
             return f"{self.name} ({self.label})"
             return f"{self.name} ({self.label})"
@@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel):
         objectchange.related_object = self.device
         objectchange.related_object = self.device
         return objectchange
         return objectchange
 
 
+    def clean(self):
+        super().clean()
+
+        if self.pk is not None and self._original_device != self.device_id:
+            raise ValidationError({
+                "device": "Components cannot be moved to a different device."
+            })
+
     @property
     @property
     def parent_object(self):
     def parent_object(self):
         return self.device
         return self.device

+ 4 - 0
netbox/dcim/models/devices.py

@@ -128,6 +128,10 @@ class DeviceType(PrimaryModel, WeightMixin):
         blank=True
         blank=True
     )
     )
 
 
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
+
     clone_fields = (
     clone_fields = (
         'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
         'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
     )
     )

+ 6 - 0
netbox/extras/api/serializers.py

@@ -104,6 +104,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
             'last_updated',
             'last_updated',
         ]
         ]
 
 
+    def validate_type(self, value):
+        if self.instance and self.instance.type != value:
+            raise serializers.ValidationError('Changing the type of custom fields is not supported.')
+
+        return value
+
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)
     def get_data_type(self, obj):
     def get_data_type(self, obj):
         types = CustomFieldTypeChoices
         types = CustomFieldTypeChoices

+ 9 - 1
netbox/extras/forms/model_forms.py

@@ -1,6 +1,7 @@
 import json
 import json
 
 
 from django import forms
 from django import forms
+from django.db.models import Q
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -39,7 +40,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
     object_type = ContentTypeChoiceField(
     object_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         # TODO: Come up with a canonical way to register suitable models
         # TODO: Come up with a canonical way to register suitable models
-        limit_choices_to=FeatureQuery('webhooks'),
+        limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
         required=False,
         required=False,
         help_text=_("Type of the related object (for object/multi-object fields only)")
         help_text=_("Type of the related object (for object/multi-object fields only)")
     )
     )
@@ -63,6 +64,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
             )
             )
         }
         }
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
+        if self.instance.pk:
+            self.fields['type'].disabled = True
+
 
 
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
 class CustomLinkForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(

+ 5 - 0
netbox/extras/tests/test_api.py

@@ -103,6 +103,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
+    update_data = {
+        'content_types': ['dcim.device'],
+        'name': 'New_Name',
+        'description': 'New description',
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 2 - 1
netbox/ipam/forms/bulk_import.py

@@ -456,7 +456,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = L2VPN
         model = L2VPN
-        fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
+        fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
+                  'comments', 'tags')
 
 
 
 
 class L2VPNTerminationImportForm(NetBoxModelImportForm):
 class L2VPNTerminationImportForm(NetBoxModelImportForm):

+ 28 - 0
netbox/netbox/models/__init__.py

@@ -1,4 +1,5 @@
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
@@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
+    def clean(self):
+        """
+        Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist.
+        """
+        super().clean()
+
+        for field in self._meta.get_fields():
+            if isinstance(field, GenericForeignKey):
+                ct_value = getattr(self, field.ct_field)
+                fk_value = getattr(self, field.fk_field)
+
+                if ct_value is None and fk_value is not None:
+                    raise ValidationError({
+                        field.ct_field: "This field cannot be null.",
+                    })
+                if fk_value is None and ct_value is not None:
+                    raise ValidationError({
+                        field.fk_field: "This field cannot be null.",
+                    })
+
+                if ct_value and fk_value:
+                    klass = getattr(self, field.ct_field).model_class()
+                    if not klass.objects.filter(pk=fk_value).exists():
+                        raise ValidationError({
+                            field.fk_field: f"Related object not found using the provided value: {fk_value}."
+                        })
+
 
 
 class PrimaryModel(NetBoxModel):
 class PrimaryModel(NetBoxModel):
     """
     """

+ 9 - 2
netbox/templates/base/layout.html

@@ -70,10 +70,17 @@ Blocks:
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 
+        {% if settings.DEBUG and not settings.DEVELOPER %}
+          <div class="alert alert-warning text-center mx-3" role="alert">
+            <strong><i class="mdi mdi-alert"></i> Debug mode is enabled.</strong>
+            Performance may be limited. Debugging should never be enabled on a production system.
+          </div>
+        {% endif %}
+
         {% if config.MAINTENANCE_MODE %}
         {% if config.MAINTENANCE_MODE %}
           <div class="alert alert-warning text-center mx-3" role="alert">
           <div class="alert alert-warning text-center mx-3" role="alert">
-            <h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
-            <span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
+            <h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
+            NetBox is currently in maintenance mode. Functionality may be limited.
           </div>
           </div>
         {% endif %}
         {% endif %}
 
 

+ 1 - 0
netbox/templates/dcim/devicetype.html

@@ -99,6 +99,7 @@
             {% include 'inc/panels/related_objects.html' %}
             {% include 'inc/panels/related_objects.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/comments.html' %}
             {% include 'inc/panels/comments.html' %}
+            {% include 'inc/panels/image_attachments.html' %}
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

+ 3 - 3
netbox/templates/generic/bulk_import.html

@@ -15,17 +15,17 @@ Context:
 {% block tabs %}
 {% block tabs %}
   <ul class="nav nav-tabs px-3">
   <ul class="nav nav-tabs px-3">
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <button class="nav-link active" id="import-form-tab" data-bs-toggle="tab" data-bs-target="#import-form" type="button" role="tab" aria-controls="import-form" aria-selected="true">
+      <button class="nav-link active" id="import-form-tab" data-bs-toggle="tab" data-bs-target="#import-form" data-href="#tab_import-form" type="button" role="tab" aria-controls="import-form" aria-selected="true">
         Direct Import
         Direct Import
       </button>
       </button>
     </li>
     </li>
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <button class="nav-link" id="upload-form-tab" data-bs-toggle="tab" data-bs-target="#upload-form" type="button" role="tab" aria-controls="upload-form" aria-selected="false">
+      <button class="nav-link" id="upload-form-tab" data-bs-toggle="tab" data-bs-target="#upload-form" data-href="#tab_upload-form" type="button" role="tab" aria-controls="upload-form" aria-selected="false">
         Upload File
         Upload File
       </button>
       </button>
     </li>
     </li>
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <button class="nav-link" id="datafile-form-tab" data-bs-toggle="tab" data-bs-target="#datafile-form" type="button" role="tab" aria-controls="datafile-form" aria-selected="false">
+      <button class="nav-link" id="datafile-form-tab" data-bs-toggle="tab" data-bs-target="#datafile-form" data-href="#tab_datafile-form" type="button" role="tab" aria-controls="datafile-form" aria-selected="false">
         Data File
         Data File
       </button>
       </button>
     </li>
     </li>

+ 11 - 0
netbox/users/api/serializers.py

@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from rest_framework import serializers
 from rest_framework import serializers
+from rest_framework.exceptions import PermissionDenied
 
 
 from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
 from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
@@ -94,6 +95,16 @@ class TokenSerializer(ValidatedModelSerializer):
             data['key'] = Token.generate_key()
             data['key'] = Token.generate_key()
         return super().to_internal_value(data)
         return super().to_internal_value(data)
 
 
+    def validate(self, data):
+
+        # If the Token is being created on behalf of another user, enforce the grant_token permission.
+        request = self.context.get('request')
+        token_user = data.get('user')
+        if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'):
+            raise PermissionDenied("This user does not have permission to create tokens for other users.")
+
+        return super().validate(data)
+
 
 
 class TokenProvisionSerializer(serializers.Serializer):
 class TokenProvisionSerializer(serializers.Serializer):
     username = serializers.CharField()
     username = serializers.CharField()

+ 20 - 0
netbox/users/tests/test_api.py

@@ -153,6 +153,26 @@ class TokenTest(
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
+    def test_provision_token_other_user(self):
+        """
+        Test provisioning a Token for a different User with & without the grant_token permission.
+        """
+        self.add_permissions('users.add_token')
+        user2 = User.objects.create_user(username='testuser2')
+        data = {
+            'user': user2.id,
+        }
+        url = reverse('users-api:token-list')
+
+        # Attempt to create a new Token for User2 *without* the grant_token permission
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertEqual(response.status_code, 403)
+
+        # Assign grant_token permission and successfully create a new Token for User2
+        self.add_permissions('users.grant_token')
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertEqual(response.status_code, 201)
+
 
 
 class ObjectPermissionTest(
 class ObjectPermissionTest(
     # No GraphQL support for ObjectPermission
     # No GraphQL support for ObjectPermission

+ 4 - 0
netbox/utilities/utils.py

@@ -48,6 +48,10 @@ def get_viewname(model, action=None, rest_api=False):
         if is_plugin:
         if is_plugin:
             viewname = f'plugins-api:{app_label}-api:{model_name}'
             viewname = f'plugins-api:{app_label}-api:{model_name}'
         else:
         else:
+            # Alter the app_label for group and user model_name to point to users app
+            if app_label == 'auth' and model_name in ['group', 'user']:
+                app_label = 'users'
+
             viewname = f'{app_label}-api:{model_name}'
             viewname = f'{app_label}-api:{model_name}'
         # Append the action, if any
         # Append the action, if any
         if action:
         if action:

+ 9 - 9
requirements.txt

@@ -1,8 +1,8 @@
 bleach==5.0.1
 bleach==5.0.1
 boto3==1.26.91
 boto3==1.26.91
-Django==4.1.7
+Django==4.1.8
 django-cors-headers==3.14.0
 django-cors-headers==3.14.0
-django-debug-toolbar==3.8.1
+django-debug-toolbar==4.0.0
 django-filter==23.1
 django-filter==23.1
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.14
 django-mptt==0.14
@@ -22,18 +22,18 @@ graphene-django==3.0.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.1.4
-mkdocstrings[python-legacy]==0.20.0
+mkdocs-material==9.1.6
+mkdocstrings[python-legacy]==0.21.2
 netaddr==0.8.0
 netaddr==0.8.0
-Pillow==9.4.0
-psycopg2-binary==2.9.5
+Pillow==9.5.0
+psycopg2-binary==2.9.6
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.18.0
+sentry-sdk==1.19.1
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
-social-auth-core[openidconnect]==4.4.0
+social-auth-core[openidconnect]==4.4.1
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.4.0
 tablib==3.4.0
-tzdata==2023.2
+tzdata==2023.3
 
 
 # Workaround for #7401
 # Workaround for #7401
 jsonschema==3.2.0
 jsonschema==3.2.0