diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b3dd583ca..8cb548de2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.4 + placeholder: v3.5.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bd93001e7..df931c77b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.4 + placeholder: v3.5.6 validations: required: true - type: dropdown diff --git a/README.md b/README.md index 6e2b34fb8..54b3e727e 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations. ## Project Stats
- Timeline graph - Issues graph - Pull requests graph - Top contributors + Timeline graph + Issues graph + Pull requests graph + Top contributors
Stats via Repography
@@ -66,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations. [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)            [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) -
- [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)            + [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io) +
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) +            + [![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com) diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 612faefed..1e0d5c31e 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are ### Custom Selection Fields -Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. +Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index df0408f7c..bf0c4755a 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices. -### Choices +### Choice Set -For choice and multi-choice custom fields only. A comma-delimited list of the available choices. +For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field. ### Cloneable diff --git a/docs/models/extras/customfieldchoiceset.md b/docs/models/extras/customfieldchoiceset.md new file mode 100644 index 000000000..8fa30cfc7 --- /dev/null +++ b/docs/models/extras/customfieldchoiceset.md @@ -0,0 +1,17 @@ +# Custom Field Choice Sets + +Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields. + +## Fields + +### Name + +The human-friendly name of the choice set. + +### Extra Choices + +The list of valid choices, entered as a comma-separated list. + +### Order Alphabetically + +If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined. diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index b3bcb292a..c51d025f4 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -19,6 +19,9 @@ class MyModel(models.Model): Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`. +!!! note + Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions. + ## Enabling NetBox Features Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 7ad333e47..db301c55f 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,6 +1,53 @@ # NetBox v3.5 -## v3.5.5 (FUTURE) +## v3.5.7 (FUTURE) + +--- + +## v3.5.6 (2023-07-10) + +### Bug Fixes + +* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined +* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled +* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized +* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types + +--- + +## v3.5.5 (2023-07-06) + +### Enhancements + +* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization +* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses +* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type +* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table +* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log + +### Bug Fixes + +* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records +* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes +* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable +* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address +* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs +* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports +* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients +* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view +* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment +* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields +* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs +* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled +* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer +* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets +* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit +* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types +* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links +* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list +* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer +* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit +* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values --- diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 9550df3ea..b7e537c23 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -1,5 +1,6 @@ import re import typing +from collections import OrderedDict from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.openapi import AutoSchema @@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension): target_class = 'netbox.api.fields.ChoiceField' def map_serializer_field(self, auto_schema, direction): + build_cf = build_choice_field(self.target) + if direction == 'request': - return build_choice_field(self.target) + return build_cf elif direction == "response": + value = build_cf + label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))} + return build_object_type( properties={ - "value": build_basic_type(OpenApiTypes.STR), - "label": build_basic_type(OpenApiTypes.STR), + "value": value, + "label": label } ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3a3065acc..32943f468 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -699,7 +699,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', + 'created', 'last_updated', ] @extend_schema_field(serializers.JSONField(allow_null=True)) @@ -708,7 +709,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') device = NestedDeviceSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) @@ -881,12 +882,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True) - rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True) - poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True) - poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -908,9 +909,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect mac_address = serializers.CharField( required=False, default=None, + allow_blank=True, allow_null=True ) - wwn = serializers.CharField(required=False, default=None) + wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) class Meta: model = Interface diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index cc388b750..f2f401718 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100GE_CXP = '100gbase-x-cxp' TYPE_100GE_CPAK = '100gbase-x-cpak' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' + TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd' TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' @@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), + (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e53ea8079..724567666 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1077,10 +1077,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): def search(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(identifier=value.strip()) - ).distinct() + + qs_filter = Q(name__icontains=value) + try: + qs_filter |= Q(identifier=int(value)) + except ValueError: + pass + return queryset.filter(qs_filter).distinct() def _has_primary_ip(self, queryset, name, value): params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 309370bfd..93b769738 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1102,7 +1102,7 @@ class PowerPortBulkEditForm( (None, ('module', 'type', 'label', 'description', 'mark_connected')), ('Power', ('maximum_draw', 'allocated_draw')), ) - nullable_fields = ('module', 'label', 'description') + nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw') class PowerOutletBulkEditForm( diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index e3e97ab73..cd774fd18 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm): model = DeviceType fields = [ 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', + 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags', ] @@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags'] class DeviceRoleImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b52e0afa5..008db382a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3131,6 +3131,19 @@ class CableEditView(generic.ObjectEditView): return obj + def get_extra_addanother_params(self, request): + + params = { + 'a_terminations_type': request.GET.get('a_terminations_type'), + 'b_terminations_type': request.GET.get('b_terminations_type') + } + + for key in request.POST: + if 'device' in key or 'power_panel' in key or 'circuit' in key: + params.update({key: request.POST.get(key)}) + + return params + @register_model_view(Cable, 'delete') class CableDeleteView(generic.ObjectDeleteView): diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 4271e1748..a97c630d2 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -7,6 +7,7 @@ __all__ = [ 'NestedBookmarkSerializer', 'NestedConfigContextSerializer', 'NestedConfigTemplateSerializer', + 'NestedCustomFieldChoiceSetSerializer', 'NestedCustomFieldSerializer', 'NestedCustomLinkSerializer', 'NestedExportTemplateSerializer', @@ -34,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') + + class Meta: + model = models.CustomFieldChoiceSet + fields = ['id', 'url', 'display', 'name', 'choices_count'] + + class NestedCustomLinkSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f28a5c411..fea7582c0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -35,6 +35,7 @@ __all__ = ( 'ConfigContextSerializer', 'ConfigTemplateSerializer', 'ContentTypeSerializer', + 'CustomFieldChoiceSetSerializer', 'CustomFieldSerializer', 'CustomLinkSerializer', 'DashboardSerializer', @@ -94,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() + choice_set = NestedCustomFieldChoiceSetSerializer(required=False) ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) class Meta: @@ -101,7 +103,7 @@ class CustomFieldSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default', - 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', + 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created', 'last_updated', ] @@ -127,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer): return 'string' +class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') + + class Meta: + model = CustomFieldChoiceSet + fields = [ + 'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count', + 'created', 'last_updated', + ] + + # # Custom links # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 6e610097f..c13d60797 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -9,6 +9,7 @@ router.APIRootView = views.ExtrasRootView router.register('webhooks', views.WebhookViewSet) router.register('custom-fields', views.CustomFieldViewSet) +router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet) router.register('custom-links', views.CustomLinkViewSet) router.register('export-templates', views.ExportTemplateViewSet) router.register('saved-filters', views.SavedFilterViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3c7e6bfcc..5761d6767 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,5 @@ from django.contrib.contenttypes.models import ContentType from django.http import Http404 -from django.shortcuts import get_object_or_404 from django_rq.queues import get_connection from rest_framework import status from rest_framework.decorators import action @@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet): class CustomFieldViewSet(NetBoxModelViewSet): metadata_class = ContentTypeMetadata - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') serializer_class = serializers.CustomFieldSerializer filterset_class = filtersets.CustomFieldFilterSet +class CustomFieldChoiceSetViewSet(NetBoxModelViewSet): + queryset = CustomFieldChoiceSet.objects.all() + serializer_class = serializers.CustomFieldChoiceSetSerializer + filterset_class = filtersets.CustomFieldChoiceSetFilterSet + + # # Custom links # @@ -379,7 +384,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): Retrieve a list of recent changes. """ metadata_class = ContentTypeMetadata - queryset = ObjectChange.objects.prefetch_related('user') + queryset = ObjectChange.objects.valid_models().prefetch_related('user') serializer_class = serializers.ObjectChangeSerializer filterset_class = filtersets.ObjectChangeFilterSet diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 3b9ce6c46..3d6275f45 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -10,7 +10,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import Q -from django.http import QueryDict from django.template.loader import render_to_string from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ @@ -20,7 +19,7 @@ from extras.utils import FeatureQuery from utilities.forms import BootstrapMixin from utilities.permissions import get_permission_for_model from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, get_viewname +from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname from .utils import register_widget __all__ = ( @@ -172,8 +171,7 @@ class ObjectCountsWidget(DashboardWidget): qs = model.objects.restrict(request.user, 'view') # Apply any specified filters if filters := self.config.get('filters'): - params = QueryDict(mutable=True) - params.update(filters) + params = dict_to_querydict(filters) filterset = getattr(resolve(url).func.view_class, 'filterset', None) qs = filterset(params, qs).qs url = f'{url}?{params.urlencode()}' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index ef094c2d0..42277d219 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', + 'CustomFieldChoiceSetFilterSet', 'CustomFieldFilterSet', 'CustomLinkFilterSet', 'ExportTemplateFilterSet', @@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet): field_name='content_types__id' ) content_types = ContentTypeFilter() + choice_set_id = django_filters.ModelMultipleChoiceFilter( + queryset=CustomFieldChoiceSet.objects.all() + ) + choice_set = django_filters.ModelMultipleChoiceFilter( + field_name='choice_set__name', + queryset=CustomFieldChoiceSet.objects.all(), + to_field_name='name' + ) class Meta: model = CustomField @@ -93,6 +102,35 @@ class CustomFieldFilterSet(BaseFilterSet): ) +class CustomFieldChoiceSetFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + choice = MultiValueCharFilter( + method='filter_by_choice' + ) + + class Meta: + model = CustomFieldChoiceSet + fields = [ + 'id', 'name', 'description', 'order_alphabetically', + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(extra_choices__contains=value) + ) + + def filter_by_choice(self, queryset, name, value): + # TODO: Support case-insensitive matching + return queryset.filter(extra_choices__overlap=value) + + class CustomLinkFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 7c838be20..b0c6b87ea 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -4,13 +4,14 @@ from django.utils.translation import gettext as _ from extras.choices import * from extras.models import * from utilities.forms import BulkEditForm, add_blank_choice -from utilities.forms.fields import ColorField +from utilities.forms.fields import ColorField, DynamicModelChoiceField from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( 'ConfigContextBulkEditForm', 'ConfigTemplateBulkEditForm', 'CustomFieldBulkEditForm', + 'CustomFieldChoiceSetBulkEditForm', 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', @@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm): weight = forms.IntegerField( required=False ) + choice_set = DynamicModelChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + required=False + ) ui_visibility = forms.ChoiceField( label=_("UI visibility"), choices=add_blank_choice(CustomFieldVisibilityChoices), @@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm): widget=BulkEditNullBooleanSelect() ) - nullable_fields = ('group_name', 'description',) + nullable_fields = ('group_name', 'description', 'choice_set') + + +class CustomFieldChoiceSetBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + required=False + ) + order_alphabetically = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + nullable_fields = ('description',) class CustomLinkBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 818b8a52f..b47fcba60 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -9,10 +9,13 @@ from extras.models import * from extras.utils import FeatureQuery from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelForm -from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField +from utilities.forms.fields import ( + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField, +) __all__ = ( 'ConfigTemplateImportForm', + 'CustomFieldChoiceSetImportForm', 'CustomFieldImportForm', 'CustomLinkImportForm', 'ExportTemplateImportForm', @@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm): required=False, help_text=_("Object type (for object or multi-object fields)") ) - choices = SimpleArrayField( - base_field=forms.CharField(), + choice_set = CSVModelChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + to_field_name='name', required=False, - help_text=_('Comma-separated list of field choices') + help_text=_('Choice set (for selection fields)') ) ui_visibility = CSVChoiceField( choices=CustomFieldVisibilityChoices, @@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm): model = CustomField fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', - 'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'ui_visibility', 'is_cloneable', + 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable', + ) + + +class CustomFieldChoiceSetImportForm(CSVModelForm): + extra_choices = SimpleArrayField( + base_field=forms.CharField(), + required=False, + help_text=_('Comma-separated list of field choices') + ) + + class Meta: + model = CustomFieldChoiceSet + fields = ( + 'name', 'description', 'extra_choices', 'order_alphabetically', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 56e9c8dfb..26b4d9a41 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( 'ConfigContextFilterForm', 'ConfigRevisionFilterForm', 'ConfigTemplateFilterForm', + 'CustomFieldChoiceSetFilterForm', 'CustomFieldFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', @@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), ('Attributes', ( - 'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable', + 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility', + 'is_cloneable', )), ) content_type_id = ContentTypeMultipleChoiceField( @@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + choice_set_id = DynamicModelMultipleChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + required=False, + label=_('Choice set') + ) ui_visibility = forms.ChoiceField( choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, @@ -75,10 +82,19 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): ) +class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id', 'choice')), + ) + choice = forms.CharField( + required=False + ) + + class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), - ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), + (_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 354d2a51a..428c6391b 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -16,9 +16,10 @@ from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms.fields import ( - CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, - SlugField, + CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, JSONField, SlugField, ) +from utilities.forms.widgets import ArrayWidget from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -27,6 +28,7 @@ __all__ = ( 'ConfigContextForm', 'ConfigRevisionForm', 'ConfigTemplateForm', + 'CustomFieldChoiceSetForm', 'CustomFieldForm', 'CustomLinkForm', 'ExportTemplateForm', @@ -50,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): required=False, help_text=_("Type of the related object (for object/multi-object fields only)") ) + choice_set = DynamicModelChoiceField( + queryset=CustomFieldChoiceSet.objects.all(), + required=False + ) fieldsets = ( ('Custom Field', ( 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', )), ('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')), - ('Values', ('default', 'choices')), + ('Values', ('default', 'choice_set')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -78,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): self.fields['type'].disabled = True +class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): + extra_choices = forms.CharField( + widget=ArrayWidget(), + help_text=_('Enter one choice per line.') + ) + + class Meta: + model = CustomFieldChoiceSet + fields = ('name', 'description', 'extra_choices', 'order_alphabetically') + + def clean_extra_choices(self): + return self.cleaned_data['extra_choices'].splitlines() + + class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 05febaa6f..19a7878e1 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form): self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data - - @property - def requires_input(self): - """ - A boolean indicating whether the form requires user input (ignore the built-in fields). - """ - return bool(len(self.fields) > 3) diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index c61b0b88c..e13cc0e9f 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType): def resolve_custom_field_list(root, info, **kwargs): return gql_query_optimizer(models.CustomField.objects.all(), info) + custom_field_choice_set = ObjectField(CustomFieldChoiceSetType) + custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType) + + def resolve_custom_field_choices_list(root, info, **kwargs): + return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info) + custom_link = ObjectField(CustomLinkType) custom_link_list = ObjectListField(CustomLinkType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index ae7d5cef6..73ff8eb8a 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType __all__ = ( 'ConfigContextType', 'ConfigTemplateType', + 'CustomFieldChoiceSetType', 'CustomFieldType', 'CustomLinkType', 'ExportTemplateType', @@ -41,6 +42,14 @@ class CustomFieldType(ObjectType): filterset_class = filtersets.CustomFieldFilterSet +class CustomFieldChoiceSetType(ObjectType): + + class Meta: + model = models.CustomFieldChoiceSet + fields = '__all__' + filterset_class = filtersets.CustomFieldChoiceSetFilterSet + + class CustomLinkType(ObjectType): class Meta: diff --git a/netbox/extras/migrations/0096_customfieldchoiceset.py b/netbox/extras/migrations/0096_customfieldchoiceset.py new file mode 100644 index 000000000..dea6f02fc --- /dev/null +++ b/netbox/extras/migrations/0096_customfieldchoiceset.py @@ -0,0 +1,61 @@ +import django.contrib.postgres.fields +from django.db import migrations, models + +from extras.choices import CustomFieldTypeChoices + + +def create_choice_sets(apps, schema_editor): + """ + Create a CustomFieldChoiceSet for each CustomField with choices defined. + """ + CustomField = apps.get_model('extras', 'CustomField') + CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet') + + # Create custom field choice sets + choice_fields = CustomField.objects.filter( + type__in=(CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT), + choices__len__gt=0 + ) + for cf in choice_fields: + choiceset = CustomFieldChoiceSet.objects.create( + name=f'{cf.name} Choices', + extra_choices=cf.choices + ) + cf.choice_set = choiceset + + # Update custom fields to point to new choice sets + CustomField.objects.bulk_update(choice_fields, ['choice_set']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0095_bookmarks'), + ] + + operations = [ + migrations.CreateModel( + name='CustomFieldChoiceSet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)), + ('order_alphabetically', models.BooleanField(default=False)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='customfield', + name='choice_set', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='choices_for', to='extras.customfieldchoiceset'), + ), + migrations.RunPython( + code=create_choice_sets, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/extras/migrations/0097_customfield_remove_choices.py b/netbox/extras/migrations/0097_customfield_remove_choices.py new file mode 100644 index 000000000..f3e8c547e --- /dev/null +++ b/netbox/extras/migrations/0097_customfield_remove_choices.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2023-07-17 15:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0096_customfieldchoiceset'), + ] + + operations = [ + migrations.RemoveField( + model_name='customfield', + name='choices', + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 423219ccb..399f01005 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,6 +1,6 @@ from .change_logging import * from .configs import * -from .customfields import CustomField +from .customfields import * from .dashboard import * from .models import * from .reports import * diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 54d72cdd8..444701acc 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -5,7 +5,7 @@ from django.db import models from django.urls import reverse from extras.choices import * -from utilities.querysets import RestrictedQuerySet +from ..querysets import ObjectChangeQuerySet __all__ = ( 'ObjectChange', @@ -82,7 +82,7 @@ class ObjectChange(models.Model): null=True ) - objects = RestrictedQuerySet.as_manager() + objects = ObjectChangeQuerySet.as_manager() class Meta: ordering = ['-time'] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index be3540f08..bdb600c88 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -31,6 +31,7 @@ from utilities.validators import validate_regex __all__ = ( 'CustomField', + 'CustomFieldChoiceSet', 'CustomFieldManager', ) @@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' ) ) - choices = ArrayField( - base_field=models.CharField(max_length=100), + choice_set = models.ForeignKey( + to='CustomFieldChoiceSet', + on_delete=models.PROTECT, + related_name='choices_for', blank=True, - null=True, - help_text=_('Comma-separated list of available choices (for selection fields)') + null=True ) ui_visibility = models.CharField( max_length=50, @@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): clone_fields = ( 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', - 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', - 'ui_visibility', 'is_cloneable', + 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choice_set', 'ui_visibility', 'is_cloneable', ) class Meta: @@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): def search_type(self): return SEARCH_TYPES.get(self.type) + @property + def choices(self): + if self.choice_set: + return self.choice_set.choices + return [] + def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or @@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'validation_regex': "Regular expression validation is supported only for text and URL fields" }) - # Choices can be set only on selection fields - if self.choices and self.type not in ( - CustomFieldTypeChoices.TYPE_SELECT, - CustomFieldTypeChoices.TYPE_MULTISELECT - ): - raise ValidationError({ - 'choices': "Choices may be set only for custom selection fields." - }) - - # Selection fields must have at least one choice defined + # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT - ) and not self.choices: + ): + if not self.choice_set: + raise ValidationError({ + 'choice_set': "Selection fields must specify a set of choices." + }) + elif self.choice_set: raise ValidationError({ - 'choices': "Selection fields must specify at least one choice." + 'choice_set': "Choices may be set only on selection fields." }) # A selection field's default (if any) must be present in its available choices @@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): elif self.required: raise ValidationError("Required field cannot be empty.") + + +class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): + """ + Represents a set of choices available for choice and multi-choice custom fields. + """ + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + extra_choices = ArrayField( + base_field=models.CharField(max_length=100), + help_text=_('List of field choices') + ) + order_alphabetically = models.BooleanField( + default=False, + help_text=_('Choices are automatically ordered alphabetically on save') + ) + + clone_fields = ('extra_choices', 'order_alphabetically') + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:customfieldchoiceset', args=[self.pk]) + + @property + def choices(self): + return self.extra_choices + + @property + def choices_count(self): + return len(self.choices) + + def save(self, *args, **kwargs): + + # Sort choices if alphabetical ordering is enforced + if self.order_alphabetically: + self.extra_choices = sorted(self.choices) + + return super().save(*args, **kwargs) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 20bf87903..193d3af6a 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models -from django.http import HttpResponse, QueryDict +from django.http import HttpResponse from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -25,7 +25,7 @@ from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, ) from utilities.querysets import RestrictedQuerySet -from utilities.utils import clean_html, render_jinja2 +from utilities.utils import clean_html, dict_to_querydict, render_jinja2 __all__ = ( 'Bookmark', @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote(link, safe='/:?&=%+[]@#') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#,') # Verify link scheme is allowed result = urllib.parse.urlparse(link) @@ -463,8 +463,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): @property def url_params(self): - qd = QueryDict(mutable=True) - qd.update(self.parameters) + qd = dict_to_querydict(self.parameters) return qd.urlencode() diff --git a/netbox/extras/models/reports.py b/netbox/extras/models/reports.py index aaa785696..f1e336df5 100644 --- a/netbox/extras/models/reports.py +++ b/netbox/extras/models/reports.py @@ -1,7 +1,7 @@ import inspect +import logging from functools import cached_property -from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse @@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from .mixins import PythonModuleMixin +logger = logging.getLogger('netbox.reports') + __all__ = ( 'Report', 'ReportModule', @@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile): try: module = self.get_module() - except ImportError: + except (ImportError, SyntaxError) as e: + logger.error(f"Unable to load report module {self.name}, exception: {e}") return {} reports = {} ordered = getattr(module, 'report_order', []) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 2b97af0fb..7b71fa656 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,5 +1,8 @@ +from django.apps import apps +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.aggregates import JSONBAgg from django.db.models import OuterRef, Subquery, Q +from django.db.utils import ProgrammingError from extras.models.tags import TaggedItem from utilities.query_functions import EmptyGroupByJSONBAgg @@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) return base_query + + +class ObjectChangeQuerySet(RestrictedQuerySet): + + def valid_models(self): + # Exclude any change records which refer to an instance of a model that's no longer installed. This + # can happen when a plugin is removed but its data remains in the database, for example. + try: + content_types = ContentType.objects.get_for_models(*apps.get_models()).values() + except ProgrammingError: + # Handle the case where the database schema has not yet been initialized + content_types = ContentType.objects.none() + + content_type_ids = set( + ct.pk for ct in content_types + ) + return self.filter(changed_object_type_id__in=content_type_ids) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index cebc57af4..9fa31db31 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -366,7 +366,7 @@ class BaseScript: if self.fieldsets: fieldsets.extend(self.fieldsets) else: - fields = (name for name, _ in self._get_vars().items()) + fields = list(name for name, _ in self._get_vars().items()) fieldsets.append(('Script Data', fields)) # Append the default fieldset if defined in the Meta class @@ -390,6 +390,11 @@ class BaseScript: # Set initial "commit" checkbox state based on the script's Meta parameter form.fields['_commit'].initial = self.commit_default + # Hide fields if scheduling has been disabled + if not self.scheduling_enabled: + form.fields['_schedule_at'].widget = forms.HiddenInput() + form.fields['_interval'].widget = forms.HiddenInput() + return form # Logging diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 6cb363c01..e5e722398 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -2,6 +2,7 @@ import json import django_tables2 as tables from django.conf import settings +from django.utils.translation import gettext as _ from extras.models import * from netbox.tables import NetBoxTable, columns @@ -12,6 +13,7 @@ __all__ = ( 'ConfigContextTable', 'ConfigRevisionTable', 'ConfigTemplateTable', + 'CustomFieldChoiceSetTable', 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', @@ -64,6 +66,11 @@ class CustomFieldTable(NetBoxTable): required = columns.BooleanColumn() ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") description = columns.MarkdownColumn() + choices = columns.ArrayColumn( + max_items=10, + orderable=False, + verbose_name=_('Choices') + ) is_cloneable = columns.BooleanColumn() class Meta(NetBoxTable.Meta): @@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') +class CustomFieldChoiceSetTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + choices = columns.ArrayColumn( + max_items=10, + accessor=tables.A('extra_choices'), + orderable=False, + verbose_name=_('Choices') + ) + choice_count = tables.TemplateColumn( + accessor=tables.A('extra_choices'), + template_code='{{ value|length }}', + orderable=False, + verbose_name=_('Count') + ) + order_alphabetically = columns.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = CustomFieldChoiceSet + fields = ( + 'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created', + 'last_updated', + ) + default_columns = ('pk', 'name', 'choice_count', 'description') + + class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index e09d4de78..922b45240 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -8,7 +8,6 @@ from rest_framework import status from core.choices import ManagedFileRootPathChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site -from extras.api.views import ReportViewSet, ScriptViewSet from extras.models import * from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar @@ -99,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): { 'content_types': ['dcim.site'], 'name': 'cf6', - 'type': 'select', - 'choices': ['A', 'B', 'C'] + 'type': 'text', }, ] bulk_update_data = { @@ -135,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): cf.content_types.add(site_ct) +class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase): + model = CustomFieldChoiceSet + brief_fields = ['choices_count', 'display', 'id', 'name', 'url'] + create_data = [ + { + 'name': 'Choice Set 4', + 'extra_choices': ['4A', '4B', '4C'], + }, + { + 'name': 'Choice Set 5', + 'extra_choices': ['5A', '5B', '5C'], + }, + { + 'name': 'Choice Set 6', + 'extra_choices': ['6A', '6B', '6C'], + }, + ] + bulk_update_data = { + 'description': 'New description', + } + update_data = { + 'name': 'Choice Set X', + 'extra_choices': ['X1', 'X2', 'X3'], + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']), + CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + class CustomLinkTest(APIViewTestCases.APIViewTestCase): model = CustomLink brief_fields = ['display', 'id', 'name', 'url'] @@ -634,6 +668,7 @@ class ReportTest(APITestCase): super().setUp() # Monkey-patch the API viewset's _get_report() method to return our test Report above + from extras.api.views import ReportViewSet ReportViewSet._get_report = self.get_test_report def test_get_report(self): @@ -676,6 +711,7 @@ class ScriptTest(APITestCase): super().setUp() # Monkey-patch the API viewset's _get_script() method to return our test Script above + from extras.api.views import ScriptViewSet ScriptViewSet._get_script = self.get_test_script def test_get_script(self): diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index e0be8c3bd..9ebbeef5c 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import * -from extras.models import CustomField, ObjectChange, Tag +from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag from utilities.testing import APITestCase from utilities.testing.utils import create_tags, post_data from utilities.testing.views import ModelViewTestCase @@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase): @classmethod def setUpTestData(cls): + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=['Bar', 'Foo'] + ) # Create a custom field on the Site model ct = ContentType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, - name='my_field', + name='cf1', required=False ) cf.save() @@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase): # Create a select custom field on the Site model cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field_select', + name='cf2', required=False, - choices=['Bar', 'Foo'] + choice_set=choice_set ) cf_select.save() cf_select.content_types.set([ct]) @@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase): 'name': 'Site 1', 'slug': 'site-1', 'status': SiteStatusChoices.STATUS_ACTIVE, - 'cf_my_field': 'ABC', - 'cf_my_field_select': 'Bar', + 'cf_cf1': 'ABC', + 'cf_cf2': 'Bar', 'tags': [tag.pk for tag in tags], } @@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.prechange_data, None) - self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1']) + self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): @@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase): 'name': 'Site X', 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, - 'cf_my_field': 'DEF', - 'cf_my_field_select': 'Foo', + 'cf_cf1': 'DEF', + 'cf_cf2': 'Foo', 'tags': [tags[2].pk], } @@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.prechange_data['name'], 'Site 1') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1']) + self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) def test_delete_object(self): @@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase): name='Site 1', slug='site-1', custom_field_data={ - 'my_field': 'ABC', - 'my_field_select': 'Bar' + 'cf1': 'ABC', + 'cf2': 'Bar' } ) site.save() @@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) @@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase): ct = ContentType.objects.get_for_model(Site) cf = CustomField( type=CustomFieldTypeChoices.TYPE_TEXT, - name='my_field', + name='cf1', required=False ) cf.save() cf.content_types.set([ct]) # Create a select custom field on the Site model + choice_set = CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=['Bar', 'Foo'] + ) cf_select = CustomField( type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field_select', + name='cf2', required=False, - choices=['Bar', 'Foo'] + choice_set=choice_set ) cf_select.save() cf_select.content_types.set([ct]) @@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase): 'name': 'Site 1', 'slug': 'site-1', 'custom_fields': { - 'my_field': 'ABC', - 'my_field_select': 'Bar', + 'cf1': 'ABC', + 'cf2': 'Bar', }, 'tags': [ {'name': 'Tag 1'}, @@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase): 'name': 'Site X', 'slug': 'site-x', 'custom_fields': { - 'my_field': 'DEF', - 'my_field_select': 'Foo', + 'cf1': 'DEF', + 'cf2': 'Foo', }, 'tags': [ {'name': 'Tag 3'} @@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase): name='Site 1', slug='site-1', custom_field_data={ - 'my_field': 'ABC', - 'my_field_select': 'Bar' + 'cf1': 'ABC', + 'cf2': 'Bar' } ) site.save() @@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar') self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data, None) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 3fd0dc83e..3b802a0f2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site from extras.choices import * -from extras.models import CustomField +from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -272,12 +272,18 @@ class CustomFieldTest(TestCase): CHOICES = ('Option A', 'Option B', 'Option C') value = CHOICES[1] + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) + # Create a custom field & check that initial value is null cf = CustomField.objects.create( name='select_field', type=CustomFieldTypeChoices.TYPE_SELECT, required=False, - choices=CHOICES + choice_set=choice_set ) cf.content_types.set([self.object_type]) instance = Site.objects.first() @@ -299,12 +305,18 @@ class CustomFieldTest(TestCase): CHOICES = ['Option A', 'Option B', 'Option C'] value = [CHOICES[1], CHOICES[2]] + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) + # Create a custom field & check that initial value is null cf = CustomField.objects.create( name='multiselect_field', type=CustomFieldTypeChoices.TYPE_MULTISELECT, required=False, - choices=CHOICES + choice_set=choice_set ) cf.content_types.set([self.object_type]) instance = Site.objects.first() @@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase): ) VLAN.objects.bulk_create(vlans) + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=('Foo', 'Bar', 'Baz') + ) + custom_fields = ( CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), @@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase): type=CustomFieldTypeChoices.TYPE_SELECT, name='select_field', default='Foo', - choices=( - 'Foo', 'Bar', 'Baz' - ) + choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_MULTISELECT, name='multiselect_field', default=['Foo'], - choices=( - 'Foo', 'Bar', 'Baz' - ) + choice_set=choice_set ), CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, @@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase): @classmethod def setUpTestData(cls): + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=('Choice A', 'Choice B', 'Choice C') + ) + custom_fields = ( CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), @@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase): CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON), - CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ - 'Choice A', 'Choice B', 'Choice C', - ]), - CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[ - 'Choice A', 'Choice B', 'Choice C', - ]), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set), + CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set), ) for cf in custom_fields: cf.save() @@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase): Manufacturer(name='Manufacturer 4', slug='manufacturer-4'), )) + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=['A', 'B', 'C', 'X'] + ) + # Integer filtering cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) cf.save() @@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf9', type=CustomFieldTypeChoices.TYPE_SELECT, - choices=['Foo', 'Bar', 'Baz'] + choice_set=choice_set ) cf.save() cf.content_types.set([obj_type]) @@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase): cf = CustomField( name='cf10', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=['A', 'B', 'C', 'X'] + choice_set=choice_set ) cf.save() cf.content_types.set([obj_type]) @@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-26', 'cf7': 'http://a.example.com', 'cf8': 'http://a.example.com', - 'cf9': 'Foo', + 'cf9': 'A', 'cf10': ['A', 'X'], 'cf11': manufacturers[0].pk, 'cf12': [manufacturers[0].pk, manufacturers[3].pk], @@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-27', 'cf7': 'http://b.example.com', 'cf8': 'http://b.example.com', - 'cf9': 'Bar', + 'cf9': 'B', 'cf10': ['B', 'X'], 'cf11': manufacturers[1].pk, 'cf12': [manufacturers[1].pk, manufacturers[3].pk], @@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf6': '2016-06-28', 'cf7': 'http://c.example.com', 'cf8': 'http://c.example.com', - 'cf9': 'Baz', + 'cf9': 'C', 'cf10': ['C', 'X'], 'cf11': manufacturers[2].pk, 'cf12': [manufacturers[2].pk, manufacturers[3].pk], @@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index b4b216244..c558a0467 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) custom_fields = ( CustomField( @@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN ), + CustomField( + name='Custom Field 4', + type=CustomFieldTypeChoices.TYPE_SELECT, + required=False, + weight=400, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + choice_set=choice_sets[0] + ), + CustomField( + name='Custom Field 5', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + weight=500, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + choice_set=choice_sets[1] + ), ) CustomField.objects.bulk_create(custom_fields) - custom_fields[0].content_types.add(content_types[0]) - custom_fields[1].content_types.add(content_types[1]) - custom_fields[2].content_types.add(content_types[2]) + custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site')) + custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack')) + custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) + custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device')) def test_name(self): params = {'name': ['Custom Field 1', 'Custom Field 2']} @@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): def test_content_types(self): params = {'content_types': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_required(self): @@ -86,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_choice_set(self): + params = {'choice_set': ['Choice Set 1', 'Choice Set 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests): + queryset = CustomFieldChoiceSet.objects.all() + filterset = CustomFieldChoiceSetFilterSet + + @classmethod + def setUpTestData(cls): + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']), + CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + def test_name(self): + params = {'name': ['Choice Set 1', 'Choice Set 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_choice(self): + params = {'choice': ['A', 'D']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cc3625c7c..9d6054b86 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -5,7 +5,7 @@ from dcim.forms import SiteForm from dcim.models import Site from extras.choices import CustomFieldTypeChoices from extras.forms import SavedFilterForm -from extras.models import CustomField +from extras.models import CustomField, CustomFieldChoiceSet class CustomFieldModelFormTest(TestCase): @@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase): @classmethod def setUpTestData(cls): obj_type = ContentType.objects.get_for_model(Site) - CHOICES = ('A', 'B', 'C') + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=('A', 'B', 'C') + ) cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) cf_text.content_types.set([obj_type]) @@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase): cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON) cf_json.content_types.set([obj_type]) - cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) + cf_select = CustomField.objects.create( + name='select', + type=CustomFieldTypeChoices.TYPE_SELECT, + choice_set=choice_set + ) cf_select.content_types.set([obj_type]) cf_multiselect = CustomField.objects.create( name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES + choice_set=choice_set ) cf_multiselect.content_types.set([obj_type]) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 57efc5be7..acfdcf1e3 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) + CustomFieldChoiceSet.objects.create( + name='Choice Set 1', + extra_choices=('A', 'B', 'C') + ) + custom_fields = ( CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT), @@ -44,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility', 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write', 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write', - 'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write', + 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write', 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', ) @@ -64,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = CustomFieldChoiceSet + + @classmethod + def setUpTestData(cls): + + choice_sets = ( + CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']), + CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']), + CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']), + ) + CustomFieldChoiceSet.objects.bulk_create(choice_sets) + + cls.form_data = { + 'name': 'Choice Set X', + 'extra_choices': 'X1,X2,X3,X4,X5', + } + + cls.csv_data = ( + 'name,extra_choices', + 'Choice Set 4,"4A,4B,4C,4D,4E"', + 'Choice Set 5,"5A,5B,5C,5D,5E"', + 'Choice Set 6,"6A,6B,6C,6D,6E"', + ) + + cls.csv_update_data = ( + 'id,extra_choices', + f'{choice_sets[0].pk},"1X,1Y,1Z"', + f'{choice_sets[1].pk},"2X,2Y,2Z"', + f'{choice_sets[2].pk},"3X,3Y,3Z"', + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomLink diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 086537b99..fd95186e4 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -15,6 +15,14 @@ urlpatterns = [ path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), path('custom-fields//', include(get_model_urls('extras', 'customfield'))), + # Custom field choices + path('custom-field-choices/', views.CustomFieldChoiceSetListView.as_view(), name='customfieldchoiceset_list'), + path('custom-field-choices/add/', views.CustomFieldChoiceSetEditView.as_view(), name='customfieldchoiceset_add'), + path('custom-field-choices/import/', views.CustomFieldChoiceSetBulkImportView.as_view(), name='customfieldchoiceset_import'), + path('custom-field-choices/edit/', views.CustomFieldChoiceSetBulkEditView.as_view(), name='customfieldchoiceset_bulk_edit'), + path('custom-field-choices/delete/', views.CustomFieldChoiceSetBulkDeleteView.as_view(), name='customfieldchoiceset_bulk_delete'), + path('custom-field-choices//', include(get_model_urls('extras', 'customfieldchoiceset'))), + # Custom links path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index e3ba9c0c3..193d8821b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -34,7 +34,7 @@ from .scripts import run_script # class CustomFieldListView(generic.ObjectListView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') filterset = filtersets.CustomFieldFilterSet filterset_form = forms.CustomFieldFilterForm table = tables.CustomFieldTable @@ -42,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView): @register_model_view(CustomField) class CustomFieldView(generic.ObjectView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') @register_model_view(CustomField, 'edit') class CustomFieldEditView(generic.ObjectEditView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') form = forms.CustomFieldForm @register_model_view(CustomField, 'delete') class CustomFieldDeleteView(generic.ObjectDeleteView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') class CustomFieldBulkImportView(generic.BulkImportView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') model_form = forms.CustomFieldImportForm class CustomFieldBulkEditView(generic.BulkEditView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') filterset = filtersets.CustomFieldFilterSet table = tables.CustomFieldTable form = forms.CustomFieldBulkEditForm class CustomFieldBulkDeleteView(generic.BulkDeleteView): - queryset = CustomField.objects.all() + queryset = CustomField.objects.select_related('choice_set') filterset = filtersets.CustomFieldFilterSet table = tables.CustomFieldTable +# +# Custom field choices +# + +class CustomFieldChoiceSetListView(generic.ObjectListView): + queryset = CustomFieldChoiceSet.objects.all() + filterset = filtersets.CustomFieldChoiceSetFilterSet + filterset_form = forms.CustomFieldChoiceSetFilterForm + table = tables.CustomFieldChoiceSetTable + + +@register_model_view(CustomFieldChoiceSet) +class CustomFieldChoiceSetView(generic.ObjectView): + queryset = CustomFieldChoiceSet.objects.all() + + +@register_model_view(CustomFieldChoiceSet, 'edit') +class CustomFieldChoiceSetEditView(generic.ObjectEditView): + queryset = CustomFieldChoiceSet.objects.all() + form = forms.CustomFieldChoiceSetForm + + +@register_model_view(CustomFieldChoiceSet, 'delete') +class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView): + queryset = CustomFieldChoiceSet.objects.all() + + +class CustomFieldChoiceSetBulkImportView(generic.BulkImportView): + queryset = CustomFieldChoiceSet.objects.all() + model_form = forms.CustomFieldChoiceSetImportForm + + +class CustomFieldChoiceSetBulkEditView(generic.BulkEditView): + queryset = CustomFieldChoiceSet.objects.all() + filterset = filtersets.CustomFieldChoiceSetFilterSet + table = tables.CustomFieldChoiceSetTable + form = forms.CustomFieldChoiceSetBulkEditForm + + +class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView): + queryset = CustomFieldChoiceSet.objects.all() + filterset = filtersets.CustomFieldChoiceSetFilterSet + table = tables.CustomFieldChoiceSetTable + + # # Custom links # @@ -541,7 +586,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView): # class ObjectChangeListView(generic.ObjectListView): - queryset = ObjectChange.objects.all() + queryset = ObjectChange.objects.valid_models() filterset = filtersets.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable @@ -551,10 +596,10 @@ class ObjectChangeListView(generic.ObjectListView): @register_model_view(ObjectChange) class ObjectChangeView(generic.ObjectView): - queryset = ObjectChange.objects.all() + queryset = ObjectChange.objects.valid_models() def get_extra_context(self, request, instance): - related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( + related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( request_id=instance.request_id ).exclude( pk=instance.pk @@ -564,7 +609,7 @@ class ObjectChangeView(generic.ObjectView): orderable=False ) - objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter( + objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( changed_object_type=instance.changed_object_type, changed_object_id=instance.changed_object_id, ) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f59850aa2..c2cf38fe7 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -219,12 +219,13 @@ class VLANGroupSerializer(NetBoxModelSerializer): scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) + utilization = serializers.CharField(read_only=True) class Meta: model = VLANGroup fields = [ 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' ] validators = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index c895a706b..16b494dd5 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction +from django.db.models import F +from django.db.models.functions import Round from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_spectacular.utils import extend_schema @@ -149,9 +151,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet): class VLANGroupViewSet(NetBoxModelViewSet): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ).prefetch_related('tags') + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a128b6acc..d011472d9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except (AddrFormatError, ValueError): return queryset.none() + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -659,6 +662,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset return queryset.filter(address__net_mask_length=value) + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -727,6 +731,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): Q(name__icontains=value) ) + @extend_schema_field(OpenApiTypes.STR) def filter_related_ip(self, queryset, name, value): """ Filter by VRF & prefix of assigned IP addresses. @@ -941,9 +946,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): pass return queryset.filter(qs_filter) + @extend_schema_field(OpenApiTypes.STR) def get_for_device(self, queryset, name, value): return queryset.get_for_device(value) + @extend_schema_field(OpenApiTypes.STR) def get_for_virtualmachine(self, queryset, name, value): return queryset.get_for_virtualmachine(value) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index b0b08e4e0..a3c218fc9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -345,7 +345,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: raise ValidationError( "Cannot reassign IP address while it is designated as the primary IP for the parent object" ) @@ -379,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): interface = self.instance.assigned_object if type(interface) in (Interface, VMInterface): parent = interface.parent_object + parent.snapshot() if self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: parent.primary_ip4 = ipaddress diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index a07cbb789..6c0b5231b 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from ipam.fields import ASNField +from ipam.querysets import ASNRangeQuerySet from netbox.models import OrganizationalModel, PrimaryModel __all__ = ( @@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel): null=True ) + objects = ASNRangeQuerySet.as_manager() + class Meta: ordering = ('name',) verbose_name = 'ASN range' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 015f9220c..00dcf8422 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): Return all available IPs within this prefix as an IPSet. """ if self.mark_utilized: - return list() + return netaddr.IPSet() prefix = netaddr.IPSet(self.prefix) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7d4777da9..da504ded2 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext as _ from dcim.models import Interface from ipam.choices import * from ipam.constants import * -from ipam.querysets import VLANQuerySet +from ipam.querysets import VLANQuerySet, VLANGroupQuerySet from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VMInterface @@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel): help_text=_('Highest permissible ID of a child VLAN') ) + objects = VLANGroupQuerySet.as_manager() + class Meta: ordering = ('name', 'pk') # Name may be non-unique constraints = ( diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 9f4463f61..39da0c3a2 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,8 +1,34 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.db.models import Count, F, OuterRef, Q, Subquery, Value from django.db.models.expressions import RawSQL +from django.db.models.functions import Round from utilities.querysets import RestrictedQuerySet +from utilities.utils import count_related + +__all__ = ( + 'ASNRangeQuerySet', + 'PrefixQuerySet', + 'VLANQuerySet', +) + + +class ASNRangeQuerySet(RestrictedQuerySet): + + def annotate_asn_counts(self): + """ + Annotate the number of ASNs which appear within each range. + """ + from .models import ASN + + # Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value + # that we can use to count ASNs and return a single value per ASNRange. + asns = ASN.objects.filter( + asn__gte=OuterRef('start'), + asn__lte=OuterRef('end') + ).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c') + + return self.annotate(asn_count=Subquery(asns)) class PrefixQuerySet(RestrictedQuerySet): @@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet): ) +class VLANGroupQuerySet(RestrictedQuerySet): + + def annotate_utilization(self): + from .models import VLAN + + return self.annotate( + vlan_count=count_related(VLAN, 'group'), + utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2) + ) + + class VLANQuerySet(RestrictedQuerySet): def get_for_device(self, device): diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py index 511e914ec..356f2fc17 100644 --- a/netbox/ipam/tables/asn.py +++ b/netbox/ipam/tables/asn.py @@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:asnrange_list' ) - asn_count = columns.LinkedCountColumn( - viewname='ipam:asn_list', - url_params={'asn_id': 'pk'}, - verbose_name=_('ASN Count') + asn_count = tables.Column( + verbose_name=_('ASNs') ) class Meta(NetBoxTable.Meta): @@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Provider Count') ) sites = columns.ManyToManyColumn( - linkify_item=True + linkify_item=True, + verbose_name=_('Sites') ) comments = columns.MarkdownColumn() tags = columns.TagColumn( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 86d1a3775..aff090f3a 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -19,14 +19,22 @@ __all__ = ( AVAILABLE_LABEL = mark_safe('Available') +AGGREGATE_COPY_BUTTON = """ +{% copy_content record.pk prefix="aggregate_" %} +""" + PREFIX_LINK = """ {% if record.pk %} - {{ record.prefix }} + {{ record.prefix }} {% else %} {{ record.prefix }} {% endif %} """ +PREFIX_COPY_BUTTON = """ +{% copy_content record.pk prefix="prefix_" %} +""" + PREFIX_LINK_WITH_DEPTH = """ {% load helpers %} {% if record.depth %} @@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """ IPADDRESS_LINK = """ {% if record.pk %} - {{ record.address }} + {{ record.address }} {% elif perms.ipam.add_ipaddress %} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} @@ -48,6 +56,10 @@ IPADDRESS_LINK = """ {% endif %} """ +IPADDRESS_COPY_BUTTON = """ +{% copy_content record.pk prefix="ipaddress_" %} +""" + IPADDRESS_ASSIGN_LINK = """ {{ record }} """ @@ -99,7 +111,11 @@ class RIRTable(NetBoxTable): class AggregateTable(TenancyColumnsMixin, NetBoxTable): prefix = tables.Column( linkify=True, - verbose_name='Aggregate' + verbose_name='Aggregate', + attrs={ + # Allow the aggregate to be copied to the clipboard + 'a': {'id': lambda record: f"aggregate_{record.pk}"} + } ) date_added = tables.DateColumn( format="Y-m-d", @@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:aggregate_list' ) + actions = columns.ActionsColumn( + extra_buttons=AGGREGATE_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Aggregate @@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:prefix_list' ) + actions = columns.ActionsColumn( + extra_buttons=PREFIX_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Prefix @@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) + actions = columns.ActionsColumn( + extra_buttons=IPADDRESS_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 6fa2cd2da..5d9828531 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + utilization = columns.UtilizationColumn( + orderable=False, + verbose_name='Utilization' + ) tags = columns.TagColumn( url_name='ipam:vlangroup_list' ) @@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', - 'tags', 'created', 'last_updated', 'actions', + 'tags', 'created', 'last_updated', 'actions', 'utilization', ) - default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description') # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6b73a061b..32badd2d5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Prefetch +from django.db.models import F, Prefetch from django.db.models.expressions import RawSQL +from django.db.models.functions import Round from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -198,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView): # class ASNRangeListView(generic.ObjectListView): - queryset = ASNRange.objects.all() + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet filterset_form = forms.ASNRangeFilterForm table = tables.ASNRangeTable @@ -247,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView): class ASNRangeBulkEditView(generic.BulkEditView): - queryset = ASNRange.objects.annotate( - site_count=count_related(Site, 'asns') - ) + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet table = tables.ASNRangeTable form = forms.ASNRangeBulkEditForm class ASNRangeBulkDeleteView(generic.BulkDeleteView): - queryset = ASNRange.objects.annotate( - site_count=count_related(Site, 'asns') - ) + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet table = tables.ASNRangeTable @@ -886,9 +883,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView): # class VLANGroupListView(generic.ObjectListView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable @@ -896,7 +891,7 @@ class VLANGroupListView(generic.ObjectListView): @register_model_view(VLANGroup) class VLANGroupView(generic.ObjectView): - queryset = VLANGroup.objects.all() + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') def get_extra_context(self, request, instance): related_models = ( @@ -938,18 +933,14 @@ class VLANGroupBulkImportView(generic.BulkImportView): class VLANGroupBulkEditView(generic.BulkEditView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet table = tables.VLANGroupTable form = forms.VLANGroupBulkEditForm class VLANGroupBulkDeleteView(generic.BulkDeleteView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet table = tables.VLANGroupTable diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 814ca1ed6..f0bd5fd27 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication): user = token.user # When LDAP authentication is active try to load user data from LDAP directory - if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': + if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 76b3e42a8..18f350fd7 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -49,6 +49,9 @@ class CoreMiddleware: # Attach the unique request ID as an HTTP header. response['X-Request-ID'] = request.id + # Enable the Vary header to help with caching of HTMX responses + response['Vary'] = 'HX-Request' + # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5'). if is_api_request(request): response['API-Version'] = settings.REST_FRAMEWORK_VERSION @@ -203,7 +206,7 @@ class MaintenanceModeMiddleware: """ Prevent any write-related database operations if an exception is raised. """ - if isinstance(exception, InternalError): + if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError): error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \ 'operations. Please try again later.' diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 252dcdfd9..beb4cab0c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,6 +1,7 @@ from django.utils.translation import gettext as _ from netbox.registry import registry +from utilities.choices import ButtonColorChoices from . import * # @@ -288,6 +289,7 @@ CUSTOMIZATION_MENU = Menu( label=_('Customization'), items=( get_model_item('extras', 'customfield', _('Custom Fields')), + get_model_item('extras', 'customfieldchoiceset', _('Custom Field Choices')), get_model_item('extras', 'customlink', _('Custom Links')), get_model_item('extras', 'exporttemplate', _('Export Templates')), get_model_item('extras', 'savedfilter', _('Saved Filters')), @@ -350,6 +352,56 @@ ADMIN_MENU = Menu( label=_('Admin'), icon_class='mdi mdi-account-multiple', groups=( + MenuGroup( + label=_('Users'), + items=( + # Proxy model for auth.User + MenuItem( + link=f'users:netboxuser_list', + link_text=_('Users'), + permissions=[f'auth.view_user'], + buttons=( + MenuItemButton( + link=f'users:netboxuser_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'auth.add_user'], + color=ButtonColorChoices.GREEN + ), + MenuItemButton( + link=f'users:netboxuser_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'auth.add_user'], + color=ButtonColorChoices.CYAN + ) + ) + ), + # Proxy model for auth.Group + MenuItem( + link=f'users:netboxgroup_list', + link_text=_('Groups'), + permissions=[f'auth.view_group'], + buttons=( + MenuItemButton( + link=f'users:netboxgroup_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'auth.add_group'], + color=ButtonColorChoices.GREEN + ), + MenuItemButton( + link=f'users:netboxgroup_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'auth.add_group'], + color=ButtonColorChoices.CYAN + ) + ) + ), + get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), + ), + ), MenuGroup( label=_('Configuration'), items=( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 31363144f..7d2da2996 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.5-dev' +VERSION = '3.5.7-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9ef327026..1f698f396 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -21,6 +21,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view __all__ = ( 'ActionsColumn', + 'ArrayColumn', 'BooleanColumn', 'ChoiceFieldColumn', 'ColorColumn', @@ -591,3 +592,22 @@ class MarkdownColumn(tables.TemplateColumn): def value(self, value): return value + + +class ArrayColumn(tables.Column): + """ + List array items as a comma-separated list. + """ + def __init__(self, *args, max_items=None, **kwargs): + self.max_items = max_items + super().__init__(*args, **kwargs) + + def render(self, value): + if self.max_items: + # Limit the returned items to the specified maximum number + omitted = len(value) - self.max_items + value = value[:self.max_items - 1] + if omitted > 0: + value.append(f'({omitted} more)') + + return ', '.join(value) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index e66e79a7a..35caa31b3 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): for name, m2m_field in m2m_fields.items(): if name in form.nullable_fields and name in nullified_fields: getattr(obj, name).clear() - else: + elif form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Add/remove tags diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 8e363f0a5..a55f01509 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -22,6 +22,7 @@ class ActionsMixin: Return a tuple of actions for which the given user is permitted to do. """ model = model or self.queryset.model + return [ action for action in self.actions if user.has_perms([ get_permission_for_model(model, name) for name in self.action_perms[action] diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 9642d1585..b62436d75 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index f86d50148..ed3833f98 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/clipboard.ts b/netbox/project-static/src/clipboard.ts index a04acba39..46ca5e36c 100644 --- a/netbox/project-static/src/clipboard.ts +++ b/netbox/project-static/src/clipboard.ts @@ -2,7 +2,7 @@ import Clipboard from 'clipboard'; import { getElements } from './util'; export function initClipboard(): void { - for (const element of getElements('a.copy-token', 'button.copy-secret')) { + for (const element of getElements('a.copy-content')) { new Clipboard(element); } } diff --git a/netbox/templates/core/datafile.html b/netbox/templates/core/datafile.html index 3d79d17e2..785617ae5 100644 --- a/netbox/templates/core/datafile.html +++ b/netbox/templates/core/datafile.html @@ -39,9 +39,7 @@ Path {{ object.path }} - - - + {% copy_content "datafile_path" %} @@ -56,9 +54,7 @@ SHA256 Hash {{ object.hash }} - - - + {% copy_content "datafile_hash" %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 68fa84a24..c81bb5a3c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -211,12 +211,13 @@ Primary IPv4 {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} + {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip4" %} {% else %} {{ ''|placeholder }} {% endif %} @@ -226,12 +227,13 @@ Primary IPv6 {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} + {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip6" %} {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 0ee4c1ccf..bb28be9a2 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -15,15 +15,14 @@ Rack {{ terminations.0.device.rack|linkify|placeholder }} - - Device - {{ terminations.0.device|linkify }} - {{ terminations.0|meta:"verbose_name"|capfirst }} {% for term in terminations %} - {{ term|linkify }}{% if not forloop.last %},{% endif %} + {{term.device|linkify}} + + {{ term|linkify }} + {% if not forloop.last %}
{% endif %} {% endfor %} diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html index d6e3e0c63..1caf05bd2 100644 --- a/netbox/templates/dcim/virtualdevicecontext.html +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -31,13 +31,23 @@ Primary IPv4 - {{ object.primary_ip4|linkify|placeholder }} + {% if object.primary_ip4 %} + {{ object.primary_ip4 }} + {% copy_content "primary_ip4" %} + {% else %} + — + {% endif %} Primary IPv6 - {{ object.primary_ip6|linkify|placeholder }} + {% if object.primary_ip6 %} + {{ object.primary_ip6 }} + {% copy_content "primary_ip6" %} + {% else %} + — + {% endif %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index b783c8a77..bab207243 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -15,14 +15,6 @@ Name {{ object.name }} - - Label - {{ object.label|placeholder }} - - - Group Name - {{ object.group_name|placeholder }} - Type @@ -30,6 +22,14 @@ {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + + Label + {{ object.label|placeholder }} + + + Group + {{ object.group_name|placeholder }} + Description {{ object.description|markdown|placeholder }} @@ -38,6 +38,27 @@ Required {% checkmark object.required %} + + Cloneable + {% checkmark object.is_cloneable %} + + {% if object.choice_set %} + + Choice Set + {{ object.choice_set|linkify }} ({{ object.choice_set.choices|length }} choices) + + {% endif %} + + Default Value + {{ object.default }} + + + + +
+
Behavior
+
+ - - - - -
Search Weight @@ -60,33 +81,6 @@ UI Visibility {{ object.get_ui_visibility_display }}
Cloneable{% checkmark object.is_cloneable %}
-
-
-
-
- Values -
-
- - - - - - - - -
Default Value{{ object.default }}
Choices - {% if object.choices %} - {{ object.choices|join:", " }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
@@ -94,9 +88,7 @@
-
- Assigned Models -
+
Object Types
{% for ct in object.content_types.all %} @@ -108,9 +100,7 @@
-
- Validation Rules -
+
Validation Rules
@@ -138,8 +128,8 @@
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/templates/extras/customfieldchoiceset.html b/netbox/templates/extras/customfieldchoiceset.html new file mode 100644 index 000000000..25c95729e --- /dev/null +++ b/netbox/templates/extras/customfieldchoiceset.html @@ -0,0 +1,64 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Custom Field Choice Set
+
+
+ + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|markdown|placeholder }}
Choices{{ object.choices|length }}
Order Alphabetically{% checkmark object.order_alphabetically %}
Used by +
    + {% for cf in object.choices_for.all %} +
  • {{ cf|linkify }}
  • + {% endfor %} +
+
+
+
+ {% plugin_left_page object %} +
+
+
+
Choices
+
+ + {% for choice in object.choices %} + + + + {% endfor %} +
{{ choice }}
+
+
+ {% plugin_right_page object %} +
+ +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 0c27eefda..e1efec755 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -38,71 +38,77 @@
{% include 'inc/sync_warning.html' with object=module %} - - - - - - - - - - - - {% with jobs=module.get_latest_jobs %} - {% for report_name, report in module.reports.items %} - {% with last_job=jobs|get_key:report.name %} - - - - {% if last_job %} - - - {% else %} - - - {% endif %} - - - {% for method, stats in last_job.data.items %} + {% if module.reports %} +
NameDescriptionLast RunStatus
- {{ report.name }} - {{ report.description|markdown|placeholder }} - {{ last_job.created|annotated_date }} - - {% badge last_job.get_status_display last_job.get_status_color %} - Never{{ ''|placeholder }} - {% if perms.extras.run_report %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
+ + + + + + + + + + + {% with jobs=module.get_latest_jobs %} + {% for report_name, report in module.reports.items %} + {% with last_job=jobs|get_key:report.class_name %} - - + {% if last_job %} + + + {% else %} + + + {% endif %} + - {% endfor %} - {% endwith %} - {% endfor %} - {% endwith %} - -
NameDescriptionLast RunStatus
- {{ method }} + + {{ report.name }} - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} + {{ report.description|markdown|placeholder }} + {{ last_job.created|annotated_date }} + + {% badge last_job.get_status_display last_job.get_status_color %} + Never{{ ''|placeholder }} + {% if perms.extras.run_report %} +
+
+ {% csrf_token %} + +
+
+ {% endif %}
+ {% for method, stats in last_job.data.items %} + + + {{ method }} + + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} + + + {% endfor %} + {% endwith %} + {% endfor %} + {% endwith %} + + + {% else %} + + {% endif %}
{% empty %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index b7ef2a908..b515e8a99 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -15,9 +15,9 @@
{% csrf_token %}
- {% if form.requires_input %} - {# Render grouped fields according to declared fieldsets #} - {% for group, fields in script.get_fieldsets %} + {# Render grouped fields according to declared fieldsets #} + {% for group, fields in script.get_fieldsets %} + {% if fields %}
{{ group }}
@@ -28,14 +28,8 @@ {% endwith %} {% endfor %}
- {% endfor %} - {% else %} -
- - This script does not require any input to run. -
- {% render_form form %} - {% endif %} + {% endif %} + {% endfor %}
Cancel diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 9a67e2b10..0f32ba0b9 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -61,7 +61,7 @@ {{ script_class.Meta.description|markdown|placeholder }} - {% with last_result=jobs|get_key:script_class.name %} + {% with last_result=jobs|get_key:script_class.class_name %} {% if last_result %} {{ last_result.created|annotated_date }} diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 2917536be..e474cbd84 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -42,6 +42,10 @@ Permitted VIDs {{ object.min_vid }} - {{ object.max_vid }} + + Utilization + {% utilization_graph object.utilization %} +
diff --git a/netbox/templates/tenancy/object_contacts.html b/netbox/templates/tenancy/object_contacts.html index bbe2d9bc1..e13fedc43 100644 --- a/netbox/templates/tenancy/object_contacts.html +++ b/netbox/templates/tenancy/object_contacts.html @@ -2,10 +2,12 @@ {% load helpers %} {% block extra_controls %} - {% if perms.tenancy.add_contactassignment %} - + {% if perms.tenancy.add_contactassignment %} + {% with viewname=object|viewname:"contacts" %} + Add a contact - + + {% endwith %} {% endif %} {% endblock %} diff --git a/netbox/templates/users/api_token.html b/netbox/templates/users/account/api_token.html similarity index 82% rename from netbox/templates/users/api_token.html rename to netbox/templates/users/account/api_token.html index 1a9296704..7fd6f064d 100644 --- a/netbox/templates/users/api_token.html +++ b/netbox/templates/users/account/api_token.html @@ -8,7 +8,7 @@
{% if not settings.ALLOW_TOKEN_RETRIEVAL %} {% endif %}
@@ -19,9 +19,7 @@ Key
- - - + {% copy_content "token_id" %}
{{ key }}
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/account/api_tokens.html similarity index 94% rename from netbox/templates/users/api_tokens.html rename to netbox/templates/users/account/api_tokens.html index e1641468c..25f5f02e6 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/account/api_tokens.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/base.html b/netbox/templates/users/account/base.html similarity index 68% rename from netbox/templates/users/base.html rename to netbox/templates/users/account/base.html index e07e28ced..f492f89ec 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/account/base.html @@ -1,23 +1,24 @@ {% extends 'base/layout.html' %} +{% load i18n %} {% block tabs %} {% endblock %} diff --git a/netbox/templates/users/bookmarks.html b/netbox/templates/users/account/bookmarks.html similarity index 95% rename from netbox/templates/users/bookmarks.html rename to netbox/templates/users/account/bookmarks.html index 66f367a1c..fa3c28c7c 100644 --- a/netbox/templates/users/bookmarks.html +++ b/netbox/templates/users/account/bookmarks.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'users/account/base.html' %} {% load buttons %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/password.html b/netbox/templates/users/account/password.html similarity index 94% rename from netbox/templates/users/password.html rename to netbox/templates/users/account/password.html index 02e80bb26..dcdd19e29 100644 --- a/netbox/templates/users/password.html +++ b/netbox/templates/users/account/password.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'users/account/base.html' %} {% load form_helpers %} {% block title %}Change Password{% endblock %} diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/account/preferences.html similarity index 98% rename from netbox/templates/users/preferences.html rename to netbox/templates/users/account/preferences.html index f2c88db3c..59cca302c 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/account/preferences.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load form_helpers %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/account/profile.html similarity index 98% rename from netbox/templates/users/profile.html rename to netbox/templates/users/account/profile.html index 913784c94..0e8ab1162 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/account/profile.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html new file mode 100644 index 000000000..e4eee0812 --- /dev/null +++ b/netbox/templates/users/group.html @@ -0,0 +1,48 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "Group" %}
+
+ + + + + +
{% trans "Name" %}{{ object.name }}
+
+
+
+
+
+
{% trans "Users" %}
+
+ {% for user in object.user_set.all %} + {{ user }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
{% trans "Assigned Permissions" %}
+
+ {% for perm in object.object_permissions.all %} + {{ perm }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html new file mode 100644 index 000000000..4da5a6ea5 --- /dev/null +++ b/netbox/templates/users/objectpermission.html @@ -0,0 +1,97 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "Permission" %}
+
+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Enabled" %}{% checkmark object.enabled %}
+
+
+
+
{% trans "Actions" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "View" %}{% checkmark object.can_view %}
{% trans "Add" %}{% checkmark object.can_add %}
{% trans "Change" %}{% checkmark object.can_change %}
{% trans "Delete" %}{% checkmark object.can_delete %}
+
+
+
+
{% trans "Constraints" %}
+
+ {% if object.constraints %} +
{{ object.constraints|json }}
+ {% else %} + None + {% endif %} +
+
+
+
+
+
{% trans "Object Types" %}
+
    + {% for user in object.object_types.all %} +
  • {{ user }}
  • + {% endfor %} +
+
+
+
{% trans "Assigned Users" %}
+
+ {% for user in object.users.all %} + {{ user }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
{% trans "Assigned Groups" %}
+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html new file mode 100644 index 000000000..fe03f41ed --- /dev/null +++ b/netbox/templates/users/user.html @@ -0,0 +1,84 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "User" %} {{ object.username }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "User" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Username" %}{{ object.username }}
{% trans "Full Name" %}{{ object.get_full_name|placeholder }}
{% trans "Email" %}{{ object.email|placeholder }}
{% trans "Account Created" %}{{ object.date_joined|annotated_date }}
{% trans "Active" %}{% checkmark object.active %}
{% trans "Staff" %}{% checkmark object.is_staff %}
{% trans "Superuser" %}{% checkmark object.is_superuser %}
+
+
+
+
+
+
{% trans "Assigned Groups" %}
+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
{% trans "Assigned Permissions" %}
+
+ {% for perm in object.object_permissions.all %} + {{ perm }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+ {% if perms.extras.view_objectchange %} +
+
+
+
{% trans "Recent Activity" %}
+
+ {% render_table changelog_table 'inc/table.html' %} +
+
+
+
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 51fd8aa80..3d3b498ad 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -46,12 +46,13 @@ Primary IPv4 {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} + {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip4" %} {% else %} {{ ''|placeholder }} {% endif %} @@ -61,12 +62,13 @@ Primary IPv6 {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} + {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} + {% copy_content "primary_ip6" %} {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 440541b5f..1df5e3305 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel): def get_absolute_url(self): return reverse('tenancy:contact', args=[self.contact.pk]) + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.object + return objectchange diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 0c697af79..7de8ffceb 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -1,4 +1,5 @@ import django_tables2 as tables +from django_tables2.utils import Accessor from netbox.tables import NetBoxTable, columns from tenancy.models import * @@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable): role = tables.Column( linkify=True ) + contact_title = tables.Column( + accessor=Accessor('contact__title'), + verbose_name='Contact Title' + ) + contact_phone = tables.Column( + accessor=Accessor('contact__phone'), + verbose_name='Contact Phone' + ) + contact_email = tables.Column( + accessor=Accessor('contact__email'), + verbose_name='Contact Email' + ) + contact_address = tables.Column( + accessor=Accessor('contact__address'), + verbose_name='Contact Address' + ) + contact_link = tables.Column( + accessor=Accessor('contact__link'), + verbose_name='Contact Link' + ) + contact_description = tables.Column( + accessor=Accessor('contact__description'), + verbose_name='Contact Description' + ) actions = columns.ActionsColumn( actions=('edit', 'delete') ) class Meta(NetBoxTable.Meta): model = ContactAssignment - fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') - default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') + fields = ( + 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone', + 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions' + ) + default_columns = ( + 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone' + ) diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 2db822cfe..316346c50 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -15,41 +15,6 @@ admin.site.unregister(Group) admin.site.unregister(User) -@admin.register(Group) -class GroupAdmin(admin.ModelAdmin): - form = forms.GroupAdminForm - list_display = ('name', 'user_count') - ordering = ('name',) - search_fields = ('name',) - inlines = [inlines.GroupObjectPermissionInline] - - @staticmethod - def user_count(obj): - return obj.user_set.count() - - -@admin.register(User) -class UserAdmin(UserAdmin_): - list_display = [ - 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' - ] - fieldsets = ( - (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), - ('Groups', {'fields': ('groups',)}), - ('Status', { - 'fields': ('is_active', 'is_staff', 'is_superuser'), - }), - ('Important dates', {'fields': ('last_login', 'date_joined')}), - ) - filter_horizontal = ('groups',) - list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') - - def get_inlines(self, request, obj): - if obj is not None: - return (inlines.UserObjectPermissionInline, inlines.UserConfigInline) - return () - - # # REST API tokens # @@ -64,66 +29,3 @@ class TokenAdmin(admin.ModelAdmin): def list_allowed_ips(self, obj): return obj.allowed_ips or 'Any' list_allowed_ips.short_description = "Allowed IPs" - - -# -# Permissions -# - -@admin.register(ObjectPermission) -class ObjectPermissionAdmin(admin.ModelAdmin): - actions = ('enable', 'disable') - fieldsets = ( - (None, { - 'fields': ('name', 'description', 'enabled') - }), - ('Actions', { - 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') - }), - ('Objects', { - 'fields': ('object_types',) - }), - ('Assignment', { - 'fields': ('groups', 'users') - }), - ('Constraints', { - 'fields': ('constraints',), - 'classes': ('monospace',) - }), - ) - filter_horizontal = ('object_types', 'groups', 'users') - form = forms.ObjectPermissionForm - list_display = [ - 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', - ] - list_filter = [ - 'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users' - ] - search_fields = ['actions', 'constraints', 'description', 'name'] - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') - - def list_models(self, obj): - return ', '.join([f"{ct}" for ct in obj.object_types.all()]) - list_models.short_description = 'Models' - - def list_users(self, obj): - return ', '.join([u.username for u in obj.users.all()]) - list_users.short_description = 'Users' - - def list_groups(self, obj): - return ', '.join([g.name for g in obj.groups.all()]) - list_groups.short_description = 'Groups' - - # - # Admin actions - # - - def enable(self, request, queryset): - updated = queryset.update(enabled=True) - self.message_user(request, f"Enabled {updated} permissions") - - def disable(self, request, queryset): - updated = queryset.update(enabled=False) - self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 986ddd0aa..7db6a124c 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -1,49 +1,13 @@ from django import forms -from django.contrib.auth.models import Group, User -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, ValidationError from django.utils.translation import gettext as _ -from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES -from users.models import ObjectPermission, Token -from utilities.forms.fields import ContentTypeMultipleChoiceField -from utilities.permissions import qs_filter_from_constraints +from users.models import Token __all__ = ( - 'GroupAdminForm', - 'ObjectPermissionForm', 'TokenAdminForm', ) -class GroupAdminForm(forms.ModelForm): - users = forms.ModelMultipleChoiceField( - queryset=User.objects.all(), - required=False, - widget=FilteredSelectMultiple('users', False) - ) - - class Meta: - model = Group - fields = ('name', 'users') - - def __init__(self, *args, **kwargs): - super(GroupAdminForm, self).__init__(*args, **kwargs) - - if self.instance.pk: - self.fields['users'].initial = self.instance.user_set.all() - - def save_m2m(self): - self.instance.user_set.set(self.cleaned_data['users']) - - def save(self, *args, **kwargs): - instance = super(GroupAdminForm, self).save() - self.save_m2m() - - return instance - - class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, @@ -55,82 +19,3 @@ class TokenAdminForm(forms.ModelForm): 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token - - -class ObjectPermissionForm(forms.ModelForm): - object_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES - ) - can_view = forms.BooleanField(required=False) - can_add = forms.BooleanField(required=False) - can_change = forms.BooleanField(required=False) - can_delete = forms.BooleanField(required=False) - - class Meta: - model = ObjectPermission - exclude = [] - help_texts = { - 'actions': _('Actions granted in addition to those listed above'), - 'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null ' - 'to match all objects of this type. A list of multiple objects will result in a logical OR ' - 'operation.') - } - labels = { - 'actions': 'Additional actions' - } - widgets = { - 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Make the actions field optional since the admin form uses it only for non-CRUD actions - self.fields['actions'].required = False - - # Order group and user fields - self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') - self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') - - # Check the appropriate checkboxes when editing an existing ObjectPermission - if self.instance.pk: - for action in ['view', 'add', 'change', 'delete']: - if action in self.instance.actions: - self.fields[f'can_{action}'].initial = True - self.instance.actions.remove(action) - - def clean(self): - super().clean() - - object_types = self.cleaned_data.get('object_types') - constraints = self.cleaned_data.get('constraints') - - # Append any of the selected CRUD checkboxes to the actions list - if not self.cleaned_data.get('actions'): - self.cleaned_data['actions'] = list() - for action in ['view', 'add', 'change', 'delete']: - if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: - self.cleaned_data['actions'].append(action) - - # At least one action must be specified - if not self.cleaned_data['actions']: - raise ValidationError("At least one action must be selected.") - - # Validate the specified model constraints by attempting to execute a query. We don't care whether the query - # returns anything; we just want to make sure the specified constraints are valid. - if object_types and constraints: - # Normalize the constraints to a list of dicts - if type(constraints) is not list: - constraints = [constraints] - for ct in object_types: - model = ct.model_class() - try: - tokens = { - CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID - } - model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() - except FieldError as e: - raise ValidationError({ - 'constraints': f'Invalid filter for {model}: {e}' - }) diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 44ad98cc2..a4e9a9fbc 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -49,7 +49,7 @@ class UserFilterSet(BaseFilterSet): class Meta: model = get_user_model() - fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] + fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser'] def search(self, queryset, name, value): if not value.strip(): @@ -115,6 +115,18 @@ class ObjectPermissionFilterSet(BaseFilterSet): method='search', label=_('Search'), ) + can_view = django_filters.BooleanFilter( + method='_check_action' + ) + can_add = django_filters.BooleanFilter( + method='_check_action' + ) + can_change = django_filters.BooleanFilter( + method='_check_action' + ) + can_delete = django_filters.BooleanFilter( + method='_check_action' + ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', queryset=get_user_model().objects.all(), @@ -149,3 +161,10 @@ class ObjectPermissionFilterSet(BaseFilterSet): Q(name__icontains=value) | Q(description__icontains=value) ) + + def _check_action(self, queryset, name, value): + action = name.split('_')[1] + if value: + return queryset.filter(actions__contains=[action]) + else: + return queryset.exclude(actions__contains=[action]) diff --git a/netbox/users/forms/__init__.py b/netbox/users/forms/__init__.py index 1499f98b2..a545c3add 100644 --- a/netbox/users/forms/__init__.py +++ b/netbox/users/forms/__init__.py @@ -1,3 +1,4 @@ +from .authentication import * from .bulk_edit import * from .bulk_import import * from .filtersets import * diff --git a/netbox/users/forms/authentication.py b/netbox/users/forms/authentication.py new file mode 100644 index 000000000..2b540b752 --- /dev/null +++ b/netbox/users/forms/authentication.py @@ -0,0 +1,25 @@ +from django.contrib.auth.forms import ( + AuthenticationForm, + PasswordChangeForm as DjangoPasswordChangeForm, +) + +from utilities.forms import BootstrapMixin + +__all__ = ( + 'LoginForm', + 'PasswordChangeForm', +) + + +class LoginForm(BootstrapMixin, AuthenticationForm): + """ + Used to authenticate a user by username and password. + """ + pass + + +class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): + """ + This form enables a user to change his or her own password. + """ + pass diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index df2b418b0..2df698c84 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -1,22 +1,39 @@ from django import forms -from django.utils.translation import gettext as _ - +from django.utils.translation import gettext_lazy as _ from users.models import * -from utilities.forms import BulkEditForm, add_blank_choice +from utilities.forms import BootstrapMixin, BulkEditForm, add_blank_choice +from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( + 'ObjectPermissionBulkEditForm', 'TokenBulkEditForm', + 'UserBulkEditForm', ) class TokenBulkEditForm(BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Token.objects.all(), - widget=forms.MultipleHiddenInput - ) - description = forms.CharField( - max_length=200, - required=False - ) + pk = forms.ModelMultipleChoiceField(queryset=Token.objects.all(), widget=forms.MultipleHiddenInput) + description = forms.CharField(max_length=200, required=False) + +class UserBulkEditForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=NetBoxUser.objects.all(), widget=forms.MultipleHiddenInput) + first_name = forms.CharField(label=_('First name'), max_length=150, required=False) + last_name = forms.CharField(label=_('Last name'), max_length=150, required=False) + is_active = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Active')) + is_staff = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Staff status')) + is_superuser = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Superuser status')) + + model = NetBoxUser + fieldsets = ((None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')),) + nullable_fields = ('first_name', 'last_name') + + +class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=ObjectPermission.objects.all(), widget=forms.MultipleHiddenInput) + description = forms.CharField(label=_('Description'), max_length=200, required=False) + enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Enabled')) + + model = ObjectPermission + fieldsets = ((None, ('enabled', 'description')),) nullable_fields = ('description',) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 31283bd4a..a395ad5a5 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -3,7 +3,9 @@ from utilities.forms import CSVModelForm __all__ = ( + 'GroupImportForm', 'TokenImportForm', + 'UserImportForm', ) @@ -12,3 +14,28 @@ class TokenImportForm(CSVModelForm): class Meta: model = Token fields = ('description', ) + + +class GroupImportForm(CSVModelForm): + + class Meta: + model = NetBoxGroup + fields = ( + 'name', + ) + + +class UserImportForm(CSVModelForm): + + class Meta: + model = NetBoxUser + fields = ( + 'username', 'first_name', 'last_name', 'email', 'password', 'is_staff', + 'is_active', 'is_superuser' + ) + + def save(self, *args, **kwargs): + # Set the hashed password + self.instance.set_password(self.cleaned_data.get('password')) + + return super().save(*args, **kwargs) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 70c06828d..993e88dbc 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -2,11 +2,118 @@ from django import forms from extras.forms.mixins import SavedFiltersMixin from utilities.forms import FilterForm from users.models import Token +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.utils.translation import gettext_lazy as _ + +from netbox.forms import NetBoxModelFilterSetForm +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES +from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( + 'GroupFilterForm', + 'ObjectPermissionFilterForm', 'TokenFilterForm', + 'UserFilterForm', ) class TokenFilterForm(SavedFiltersMixin, FilterForm): model = Token + + +class GroupFilterForm(NetBoxModelFilterSetForm): + model = NetBoxGroup + fieldsets = ( + (None, ('q', 'filter_id',)), + ) + + +class UserFilterForm(NetBoxModelFilterSetForm): + model = NetBoxUser + fieldsets = ( + (None, ('q', 'filter_id',)), + (_('Group'), ('group_id',)), + (_('Status'), ('is_active', 'is_staff', 'is_superuser')), + ) + group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Group') + ) + is_active = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Is Active'), + ) + is_staff = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Is Staff'), + ) + is_superuser = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Is Superuser'), + ) + + +class ObjectPermissionFilterForm(NetBoxModelFilterSetForm): + model = ObjectPermission + fieldsets = ( + (None, ('q', 'filter_id',)), + (_('Permission'), ('enabled', 'group_id', 'user_id')), + (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete')), + ) + enabled = forms.NullBooleanField( + label=_('Enabled'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Group') + ) + user_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label=_('User') + ) + can_view = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can View'), + ) + can_add = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can Add'), + ) + can_change = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can Change'), + ) + can_delete = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can Delete'), + ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 83ca6f269..1c0af70fd 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -1,17 +1,38 @@ from django import forms from django.conf import settings -from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.forms import PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField +from django.core.exceptions import FieldError from django.utils.html import mark_safe from django.utils.translation import gettext as _ - +from django.utils.translation import gettext_lazy as _ from ipam.formfields import IPNetworkFormField from ipam.validators import prefix_validator -from netbox.preferences import PREFERENCES -from utilities.forms import BootstrapMixin -from utilities.forms.widgets import DateTimePicker -from utilities.utils import flatten_dict +from users.constants import * +from users.models import * from users.models import Token, UserConfig +from utilities.forms import BootstrapMixin +from utilities.forms.fields import ( + ContentTypeMultipleChoiceField, + DynamicModelMultipleChoiceField, +) +from utilities.forms.widgets import DateTimePicker +from utilities.permissions import qs_filter_from_constraints +from utilities.utils import flatten_dict + +from netbox.preferences import PREFERENCES + +__all__ = ( + 'GroupForm', + 'ObjectPermissionForm', + 'TokenForm', + 'UserConfigForm', + 'UserForm', +) class LoginForm(BootstrapMixin, AuthenticationForm): @@ -23,9 +44,7 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): - def __new__(mcs, name, bases, attrs): - # Emulate a declared field for each supported user preference preference_fields = {} for field_name, preference in PREFERENCES.items(): @@ -47,27 +66,24 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): fieldsets = ( - ('User Interface', ( - 'pagination.per_page', - 'pagination.placement', - 'ui.colormode', - )), - ('Miscellaneous', ( - 'data_format', - )), + ( + _('User Interface'), + ( + 'pagination.per_page', + 'pagination.placement', + 'ui.colormode', + ), + ), + (_('Miscellaneous'), ('data_format',)), ) # List of clearable preferences - pk = forms.MultipleChoiceField( - choices=[], - required=False - ) + pk = forms.MultipleChoiceField(label=_('Pk'), choices=[], required=False) class Meta: model = UserConfig fields = () def __init__(self, *args, instance=None, **kwargs): - # Get initial data from UserConfig instance initial_data = flatten_dict(instance.data) kwargs['initial'] = initial_data @@ -75,12 +91,9 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe super().__init__(*args, instance=instance, **kwargs) # Compile clearable preference choices - self.fields['pk'].choices = ( - (f'tables.{table_name}', '') for table_name in instance.data.get('tables', []) - ) + self.fields['pk'].choices = ((f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])) def save(self, *args, **kwargs): - # Set UserConfig data for pref_name, value in self.cleaned_data.items(): if pref_name == 'pk': @@ -95,28 +108,31 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe @property def plugin_fields(self): - return [ - name for name in self.fields.keys() if name.startswith('plugins.') - ] + return [name for name in self.fields.keys() if name.startswith('plugins.')] class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( - required=False, - help_text=_("If no key is provided, one will be generated automatically.") + label=_('Key'), required=False, help_text=_("If no key is provided, one will be generated automatically.") ) allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(validators=[prefix_validator]), required=False, label=_('Allowed IPs'), - help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64'), + help_text=_( + 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64' + ), ) class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'key', + 'write_enabled', + 'expires', + 'description', + 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), @@ -128,3 +144,240 @@ class TokenForm(BootstrapMixin, forms.ModelForm): # Omit the key field if token retrieval is not permitted if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: del self.fields['key'] + + +class UserForm(BootstrapMixin, forms.ModelForm): + password = forms.CharField( + label=_('Password'), + widget=forms.PasswordInput(), + required=True, + ) + confirm_password = forms.CharField( + label=_('Confirm password'), + widget=forms.PasswordInput(), + required=True, + help_text=_("Enter the same password as before, for verification."), + ) + groups = DynamicModelMultipleChoiceField(label=_('Groups'), required=False, queryset=Group.objects.all()) + object_permissions = DynamicModelMultipleChoiceField( + required=False, + label=_('Permissions'), + queryset=ObjectPermission.objects.all(), + to_field_name='pk', + ) + + fieldsets = ( + (_('User'), ('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email')), + (_('Groups'), ('groups',)), + (_('Status'), ('is_active', 'is_staff', 'is_superuser')), + (_('Permissions'), ('object_permissions',)), + ) + + class Meta: + model = NetBoxUser + fields = [ + 'username', + 'first_name', + 'last_name', + 'email', + 'groups', + 'object_permissions', + 'is_active', + 'is_staff', + 'is_superuser', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + # Populate assigned permissions + self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True) + + # Password fields are optional for existing Users + self.fields['password'].required = False + self.fields['password'].widget.attrs.pop('required') + self.fields['confirm_password'].required = False + self.fields['confirm_password'].widget.attrs.pop('required') + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Update assigned permissions + instance.object_permissions.set(self.cleaned_data['object_permissions']) + + # On edit, check if we have to save the password + if self.cleaned_data.get('password'): + instance.set_password(self.cleaned_data.get('password')) + instance.save() + + return instance + + def clean(self): + # Check that password confirmation matches if password is set + if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']: + raise forms.ValidationError(_("Passwords do not match! Please check your input and try again.")) + + # TODO: Move this logic to the NetBoxUser class + def clean_username(self): + """Reject usernames that differ only in case.""" + instance = getattr(self, 'instance', None) + if instance: + qs = self._meta.model.objects.exclude(pk=instance.pk) + else: + qs = self._meta.model.objects.all() + + username = self.cleaned_data.get("username") + if username and qs.filter(username__iexact=username).exists(): + raise forms.ValidationError(_("user with this username already exists")) + + return username + + +class GroupForm(BootstrapMixin, forms.ModelForm): + users = DynamicModelMultipleChoiceField(label=_('Users'), required=False, queryset=get_user_model().objects.all()) + object_permissions = DynamicModelMultipleChoiceField( + required=False, + label=_('Permissions'), + queryset=ObjectPermission.objects.all(), + to_field_name='pk', + ) + + fieldsets = ( + (None, ('name',)), + (_('Users'), ('users',)), + (_('Permissions'), ('object_permissions',)), + ) + + class Meta: + model = NetBoxGroup + fields = [ + 'name', + 'users', + 'object_permissions', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Populate assigned users and permissions + if self.instance.pk: + self.fields['users'].initial = self.instance.user_set.values_list('id', flat=True) + self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Update assigned users and permissions + instance.user_set.set(self.cleaned_data['users']) + instance.object_permissions.set(self.cleaned_data['object_permissions']) + + return instance + + +class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, + widget=forms.SelectMultiple(attrs={'size': 6}), + ) + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) + actions = SimpleArrayField( + label=_('Additional actions'), + base_field=forms.CharField(), + required=False, + help_text=_('Actions granted in addition to those listed above'), + ) + users = DynamicModelMultipleChoiceField(label=_('Users'), required=False, queryset=get_user_model().objects.all()) + groups = DynamicModelMultipleChoiceField(label=_('Groups'), required=False, queryset=Group.objects.all()) + + fieldsets = ( + ( + None, + ( + 'name', + 'description', + 'enabled', + ), + ), + (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete', 'actions')), + (_('Objects'), ('object_types',)), + (_('Assignment'), ('groups', 'users')), + (_('Constraints'), ('constraints',)), + ) + + class Meta: + model = ObjectPermission + fields = [ + 'name', + 'description', + 'enabled', + 'object_types', + 'users', + 'groups', + 'constraints', + 'actions', + ] + help_texts = { + 'constraints': _( + 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.' + ) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + super().clean() + + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise forms.ValidationError(_("At least one action must be selected.")) + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if object_types and constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] + for ct in object_types: + model = ct.model_class() + try: + tokens = { + CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID + } + model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() + except FieldError as e: + raise forms.ValidationError( + {'constraints': _('Invalid filter for {model}: {e}').format(model=model, e=e)} + ) diff --git a/netbox/users/migrations/0004_netboxgroup_netboxuser.py b/netbox/users/migrations/0004_netboxgroup_netboxuser.py new file mode 100644 index 000000000..59d941643 --- /dev/null +++ b/netbox/users/migrations/0004_netboxgroup_netboxuser.py @@ -0,0 +1,50 @@ +# Generated by Django 4.1.9 on 2023-06-06 18:15 + +import django.contrib.auth.models +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('users', '0003_token_allowed_ips_last_used'), + ] + + operations = [ + migrations.CreateModel( + name='NetBoxGroup', + fields=[], + options={ + 'verbose_name': 'Group', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name='NetBoxUser', + fields=[], + options={ + 'verbose_name': 'User', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.AlterModelOptions( + name='netboxgroup', + options={'ordering': ('name',), 'verbose_name': 'Group'}, + ), + migrations.AlterModelOptions( + name='netboxuser', + options={'ordering': ('username',), 'verbose_name': 'User'}, + ), + ] diff --git a/netbox/users/migrations/0004_usertoken.py b/netbox/users/migrations/0004_usertoken.py index 482cda193..5e69f9d32 100644 --- a/netbox/users/migrations/0004_usertoken.py +++ b/netbox/users/migrations/0004_usertoken.py @@ -5,7 +5,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('users', '0003_token_allowed_ips_last_used'), + ('users', '0004_netboxgroup_netboxuser'), ] operations = [ diff --git a/netbox/users/models.py b/netbox/users/models.py index 57d7430e8..a004b6c74 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -2,13 +2,14 @@ import binascii import os from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group, GroupManager, User, UserManager from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ from netaddr import IPNetwork @@ -20,6 +21,8 @@ from utilities.utils import flatten_dict from .constants import * __all__ = ( + 'NetBoxGroup', + 'NetBoxUser', 'ObjectPermission', 'Token', 'UserConfig', @@ -30,6 +33,7 @@ __all__ = ( # Proxy models for admin # + class AdminGroup(Group): """ Proxy contrib.auth.models.Group for the admin UI @@ -48,6 +52,44 @@ class AdminUser(User): proxy = True +class NetBoxUserManager(UserManager.from_queryset(RestrictedQuerySet)): + pass + + +class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)): + pass + + +class NetBoxUser(User): + """ + Proxy contrib.auth.models.User for the UI + """ + objects = NetBoxUserManager() + + class Meta: + verbose_name = 'User' + proxy = True + ordering = ('username',) + + def get_absolute_url(self): + return reverse('users:netboxuser', args=[self.pk]) + + +class NetBoxGroup(Group): + """ + Proxy contrib.auth.models.User for the UI + """ + objects = NetBoxGroupManager() + + class Meta: + verbose_name = 'Group' + proxy = True + ordering = ('name',) + + def get_absolute_url(self): + return reverse('users:netboxgroup', args=[self.pk]) + + # # User preferences # @@ -337,6 +379,22 @@ class ObjectPermission(models.Model): def __str__(self): return self.name + @property + def can_view(self): + return 'view' in self.actions + + @property + def can_add(self): + return 'add' in self.actions + + @property + def can_change(self): + return 'change' in self.actions + + @property + def can_delete(self): + return 'delete' in self.actions + def list_constraints(self): """ Return all constraint sets as a list (even if only a single set is defined). @@ -344,3 +402,6 @@ class ObjectPermission(models.Model): if type(self.constraints) is not list: return [self.constraints] return self.constraints + + def get_absolute_url(self): + return reverse('users:objectpermission', args=[self.pk]) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 0f1484887..741a4b024 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -1,8 +1,14 @@ -from .models import Token +import django_tables2 as tables + from netbox.tables import NetBoxTable, columns +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission +from .models import Token __all__ = ( + 'GroupTable', + 'ObjectPermissionTable', 'TokenTable', + 'UserTable', ) @@ -12,9 +18,7 @@ ALLOWED_IPS = """{{ value|join:", " }}""" COPY_BUTTON = """ {% if settings.ALLOW_TOKEN_RETRIEVAL %} - - - + {% copy_content record.pk prefix="token_" color="success" %} {% endif %} """ @@ -50,3 +54,72 @@ class TokenTable(NetBoxTable): fields = ( 'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) + + +class UserTable(NetBoxTable): + username = tables.Column( + linkify=True + ) + groups = columns.ManyToManyColumn( + linkify_item=('users:netboxgroup', {'pk': tables.A('pk')}) + ) + is_active = columns.BooleanColumn() + is_staff = columns.BooleanColumn() + is_superuser = columns.BooleanColumn() + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = NetBoxUser + fields = ( + 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', + 'is_superuser', + ) + default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active') + + +class GroupTable(NetBoxTable): + name = tables.Column(linkify=True) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = NetBoxGroup + fields = ( + 'pk', 'id', 'name', 'users_count', + ) + default_columns = ('pk', 'name', 'users_count', ) + + +class ObjectPermissionTable(NetBoxTable): + name = tables.Column(linkify=True) + object_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + can_view = columns.BooleanColumn() + can_add = columns.BooleanColumn() + can_change = columns.BooleanColumn() + can_delete = columns.BooleanColumn() + custom_actions = columns.ArrayColumn( + accessor=tables.A('actions') + ) + users = columns.ManyToManyColumn( + linkify_item=('users:netboxuser', {'pk': tables.A('pk')}) + ) + groups = columns.ManyToManyColumn( + linkify_item=('users:netboxgroup', {'pk': tables.A('pk')}) + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = ObjectPermission + fields = ( + 'pk', 'id', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', + 'custom_actions', 'users', 'groups', 'constraints', 'description', + ) + default_columns = ( + 'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description', + ) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index d632687ef..542b40b83 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -10,7 +10,6 @@ from users import filtersets from users.models import ObjectPermission, Token from utilities.testing import BaseFilterSetTests - User = get_user_model() @@ -34,7 +33,8 @@ class UserTestCase(TestCase, BaseFilterSetTests): first_name='Hank', last_name='Hill', email='hank@stricklandpropane.com', - is_staff=True + is_staff=True, + is_superuser=True ), User( username='User2', @@ -83,13 +83,17 @@ class UserTestCase(TestCase, BaseFilterSetTests): params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_is_active(self): + params = {'is_active': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_is_staff(self): params = {'is_staff': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_is_active(self): - params = {'is_active': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_is_superuser(self): + params = {'is_superuser': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_group(self): groups = Group.objects.all()[:2] @@ -191,6 +195,22 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_can_view(self): + params = {'can_view': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_can_add(self): + params = {'can_add': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_can_change(self): + params = {'can_change': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_can_delete(self): + params = {'can_delete': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class TokenTestCase(TestCase, BaseFilterSetTests): queryset = Token.objects.all() diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py new file mode 100644 index 000000000..ca62f474e --- /dev/null +++ b/netbox/users/tests/test_views.py @@ -0,0 +1,151 @@ +from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType + +from users.models import * +from utilities.testing import ViewTestCases + + +class UserTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = NetBoxUser + maxDiff = None + validation_excluded_fields = ['password'] + + def _get_queryset(self): + # Omit the user attached to the test client + return self.model.objects.exclude(username='testuser') + + @classmethod + def setUpTestData(cls): + + users = ( + NetBoxUser(username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'), + NetBoxUser(username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'), + NetBoxUser(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'), + ) + NetBoxUser.objects.bulk_create(users) + + cls.form_data = { + 'username': 'usernamex', + 'first_name': 'firstx', + 'last_name': 'lastx', + 'email': 'userx@foo.com', + 'password': 'pass1xxx', + 'confirm_password': 'pass1xxx', + } + + cls.csv_data = ( + "username,first_name,last_name,email,password", + "username4,first4,last4,email4@foo.com,pass4xxx", + "username5,first5,last5,email5@foo.com,pass5xxx", + "username6,first6,last6,email6@foo.com,pass6xxx", + ) + + cls.csv_update_data = ( + "id,first_name,last_name", + f"{users[0].pk},first7,last7", + f"{users[1].pk},first8,last8", + f"{users[2].pk},first9,last9", + ) + + cls.bulk_edit_data = { + 'last_name': 'newlastname', + } + + +class GroupTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = NetBoxGroup + maxDiff = None + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='group1'), + Group(name='group2'), + Group(name='group3'), + ) + Group.objects.bulk_create(groups) + + cls.form_data = { + 'name': 'groupx', + } + + cls.csv_data = ( + "name", + "group4" + "group5" + "group6" + ) + + cls.csv_update_data = ( + "id,name", + f"{groups[0].pk},group7", + f"{groups[1].pk},group8", + f"{groups[2].pk},group9", + ) + + +class ObjectPermissionTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = ObjectPermission + maxDiff = None + + @classmethod + def setUpTestData(cls): + ct = ContentType.objects.get_by_natural_key('dcim', 'site') + + permissions = ( + ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']), + ObjectPermission(name='Permission 2', actions=['view', 'add', 'delete']), + ObjectPermission(name='Permission 3', actions=['view', 'add', 'delete']), + ) + ObjectPermission.objects.bulk_create(permissions) + + cls.form_data = { + 'name': 'Permission X', + 'description': 'A new permission', + 'object_types': [ct.pk], + 'actions': 'view,edit,delete', + } + + cls.csv_data = ( + "name", + "permission4" + "permission5" + "permission6" + ) + + cls.csv_update_data = ( + "id,name,actions", + f"{permissions[0].pk},permission7", + f"{permissions[1].pk},permission8", + f"{permissions[2].pk},permission9", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 354a04e1b..ef178d099 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -6,13 +6,11 @@ from . import views app_name = 'users' urlpatterns = [ - # User + # Account views path('profile/', views.ProfileView.as_view(), name='profile'), path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), - - # API tokens path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), path('api-tokens//', include(get_model_urls('users', 'token'))), @@ -25,4 +23,26 @@ urlpatterns = [ path('user-tokens/delete/', views.UserTokenBulkDeleteView.as_view(), name='user_token_bulk_delete'), path('user-tokens//', include(get_model_urls('users', 'user_token'))), + # Users + path('users/', views.UserListView.as_view(), name='netboxuser_list'), + path('users/add/', views.UserEditView.as_view(), name='netboxuser_add'), + path('users/edit/', views.UserBulkEditView.as_view(), name='netboxuser_bulk_edit'), + path('users/import/', views.UserBulkImportView.as_view(), name='netboxuser_import'), + path('users/delete/', views.UserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'), + path('users//', include(get_model_urls('users', 'netboxuser'))), + + # Groups + path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'), + path('groups/add/', views.GroupEditView.as_view(), name='netboxgroup_add'), + path('groups/import/', views.GroupBulkImportView.as_view(), name='netboxgroup_import'), + path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'), + path('groups//', include(get_model_urls('users', 'netboxgroup'))), + + # Permissions + path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'), + path('permissions/add/', views.ObjectPermissionEditView.as_view(), name='objectpermission_add'), + path('permissions/edit/', views.ObjectPermissionBulkEditView.as_view(), name='objectpermission_bulk_edit'), + path('permissions/delete/', views.ObjectPermissionBulkDeleteView.as_view(), name='objectpermission_bulk_delete'), + path('permissions//', include(get_model_urls('users', 'objectpermission'))), + ] diff --git a/netbox/users/views.py b/netbox/users/views.py index debfb8617..36221e7c2 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -2,10 +2,13 @@ import logging from django.conf import settings from django.contrib import messages -from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash +from django.contrib.auth import login as auth_login +from django.contrib.auth import logout as auth_logout +from django.contrib.auth import update_session_auth_hash from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in +from django.db.models import Count from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render, resolve_url from django.urls import reverse @@ -13,29 +16,38 @@ from django.utils.decorators import method_decorator from django.utils.http import url_has_allowed_host_and_scheme, urlencode from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View -from social_core.backends.utils import load_backends - from extras.models import Bookmark, ObjectChange from extras.tables import BookmarkTable, ObjectChangeTable +from social_core.backends.utils import load_backends +from utilities.forms import ConfirmationForm +from utilities.views import register_model_view + from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from netbox.views import generic -from utilities.forms import ConfirmationForm -from utilities.views import register_model_view -from .filtersets import TokenFilterSet -from . import forms -from .models import Token, UserConfig, UserToken -from .tables import TokenTable +from . import filtersets, forms, tables +from .filtersets import TokenFilterSet +from .models import ( + NetBoxGroup, + NetBoxUser, + ObjectPermission, + Token, + UserConfig, + UserToken, +) +from .tables import TokenTable # # Login/logout # + class LoginView(View): """ Perform user authentication via the web UI. """ + template_name = 'login.html' @method_decorator(sensitive_post_parameters('password')) @@ -77,10 +89,14 @@ class LoginView(View): logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) - return render(request, self.template_name, { - 'form': form, - 'auth_backends': self.get_auth_backends(request), - }) + return render( + request, + self.template_name, + { + 'form': form, + 'auth_backends': self.get_auth_backends(request), + }, + ) def post(self, request): logger = logging.getLogger('netbox.auth.login') @@ -111,10 +127,14 @@ class LoginView(View): else: logger.debug(f"Login form validation failed for username: {form['username'].value()}") - return render(request, self.template_name, { - 'form': form, - 'auth_backends': self.get_auth_backends(request), - }) + return render( + request, + self.template_name, + { + 'form': form, + 'auth_backends': self.get_auth_backends(request), + }, + ) def redirect_to_next(self, request, logger): data = request.POST if request.method == "POST" else request.GET @@ -155,38 +175,49 @@ class LogoutView(View): # User profiles # + class ProfileView(LoginRequiredMixin, View): - template_name = 'users/profile.html' + template_name = 'users/account/profile.html' def get(self, request): - # Compile changelog table - changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related( - 'changed_object_type' - )[:20] + changelog = ( + ObjectChange.objects.valid_models() + .restrict(request.user, 'view') + .filter(user=request.user) + .prefetch_related('changed_object_type')[:20] + ) changelog_table = ObjectChangeTable(changelog) - return render(request, self.template_name, { - 'changelog_table': changelog_table, - 'active_tab': 'profile', - }) + return render( + request, + self.template_name, + { + 'changelog_table': changelog_table, + 'active_tab': 'profile', + }, + ) class UserConfigView(LoginRequiredMixin, View): - template_name = 'users/preferences.html' + template_name = 'users/account/preferences.html' def get(self, request): userconfig = request.user.config - form = UserConfigForm(instance=userconfig) + form = forms.UserConfigForm(instance=userconfig) - return render(request, self.template_name, { - 'form': form, - 'active_tab': 'preferences', - }) + return render( + request, + self.template_name, + { + 'form': form, + 'active_tab': 'preferences', + }, + ) def post(self, request): userconfig = request.user.config - form = UserConfigForm(request.POST, instance=userconfig) + form = forms.UserConfigForm(request.POST, instance=userconfig) if form.is_valid(): form.save() @@ -194,14 +225,18 @@ class UserConfigView(LoginRequiredMixin, View): messages.success(request, "Your preferences have been updated.") return redirect('users:preferences') - return render(request, self.template_name, { - 'form': form, - 'active_tab': 'preferences', - }) + return render( + request, + self.template_name, + { + 'form': form, + 'active_tab': 'preferences', + }, + ) class ChangePasswordView(LoginRequiredMixin, View): - template_name = 'users/password.html' + template_name = 'users/account/password.html' def get(self, request): # LDAP users cannot change their password here @@ -211,10 +246,14 @@ class ChangePasswordView(LoginRequiredMixin, View): form = forms.PasswordChangeForm(user=request.user) - return render(request, self.template_name, { - 'form': form, - 'active_tab': 'password', - }) + return render( + request, + self.template_name, + { + 'form': form, + 'active_tab': 'password', + }, + ) def post(self, request): form = forms.PasswordChangeForm(user=request.user, data=request.POST) @@ -224,19 +263,24 @@ class ChangePasswordView(LoginRequiredMixin, View): messages.success(request, "Your password has been changed successfully.") return redirect('users:profile') - return render(request, self.template_name, { - 'form': form, - 'active_tab': 'change_password', - }) + return render( + request, + self.template_name, + { + 'form': form, + 'active_tab': 'change_password', + }, + ) # # Bookmarks # + class BookmarkListView(LoginRequiredMixin, generic.ObjectListView): table = BookmarkTable - template_name = 'users/bookmarks.html' + template_name = 'users/account/bookmarks.html' def get_queryset(self, request): return Bookmark.objects.filter(user=request.user) @@ -251,26 +295,27 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView): # API tokens # + class TokenListView(LoginRequiredMixin, View): - def get(self, request): - tokens = Token.objects.filter(user=request.user) - table = TokenTable(tokens) + table = tables.TokenTable(tokens) table.configure(request) - return render(request, 'users/api_tokens.html', { - 'tokens': tokens, - 'active_tab': 'api-tokens', - 'table': table, - }) + return render( + request, + 'users/account/api_tokens.html', + { + 'tokens': tokens, + 'active_tab': 'api-tokens', + 'table': table, + }, + ) @register_model_view(Token, 'edit') class TokenEditView(LoginRequiredMixin, View): - def get(self, request, pk=None): - if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) else: @@ -278,14 +323,17 @@ class TokenEditView(LoginRequiredMixin, View): form = forms.TokenForm(instance=token) - return render(request, 'generic/object_edit.html', { - 'object': token, - 'form': form, - 'return_url': reverse('users:token_list'), - }) + return render( + request, + 'generic/object_edit.html', + { + 'object': token, + 'form': form, + 'return_url': reverse('users:token_list'), + }, + ) def post(self, request, pk=None): - if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) form = forms.TokenForm(request.POST, instance=token) @@ -294,7 +342,6 @@ class TokenEditView(LoginRequiredMixin, View): form = forms.TokenForm(request.POST) if form.is_valid(): - token = form.save(commit=False) token.user = request.user token.save() @@ -303,43 +350,52 @@ class TokenEditView(LoginRequiredMixin, View): messages.success(request, msg) if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: - return render(request, 'users/api_token.html', { - 'object': token, - 'key': token.key, - 'return_url': reverse('users:token_list'), - }) + return render( + request, + 'users/account/api_token.html', + { + 'object': token, + 'key': token.key, + 'return_url': reverse('users:token_list'), + }, + ) elif '_addanother' in request.POST: return redirect(request.path) else: return redirect('users:token_list') - return render(request, 'generic/object_edit.html', { - 'object': token, - 'form': form, - 'return_url': reverse('users:token_list'), - 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL - }) + return render( + request, + 'generic/object_edit.html', + { + 'object': token, + 'form': form, + 'return_url': reverse('users:token_list'), + 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL, + }, + ) @register_model_view(Token, 'delete') class TokenDeleteView(LoginRequiredMixin, View): - def get(self, request, pk): - token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) initial_data = { 'return_url': reverse('users:token_list'), } form = ConfirmationForm(initial=initial_data) - return render(request, 'generic/object_delete.html', { - 'object': token, - 'form': form, - 'return_url': reverse('users:token_list'), - }) + return render( + request, + 'generic/object_delete.html', + { + 'object': token, + 'form': form, + 'return_url': reverse('users:token_list'), + }, + ) def post(self, request, pk): - token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) form = ConfirmationForm(request.POST) if form.is_valid(): @@ -347,11 +403,16 @@ class TokenDeleteView(LoginRequiredMixin, View): messages.success(request, "Token deleted") return redirect('users:token_list') - return render(request, 'generic/object_delete.html', { - 'object': token, - 'form': form, - 'return_url': reverse('users:token_list'), - }) + return render( + request, + 'generic/object_delete.html', + { + 'object': token, + 'form': form, + 'return_url': reverse('users:token_list'), + }, + ) + # # User Token @@ -370,7 +431,6 @@ class UserTokenView(generic.ObjectView): queryset = UserToken.objects.all() def get_extra_context(self, request, instance): - return {} @@ -399,3 +459,140 @@ class UserTokenBulkEditView(generic.BulkEditView): class UserTokenBulkDeleteView(generic.BulkDeleteView): queryset = UserToken.objects.all() table = TokenTable + + +# +# Users +# + + +class UserListView(generic.ObjectListView): + queryset = NetBoxUser.objects.all() + filterset = filtersets.UserFilterSet + filterset_form = forms.UserFilterForm + table = tables.UserTable + + +@register_model_view(NetBoxUser) +class UserView(generic.ObjectView): + queryset = NetBoxUser.objects.all() + template_name = 'users/user.html' + + def get_extra_context(self, request, instance): + changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20] + changelog_table = ObjectChangeTable(changelog) + + return { + 'changelog_table': changelog_table, + } + + +@register_model_view(NetBoxUser, 'edit') +class UserEditView(generic.ObjectEditView): + queryset = NetBoxUser.objects.all() + form = forms.UserForm + + +@register_model_view(NetBoxUser, 'delete') +class UserDeleteView(generic.ObjectDeleteView): + queryset = NetBoxUser.objects.all() + + +class UserBulkEditView(generic.BulkEditView): + queryset = NetBoxUser.objects.all() + filterset = filtersets.UserFilterSet + table = tables.UserTable + form = forms.UserBulkEditForm + + +class UserBulkImportView(generic.BulkImportView): + queryset = NetBoxUser.objects.all() + model_form = forms.UserImportForm + + +class UserBulkDeleteView(generic.BulkDeleteView): + queryset = NetBoxUser.objects.all() + filterset = filtersets.UserFilterSet + table = tables.UserTable + + +# +# Groups +# + + +class GroupListView(generic.ObjectListView): + queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) + filterset = filtersets.GroupFilterSet + filterset_form = forms.GroupFilterForm + table = tables.GroupTable + + +@register_model_view(NetBoxGroup) +class GroupView(generic.ObjectView): + queryset = NetBoxGroup.objects.all() + template_name = 'users/group.html' + + +@register_model_view(NetBoxGroup, 'edit') +class GroupEditView(generic.ObjectEditView): + queryset = NetBoxGroup.objects.all() + form = forms.GroupForm + + +@register_model_view(NetBoxGroup, 'delete') +class GroupDeleteView(generic.ObjectDeleteView): + queryset = NetBoxGroup.objects.all() + + +class GroupBulkImportView(generic.BulkImportView): + queryset = NetBoxGroup.objects.all() + model_form = forms.GroupImportForm + + +class GroupBulkDeleteView(generic.BulkDeleteView): + queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) + filterset = filtersets.GroupFilterSet + table = tables.GroupTable + + +# +# ObjectPermissions +# + + +class ObjectPermissionListView(generic.ObjectListView): + queryset = ObjectPermission.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + filterset_form = forms.ObjectPermissionFilterForm + table = tables.ObjectPermissionTable + + +@register_model_view(ObjectPermission) +class ObjectPermissionView(generic.ObjectView): + queryset = ObjectPermission.objects.all() + template_name = 'users/objectpermission.html' + + +@register_model_view(ObjectPermission, 'edit') +class ObjectPermissionEditView(generic.ObjectEditView): + queryset = ObjectPermission.objects.all() + form = forms.ObjectPermissionForm + + +@register_model_view(ObjectPermission, 'delete') +class ObjectPermissionDeleteView(generic.ObjectDeleteView): + queryset = ObjectPermission.objects.all() + + +class ObjectPermissionBulkEditView(generic.BulkEditView): + queryset = ObjectPermission.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + table = tables.ObjectPermissionTable + form = forms.ObjectPermissionBulkEditForm + + +class ObjectPermissionBulkDeleteView(generic.BulkDeleteView): + queryset = ObjectPermission.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + table = tables.ObjectPermissionTable diff --git a/netbox/utilities/forms/widgets/misc.py b/netbox/utilities/forms/widgets/misc.py index ca2e64319..e999af831 100644 --- a/netbox/utilities/forms/widgets/misc.py +++ b/netbox/utilities/forms/widgets/misc.py @@ -1,6 +1,7 @@ from django import forms __all__ = ( + 'ArrayWidget', 'ClearableFileInput', 'MarkdownWidget', 'NumberWithOptions', @@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput): Subclass TextInput and add a slug regeneration button next to the form field. """ template_name = 'widgets/sluginput.html' + + +class ArrayWidget(forms.Textarea): + """ + Render each item of an array on a new line within a textarea for easy editing/ + """ + def format_value(self, value): + if value is None or not len(value): + return None + return '\n'.join(value) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index b20aafce0..813a8f944 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -18,11 +18,10 @@ def get_permission_for_model(model, action): :param model: A model or instance :param action: View, add, change, or delete (string) """ - return '{}.{}_{}'.format( - model._meta.app_label, - action, - model._meta.model_name - ) + # Resolve to the "concrete" model (for proxy models) + model = model._meta.concrete_model + + return f'{model._meta.app_label}.{action}_{model._meta.model_name}' def resolve_permission(name): diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index ba4b28418..50917dd0f 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,7 +1,7 @@ from django.db.models import Prefetch, QuerySet from users.constants import CONSTRAINT_TOKEN_USER -from utilities.permissions import permission_is_exempt, qs_filter_from_constraints +from utilities.permissions import get_permission_for_model, permission_is_exempt, qs_filter_from_constraints __all__ = ( 'RestrictedPrefetch', @@ -46,9 +46,7 @@ class RestrictedQuerySet(QuerySet): :param action: The action which must be permitted (e.g. "view" for "dcim.view_site"); default is 'view' """ # Resolve the full name of the required permission - app_label = self.model._meta.app_label - model_name = self.model._meta.model_name - permission_required = f'{app_label}.{action}_{model_name}' + permission_required = get_permission_for_model(self.model, action) # Bypass restriction for superusers and exempt views if user.is_superuser or permission_is_exempt(permission_required): diff --git a/netbox/utilities/templates/builtins/copy_content.html b/netbox/utilities/templates/builtins/copy_content.html new file mode 100644 index 000000000..9025a71a1 --- /dev/null +++ b/netbox/utilities/templates/builtins/copy_content.html @@ -0,0 +1,3 @@ + + + diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index dc86586e7..35aec1000 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,9 +1,12 @@ from django import template from django.http import QueryDict +from utilities.utils import dict_to_querydict + __all__ = ( 'badge', 'checkmark', + 'copy_content', 'customfield_value', 'tag', ) @@ -77,6 +80,17 @@ def checkmark(value, show_false=True, true='Yes', false='No'): } +@register.inclusion_tag('builtins/copy_content.html') +def copy_content(target, prefix=None, color='primary'): + """ + Display a copy button to copy the content of a field. + """ + return { + 'target': f'#{prefix or ""}{target}', + 'color': f'btn-{color}' + } + + @register.inclusion_tag('builtins/htmx_table.html', takes_context=True) def htmx_table(context, viewname, return_url=None, **kwargs): """ @@ -87,8 +101,7 @@ def htmx_table(context, viewname, return_url=None, **kwargs): viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`) return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used. """ - url_params = QueryDict(mutable=True) - url_params.update(kwargs) + url_params = dict_to_querydict(kwargs) url_params['return_url'] = return_url or context['request'].path return { 'viewname': viewname, diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index dc17548a2..539fe3057 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1,5 +1,6 @@ import csv +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import ForeignKey @@ -64,8 +65,15 @@ class ViewTestCases: def test_get_object_anonymous(self): # Make the request as an unauthenticated user self.client.logout() - response = self.client.get(self._get_queryset().first().get_absolute_url()) - self.assertHttpStatus(response, 200) + ct = ContentType.objects.get_for_model(self.model) + if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: + # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users + with disable_warnings('django.request'): + response = self.client.get(self._get_queryset().first().get_absolute_url()) + self.assertHttpStatus(response, 302) + else: + response = self.client.get(self._get_queryset().first().get_absolute_url()) + self.assertHttpStatus(response, 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_without_permission(self): @@ -128,6 +136,7 @@ class ViewTestCases: :form_data: Data to be used when creating a new object. """ form_data = {} + validation_excluded_fields = [] def test_create_object_without_permission(self): @@ -146,7 +155,6 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_permission(self): - initial_count = self._get_queryset().count() # Assign unconstrained permission obj_perm = ObjectPermission( @@ -161,6 +169,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.get(self._get_url('add')), 200) # Try POST with model-level permission + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), @@ -168,19 +177,19 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self._get_queryset().count()) instance = self._get_queryset().order_by('pk').last() - self.assertInstanceEqual(instance, self.form_data) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation - objectchanges = ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk - ) - self.assertEqual(len(objectchanges), 1) - self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) + if issubclass(instance.__class__, ChangeLoggingMixin): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): - initial_count = self._get_queryset().count() # Assign constrained permission obj_perm = ObjectPermission( @@ -196,6 +205,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.get(self._get_url('add')), 200) # Try to create an object (not permitted) + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), @@ -214,7 +224,8 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self._get_queryset().count()) - self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) + instance = self._get_queryset().order_by('pk').last() + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) class EditObjectViewTestCase(ModelViewTestCase): """ @@ -223,6 +234,7 @@ class ViewTestCases: :form_data: Data to be used when updating the first existing object. """ form_data = {} + validation_excluded_fields = [] def test_edit_object_without_permission(self): instance = self._get_queryset().first() @@ -261,15 +273,17 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data) + instance = self._get_queryset().get(pk=instance.pk) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation - objectchanges = ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk - ) - self.assertEqual(len(objectchanges), 1) - self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) + if issubclass(instance.__class__, ChangeLoggingMixin): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): @@ -297,7 +311,8 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data) + instance = self._get_queryset().get(pk=instance1.pk) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Try to edit a non-permitted object request = { @@ -404,8 +419,15 @@ class ViewTestCases: def test_list_objects_anonymous(self): # Make the request as an unauthenticated user self.client.logout() - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) + ct = ContentType.objects.get_for_model(self.model) + if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: + # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users + with disable_warnings('django.request'): + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 302) + else: + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_without_permission(self): @@ -450,10 +472,19 @@ class ViewTestCases: self.assertIn(instance1.get_absolute_url(), content) self.assertNotIn(instance2.get_absolute_url(), content) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_export_objects(self): url = self._get_url('list') + # Add model-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + # Test default CSV export response = self.client.get(f'{url}?export') self.assertHttpStatus(response, 200) @@ -700,7 +731,7 @@ class ViewTestCases: # Assign model-level permission obj_perm = ObjectPermission( name='Test permission', - actions=['change'] + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -731,7 +762,7 @@ class ViewTestCases: obj_perm = ObjectPermission( name='Test permission', constraints={attr_name: value}, - actions=['change'] + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -795,7 +826,6 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_constrained_permission(self): - initial_count = self._get_queryset().count() pk_list = self._get_queryset().values_list('pk', flat=True) data = { 'pk': pk_list, @@ -814,6 +844,7 @@ class ViewTestCases: obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Attempt to bulk delete non-permitted objects + initial_count = self._get_queryset().count() self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) self.assertEqual(self._get_queryset().count(), initial_count) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 4b4a2631a..114397dae 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -11,8 +11,9 @@ from django.core import serializers from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict -from django.utils.html import escape from django.utils import timezone +from django.utils.datastructures import MultiValueDict +from django.utils.html import escape from django.utils.timezone import localtime from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel @@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''): return params +def dict_to_querydict(d, mutable=True): + """ + Create a QueryDict instance from a regular Python dictionary. + """ + qd = QueryDict(mutable=True) + for k, v in d.items(): + item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v} + qd.update(item) + if not mutable: + qd._mutable = False + return qd + + def normalize_querydict(querydict): """ Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example, diff --git a/requirements.txt b/requirements.txt index 2ffcd852b..f707c60c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ bleach==6.0.0 -boto3==1.26.156 +boto3==1.28.1 Django==4.2.2 -django-cors-headers==4.1.0 +django-cors-headers==4.2.0 django-debug-toolbar==4.1.0 django-filter==23.2 django-graphiql-debug-toolbar==0.2.0 @@ -9,27 +9,27 @@ django-mptt==0.14 django-pglocks==1.0.4 django-prometheus==2.3.1 django-redis==5.3.0 -django-rich==1.6.0 +django-rich==1.7.0 django-rq==2.8.1 -django-tables2==2.5.3 +django-tables2==2.6.0 django-taggit==4.0.0 django-timezone-field==5.1 djangorestframework==3.14.0 -drf-spectacular==0.26.2 -drf-spectacular-sidecar==2023.6.1 +drf-spectacular==0.26.3 +drf-spectacular-sidecar==2023.7.1 dulwich==0.21.5 feedparser==6.0.10 graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.16 +mkdocs-material==9.1.18 mkdocstrings[python-legacy]==0.22.0 netaddr==0.8.0 -Pillow==9.5.0 +Pillow==10.0.0 psycopg[binary,pool]==3.1.9 PyYAML==6.0 -sentry-sdk==1.25.1 +sentry-sdk==1.28.0 social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3