2
0
Эх сурвалжийг харах

Merge pull request #11965 from netbox-community/develop

Release v3.4.6
Jeremy Stretch 2 жил өмнө
parent
commit
6b6ea36b4c
56 өөрчлөгдсөн 395 нэмэгдсэн , 163 устгасан
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 2 2
      README.md
  4. 9 0
      contrib/apache.conf
  5. 1 1
      docs/installation/5-http-server.md
  6. 28 0
      docs/release-notes/version-3.4.md
  7. 1 4
      netbox/circuits/forms/bulk_edit.py
  8. 4 0
      netbox/dcim/choices.py
  9. 4 1
      netbox/dcim/filtersets.py
  10. 1 12
      netbox/dcim/forms/bulk_edit.py
  11. 5 2
      netbox/dcim/forms/bulk_import.py
  12. 8 0
      netbox/dcim/graphql/mixins.py
  13. 7 7
      netbox/dcim/graphql/types.py
  14. 1 0
      netbox/dcim/tables/devices.py
  15. 6 0
      netbox/extras/filtersets.py
  16. 1 0
      netbox/extras/forms/__init__.py
  17. 14 0
      netbox/extras/forms/misc.py
  18. 2 2
      netbox/extras/plugins/__init__.py
  19. 2 1
      netbox/extras/plugins/navigation.py
  20. 2 4
      netbox/extras/tests/test_filtersets.py
  21. 2 0
      netbox/extras/urls.py
  22. 17 1
      netbox/extras/views.py
  23. 1 1
      netbox/generate_secret_key.py
  24. 27 0
      netbox/ipam/filtersets.py
  25. 1 12
      netbox/ipam/forms/bulk_edit.py
  26. 1 0
      netbox/ipam/forms/model_forms.py
  27. 25 3
      netbox/ipam/graphql/types.py
  28. 21 0
      netbox/ipam/tests/test_filtersets.py
  29. 7 0
      netbox/netbox/models/features.py
  30. 1 1
      netbox/netbox/preferences.py
  31. 1 1
      netbox/netbox/settings.py
  32. 2 2
      netbox/netbox/tables/columns.py
  33. 0 0
      netbox/project-static/dist/netbox-dark.css
  34. 0 0
      netbox/project-static/dist/netbox-light.css
  35. 0 0
      netbox/project-static/dist/netbox-print.css
  36. 0 0
      netbox/project-static/dist/netbox.js
  37. 0 0
      netbox/project-static/dist/netbox.js.map
  38. 2 0
      netbox/project-static/src/buttons/index.ts
  39. 45 0
      netbox/project-static/src/buttons/markdownPreview.ts
  40. 67 72
      netbox/project-static/src/tables/interfaceTable.ts
  41. 22 7
      netbox/project-static/styles/netbox.scss
  42. 1 0
      netbox/templates/dcim/device/inc/interface_table_controls.html
  43. 1 2
      netbox/tenancy/forms/bulk_edit.py
  44. 1 1
      netbox/utilities/forms/fields/fields.py
  45. 1 1
      netbox/utilities/forms/forms.py
  46. 1 0
      netbox/utilities/forms/utils.py
  47. 5 0
      netbox/utilities/forms/widgets.py
  48. 0 2
      netbox/utilities/paginator.py
  49. 1 1
      netbox/utilities/templates/form_helpers/render_field.html
  50. 22 0
      netbox/utilities/templates/widgets/markdown_input.html
  51. 4 0
      netbox/utilities/testing/api.py
  52. 6 6
      netbox/utilities/utils.py
  53. 3 1
      netbox/virtualization/filtersets.py
  54. 1 3
      netbox/virtualization/forms/bulk_edit.py
  55. 1 3
      netbox/wireless/forms/bulk_edit.py
  56. 5 5
      requirements.txt

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

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

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

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

+ 2 - 2
README.md

@@ -13,9 +13,9 @@ NetBox provides the ideal "source of truth" to power network automation.
 Available as open source software under the Apache 2.0 license, NetBox serves
 Available as open source software under the Apache 2.0 license, NetBox serves
 as the cornerstone for network automation in thousands of organizations.
 as the cornerstone for network automation in thousands of organizations.
 
 
-* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
+* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
 * **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
 * **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
-* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
+* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
 * **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
 * **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
 * **Organization:** Manage tenant and contact assignments natively.
 * **Organization:** Manage tenant and contact assignments natively.
 * **Powerful search:** Easily find anything you need using a single global search function.
 * **Powerful search:** Easily find anything you need using a single global search function.

+ 9 - 0
contrib/apache.conf

@@ -1,3 +1,12 @@
+<VirtualHost *:80>
+    # CHANGE THIS TO YOUR SERVER'S NAME
+    ServerName netbox.example.com
+
+    RewriteEngine On
+    RewriteCond %{HTTPS} !=on
+    RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
+</VirtualHost>
+
 <VirtualHost *:443>
 <VirtualHost *:443>
     ProxyPreserveHost On
     ProxyPreserveHost On
 
 

+ 1 - 1
docs/installation/5-http-server.md

@@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
 Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
 Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
 
 
 ```no-highlight
 ```no-highlight
-sudo a2enmod ssl proxy proxy_http headers
+sudo a2enmod ssl proxy proxy_http headers rewrite
 sudo a2ensite netbox
 sudo a2ensite netbox
 sudo systemctl restart apache2
 sudo systemctl restart apache2
 ```
 ```

+ 28 - 0
docs/release-notes/version-3.4.md

@@ -1,5 +1,33 @@
 # NetBox v3.4
 # NetBox v3.4
 
 
+## v3.4.6 (2023-03-13)
+
+### Enhancements
+
+* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address
+* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view
+* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content
+* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views
+* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects
+* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses
+* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type
+* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces
+* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation
+
+### Bug Fixes
+
+* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address
+* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation
+* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs
+* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles
+* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified
+* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type
+* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark
+* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables
+* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths
+
+---
+
 ## v3.4.5 (2023-02-21)
 ## v3.4.5 (2023-02-21)
 
 
 ### Enhancements
 ### Enhancements

+ 1 - 4
netbox/circuits/forms/bulk_edit.py

@@ -7,7 +7,7 @@ from ipam.models import ASN
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
+    add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     StaticSelect,
     StaticSelect,
 )
 )
 
 
@@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label=_('Comments')
         label=_('Comments')
     )
     )
 
 
@@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label=_('Comments')
         label=_('Comments')
     )
     )
 
 
@@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label=_('Comments')
         label=_('Comments')
     )
     )
 
 

+ 4 - 0
netbox/dcim/choices.py

@@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_STACKWISE160 = 'cisco-stackwise-160'
     TYPE_STACKWISE160 = 'cisco-stackwise-160'
     TYPE_STACKWISE320 = 'cisco-stackwise-320'
     TYPE_STACKWISE320 = 'cisco-stackwise-320'
     TYPE_STACKWISE480 = 'cisco-stackwise-480'
     TYPE_STACKWISE480 = 'cisco-stackwise-480'
+    TYPE_STACKWISE1T = 'cisco-stackwise-1t'
     TYPE_JUNIPER_VCP = 'juniper-vcp'
     TYPE_JUNIPER_VCP = 'juniper-vcp'
     TYPE_SUMMITSTACK = 'extreme-summitstack'
     TYPE_SUMMITSTACK = 'extreme-summitstack'
     TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
     TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_STACKWISE160, 'Cisco StackWise-160'),
                 (TYPE_STACKWISE160, 'Cisco StackWise-160'),
                 (TYPE_STACKWISE320, 'Cisco StackWise-320'),
                 (TYPE_STACKWISE320, 'Cisco StackWise-320'),
                 (TYPE_STACKWISE480, 'Cisco StackWise-480'),
                 (TYPE_STACKWISE480, 'Cisco StackWise-480'),
+                (TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
                 (TYPE_JUNIPER_VCP, 'Juniper VCP'),
                 (TYPE_JUNIPER_VCP, 'Juniper VCP'),
                 (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
                 (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
                 (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
                 (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
@@ -1135,6 +1137,7 @@ class InterfacePoETypeChoices(ChoiceSet):
 
 
     TYPE_1_8023AF = 'type1-ieee802.3af'
     TYPE_1_8023AF = 'type1-ieee802.3af'
     TYPE_2_8023AT = 'type2-ieee802.3at'
     TYPE_2_8023AT = 'type2-ieee802.3at'
+    TYPE_2_8023AZ = 'type2-ieee802.3az'
     TYPE_3_8023BT = 'type3-ieee802.3bt'
     TYPE_3_8023BT = 'type3-ieee802.3bt'
     TYPE_4_8023BT = 'type4-ieee802.3bt'
     TYPE_4_8023BT = 'type4-ieee802.3bt'
 
 
@@ -1149,6 +1152,7 @@ class InterfacePoETypeChoices(ChoiceSet):
             (
             (
                 (TYPE_1_8023AF, '802.3af (Type 1)'),
                 (TYPE_1_8023AF, '802.3af (Type 1)'),
                 (TYPE_2_8023AT, '802.3at (Type 2)'),
                 (TYPE_2_8023AT, '802.3at (Type 2)'),
+                (TYPE_2_8023AZ, '802.3az (Type 2)'),
                 (TYPE_3_8023BT, '802.3bt (Type 3)'),
                 (TYPE_3_8023BT, '802.3bt (Type 3)'),
                 (TYPE_4_8023BT, '802.3bt (Type 4)'),
                 (TYPE_4_8023BT, '802.3bt (Type 4)'),
             )
             )

+ 4 - 1
netbox/dcim/filtersets.py

@@ -981,7 +981,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
             Q(serial__icontains=value.strip()) |
             Q(serial__icontains=value.strip()) |
             Q(inventoryitems__serial__icontains=value.strip()) |
             Q(inventoryitems__serial__icontains=value.strip()) |
             Q(asset_tag__icontains=value.strip()) |
             Q(asset_tag__icontains=value.strip()) |
-            Q(comments__icontains=value)
+            Q(comments__icontains=value) |
+            Q(primary_ip4__address__startswith=value) |
+            Q(primary_ip6__address__startswith=value)
         ).distinct()
         ).distinct()
 
 
     def _has_primary_ip(self, queryset, name, value):
     def _has_primary_ip(self, queryset, name, value):
@@ -1725,6 +1727,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 
 
 
 
 class CableTerminationFilterSet(BaseFilterSet):
 class CableTerminationFilterSet(BaseFilterSet):
+    termination_type = ContentTypeFilter()
 
 
     class Meta:
     class Meta:
         model = CableTermination
         model = CableTermination

+ 1 - 12
netbox/dcim/forms/bulk_edit.py

@@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
     add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
+    DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
 )
 )
 
 
 __all__ = (
 __all__ = (
@@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label=_('Comments')
         label=_('Comments')
     )
     )
 
 

+ 5 - 2
netbox/dcim/forms/bulk_import.py

@@ -447,11 +447,14 @@ class DeviceImportForm(BaseDeviceImportForm):
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
 
 
-            # Limit rack queryset by assigned site and group
+            # Limit rack queryset by assigned site and location
             params = {
             params = {
                 f"site__{self.fields['site'].to_field_name}": data.get('site'),
                 f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
             }
             }
+            if 'location' in data:
+                params.update({
+                    f"location__{self.fields['location'].to_field_name}": data.get('location'),
+                })
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
             # Limit device bay queryset by parent device
             # Limit device bay queryset by parent device

+ 8 - 0
netbox/dcim/graphql/mixins.py

@@ -10,3 +10,11 @@ class CabledObjectMixin:
 
 
     def resolve_link_peers(self, info):
     def resolve_link_peers(self, info):
         return self.link_peers
         return self.link_peers
+
+
+class PathEndpointMixin:
+    connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
+
+    def resolve_connected_endpoints(self, info):
+        # Handle empty values
+        return self.connected_endpoints or None

+ 7 - 7
netbox/dcim/graphql/types.py

@@ -7,7 +7,7 @@ from extras.graphql.mixins import (
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
-from .mixins import CabledObjectMixin
+from .mixins import CabledObjectMixin, PathEndpointMixin
 
 
 __all__ = (
 __all__ = (
     'CableType',
     'CableType',
@@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
         filterset_class = filtersets.CableTerminationFilterSet
         filterset_class = filtersets.CableTerminationFilterSet
 
 
 
 
-class ConsolePortType(ComponentObjectType, CabledObjectMixin):
+class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
 
 
     class Meta:
     class Meta:
         model = models.ConsolePort
         model = models.ConsolePort
@@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
         return self.type or None
         return self.type or None
 
 
 
 
-class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
+class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
 
 
     class Meta:
     class Meta:
         model = models.ConsoleServerPort
         model = models.ConsoleServerPort
@@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.FrontPortTemplateFilterSet
         filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
 
 
-class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
+class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
 
 
     class Meta:
     class Meta:
         model = models.Interface
         model = models.Interface
@@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
         filterset_class = filtersets.PlatformFilterSet
         filterset_class = filtersets.PlatformFilterSet
 
 
 
 
-class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
+class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerFeed
         model = models.PowerFeed
@@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
         filterset_class = filtersets.PowerFeedFilterSet
         filterset_class = filtersets.PowerFeedFilterSet
 
 
 
 
-class PowerOutletType(ComponentObjectType, CabledObjectMixin):
+class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerOutlet
         model = models.PowerOutlet
@@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
         filterset_class = filtersets.PowerPanelFilterSet
         filterset_class = filtersets.PowerPanelFilterSet
 
 
 
 
-class PowerPortType(ComponentObjectType, CabledObjectMixin):
+class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerPort
         model = models.PowerPort

+ 1 - 0
netbox/dcim/tables/devices.py

@@ -588,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable):
             'class': get_interface_row_class,
             'class': get_interface_row_class,
             'data-name': lambda record: record.name,
             'data-name': lambda record: record.name,
             'data-enabled': get_interface_state_attribute,
             'data-enabled': get_interface_state_attribute,
+            'data-type': lambda record: record.type,
         }
         }
 
 
 
 

+ 6 - 0
netbox/extras/filtersets.py

@@ -210,6 +210,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
 class JournalEntryFilterSet(NetBoxModelFilterSet):
 class JournalEntryFilterSet(NetBoxModelFilterSet):
     created = django_filters.DateTimeFromToRangeFilter()
     created = django_filters.DateTimeFromToRangeFilter()
     assigned_object_type = ContentTypeFilter()
     assigned_object_type = ContentTypeFilter()
+    assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ContentType.objects.all()
+    )
     created_by_id = django_filters.ModelMultipleChoiceFilter(
     created_by_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         label=_('User (ID)'),
         label=_('User (ID)'),
@@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
     )
     )
     time = django_filters.DateTimeFromToRangeFilter()
     time = django_filters.DateTimeFromToRangeFilter()
     changed_object_type = ContentTypeFilter()
     changed_object_type = ContentTypeFilter()
+    changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ContentType.objects.all()
+    )
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         label=_('User (ID)'),
         label=_('User (ID)'),

+ 1 - 0
netbox/extras/forms/__init__.py

@@ -2,6 +2,7 @@ from .model_forms import *
 from .filtersets import *
 from .filtersets import *
 from .bulk_edit import *
 from .bulk_edit import *
 from .bulk_import import *
 from .bulk_import import *
+from .misc import *
 from .mixins import *
 from .mixins import *
 from .config import *
 from .config import *
 from .scripts import *
 from .scripts import *

+ 14 - 0
netbox/extras/forms/misc.py

@@ -0,0 +1,14 @@
+from django import forms
+
+__all__ = (
+    'RenderMarkdownForm',
+)
+
+
+class RenderMarkdownForm(forms.Form):
+    """
+    Provides basic validation for markup to be rendered.
+    """
+    text = forms.CharField(
+        required=False
+    )

+ 2 - 2
netbox/extras/plugins/__init__.py

@@ -78,8 +78,8 @@ class PluginConfig(AppConfig):
 
 
     def _load_resource(self, name):
     def _load_resource(self, name):
         # Import from the configured path, if defined.
         # Import from the configured path, if defined.
-        if getattr(self, name):
-            return import_string(f"{self.__module__}.{self.name}")
+        if path := getattr(self, name, None):
+            return import_string(f"{self.__module__}.{path}")
 
 
         # Fall back to the resource's default path. Return None if the module has not been provided.
         # Fall back to the resource's default path. Return None if the module has not been provided.
         default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
         default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'

+ 2 - 1
netbox/extras/plugins/navigation.py

@@ -1,5 +1,6 @@
 from netbox.navigation import MenuGroup
 from netbox.navigation import MenuGroup
 from utilities.choices import ButtonColorChoices
 from utilities.choices import ButtonColorChoices
+from django.utils.text import slugify
 
 
 __all__ = (
 __all__ = (
     'PluginMenu',
     'PluginMenu',
@@ -21,7 +22,7 @@ class PluginMenu:
 
 
     @property
     @property
     def name(self):
     def name(self):
-        return self.label.replace(' ', '_')
+        return slugify(self.label)
 
 
 
 
 class PluginMenuItem:
 class PluginMenuItem:

+ 2 - 4
netbox/extras/tests/test_filtersets.py

@@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_assigned_object_type(self):
     def test_assigned_object_type(self):
         params = {'assigned_object_type': 'dcim.site'}
         params = {'assigned_object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
+        params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_assigned_object(self):
     def test_assigned_object(self):
@@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     def test_changed_object_type(self):
     def test_changed_object_type(self):
         params = {'changed_object_type': 'dcim.site'}
         params = {'changed_object_type': 'dcim.site'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
-    def test_changed_object_type_id(self):
-        params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
+        params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

+ 2 - 0
netbox/extras/urls.py

@@ -92,4 +92,6 @@ urlpatterns = [
     path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
     path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
     re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
     re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
 
 
+    # Markdown
+    path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
 ]
 ]

+ 17 - 1
netbox/extras/views.py

@@ -1,7 +1,7 @@
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Q
 from django.db.models import Count, Q
-from django.http import Http404, HttpResponseForbidden
+from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.views.generic import View
 from django.views.generic import View
@@ -10,6 +10,7 @@ from rq import Worker
 
 
 from netbox.views import generic
 from netbox.views import generic
 from utilities.htmx import is_htmx
 from utilities.htmx import is_htmx
+from utilities.templatetags.builtins.filters import render_markdown
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
     queryset = JobResult.objects.all()
     queryset = JobResult.objects.all()
     filterset = filtersets.JobResultFilterSet
     filterset = filtersets.JobResultFilterSet
     table = tables.JobResultTable
     table = tables.JobResultTable
+
+
+#
+# Markdown
+#
+
+class RenderMarkdownView(View):
+
+    def post(self, request):
+        form = forms.RenderMarkdownForm(request.POST)
+        if not form.is_valid():
+            HttpResponseBadRequest()
+        rendered = render_markdown(form.cleaned_data['text'])
+
+        return HttpResponse(rendered)

+ 1 - 1
netbox/generate_secret_key.py

@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 import secrets
 import secrets
 
 

+ 27 - 0
netbox/ipam/filtersets.py

@@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
 
 
+from rest_framework import serializers
 
 
 __all__ = (
 __all__ = (
     'AggregateFilterSet',
     'AggregateFilterSet',
@@ -599,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
                 return queryset.none()
                 return queryset.none()
         return queryset.filter(q)
         return queryset.filter(q)
 
 
+    def parse_inet_addresses(self, value):
+        '''
+        Parse networks or IP addresses and cast to a format
+        acceptable by the Postgres inet type.
+
+        Skips invalid values.
+        '''
+        parsed = []
+        for addr in value:
+            if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
+                parsed.append(addr)
+                continue
+            try:
+                network = netaddr.IPNetwork(addr)
+                parsed.append(str(network))
+            except (AddrFormatError, ValueError):
+                continue
+        return parsed
+
     def filter_address(self, queryset, name, value):
     def filter_address(self, queryset, name, value):
+        # Let's first parse the addresses passed
+        # as argument. If they are all invalid,
+        # we return an empty queryset
+        value = self.parse_inet_addresses(value)
+        if (len(value) == 0):
+            return queryset.none()
+
         try:
         try:
             return queryset.filter(address__net_in=value)
             return queryset.filter(address__net_in=value)
         except ValidationError:
         except ValidationError:

+ 1 - 12
netbox/ipam/forms/bulk_edit.py

@@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
     add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
-    SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
+    StaticSelect, DynamicModelMultipleChoiceField
 )
 )
 
 
 __all__ = (
 __all__ = (
@@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 

+ 1 - 0
netbox/ipam/forms/model_forms.py

@@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm):
                 role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
                 role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
                 assigned_object=instance
                 assigned_object=instance
             )
             )
+            ipaddress.populate_custom_field_defaults()
             ipaddress.save()
             ipaddress.save()
 
 
             # Check that the new IPAddress conforms with any assigned object-level permissions
             # Check that the new IPAddress conforms with any assigned object-level permissions

+ 25 - 3
netbox/ipam/graphql/types.py

@@ -27,6 +27,28 @@ __all__ = (
 )
 )
 
 
 
 
+class IPAddressFamilyType(graphene.ObjectType):
+
+    value = graphene.Int()
+    label = graphene.String()
+
+    def __init__(self, value):
+        self.value = value
+        self.label = f'IPv{value}'
+
+
+class BaseIPAddressFamilyType:
+    '''
+    Base type for models that need to expose their IPAddress family type.
+    '''
+    family = graphene.Field(IPAddressFamilyType)
+
+    def resolve_family(self, _):
+        # Note that self, is an instance of models.IPAddress
+        # thus resolves to the address family value.
+        return IPAddressFamilyType(self.family)
+
+
 class ASNType(NetBoxObjectType):
 class ASNType(NetBoxObjectType):
     asn = graphene.Field(BigInt)
     asn = graphene.Field(BigInt)
 
 
@@ -36,7 +58,7 @@ class ASNType(NetBoxObjectType):
         filterset_class = filtersets.ASNFilterSet
         filterset_class = filtersets.ASNFilterSet
 
 
 
 
-class AggregateType(NetBoxObjectType):
+class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
 
 
     class Meta:
     class Meta:
         model = models.Aggregate
         model = models.Aggregate
@@ -64,7 +86,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
 
 
-class IPAddressType(NetBoxObjectType):
+class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
     assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
     assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
 
 
     class Meta:
     class Meta:
@@ -87,7 +109,7 @@ class IPRangeType(NetBoxObjectType):
         return self.role or None
         return self.role or None
 
 
 
 
-class PrefixType(NetBoxObjectType):
+class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
 
 
     class Meta:
     class Meta:
         model = models.Prefix
         model = models.Prefix

+ 21 - 0
netbox/ipam/tests/test_filtersets.py

@@ -10,6 +10,7 @@ from ipam.models import *
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from rest_framework import serializers
 
 
 
 
 class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -851,6 +852,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
         params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+        # Check for valid edge cases. Note that Postgres inet type
+        # only accepts netmasks in the int form, so the filterset
+        # casts netmasks in the xxx.xxx.xxx.xxx format.
+        params = {'address': ['24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'address': ['10.0.0.1/255.255.255.0']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+        # Check for invalid input.
+        params = {'address': ['/24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'address': ['10.0.0.1/255.255.999.0']}  # Invalid netmask
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+        # Check for partially invalid input.
+        params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_mask_length(self):
     def test_mask_length(self):
         params = {'mask_length': '24'}
         params = {'mask_length': '24'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)

+ 7 - 0
netbox/netbox/models/features.py

@@ -216,6 +216,13 @@ class CustomFieldsMixin(models.Model):
 
 
         return dict(groups)
         return dict(groups)
 
 
+    def populate_custom_field_defaults(self):
+        """
+        Apply the default value for each custom field
+        """
+        for cf in self.custom_fields:
+            self.custom_field_data[cf.name] = cf.default
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
         from extras.models import CustomField
         from extras.models import CustomField

+ 1 - 1
netbox/netbox/preferences.py

@@ -24,7 +24,7 @@ PREFERENCES = {
     'pagination.per_page': UserPreference(
     'pagination.per_page': UserPreference(
         label=_('Page length'),
         label=_('Page length'),
         choices=get_page_lengths(),
         choices=get_page_lengths(),
-        description=_('The number of objects to display per page'),
+        description=_('The default number of objects to display per page'),
         coerce=lambda x: int(x)
         coerce=lambda x: int(x)
     ),
     ),
     'pagination.placement': UserPreference(
     'pagination.placement': UserPreference(

+ 1 - 1
netbox/netbox/settings.py

@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.4.5'
+VERSION = '3.4.6'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 2 - 2
netbox/netbox/tables/columns.py

@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
 from typing import Optional
 from typing import Optional
+from urllib.parse import quote
 
 
 import django_tables2 as tables
 import django_tables2 as tables
 from django.conf import settings
 from django.conf import settings
@@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
 from django.template import Context, Template
 from django.template import Context, Template
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.dateparse import parse_date
 from django.utils.dateparse import parse_date
-from django.utils.encoding import escape_uri_path
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.formats import date_format
 from django.utils.formats import date_format
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
@@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
 
 
         model = table.Meta.model
         model = table.Meta.model
         request = getattr(table, 'context', {}).get('request')
         request = getattr(table, 'context', {}).get('request')
-        url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
+        url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
         html = ''
         html = ''
 
 
         # Compile actions menu
         # Compile actions menu

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 2 - 0
netbox/project-static/src/buttons/index.ts

@@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
 import { initReslug } from './reslug';
 import { initReslug } from './reslug';
 import { initSelectAll } from './selectAll';
 import { initSelectAll } from './selectAll';
 import { initSelectMultiple } from './selectMultiple';
 import { initSelectMultiple } from './selectMultiple';
+import { initMarkdownPreviews } from './markdownPreview';
 
 
 export function initButtons(): void {
 export function initButtons(): void {
   for (const func of [
   for (const func of [
@@ -13,6 +14,7 @@ export function initButtons(): void {
     initSelectAll,
     initSelectAll,
     initSelectMultiple,
     initSelectMultiple,
     initMoveButtons,
     initMoveButtons,
+    initMarkdownPreviews,
   ]) {
   ]) {
     func();
     func();
   }
   }

+ 45 - 0
netbox/project-static/src/buttons/markdownPreview.ts

@@ -0,0 +1,45 @@
+import { isTruthy } from 'src/util';
+
+/**
+ * interface for htmx configRequest event
+ */
+declare global {
+  interface HTMLElementEventMap {
+    'htmx:configRequest': CustomEvent<{
+      parameters: Record<string, string>;
+      headers: Record<string, string>;
+    }>;
+  }
+}
+
+function initMarkdownPreview(markdownWidget: HTMLDivElement) {
+  const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement;
+  const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement;
+  const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement;
+
+  /**
+   * Make sure the textarea has style attribute height
+   * So that it can be copied over to preview div.
+   */
+  if (!isTruthy(textarea.style.height)) {
+    const { height } = textarea.getBoundingClientRect();
+    textarea.style.height = `${height}px`;
+  }
+
+  /**
+   * Add the value of the textarea to the body of the htmx request
+   * and copy the height of text are to the preview div
+   */
+  previewButton.addEventListener('htmx:configRequest', e => {
+    e.detail.parameters = { text: textarea.value || '' };
+    e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN;
+    preview.style.minHeight = textarea.style.height;
+    preview.innerHTML = '';
+  });
+}
+
+export function initMarkdownPreviews(): void {
+  for (const markdownWidget of document.querySelectorAll<HTMLDivElement>('.markdown-widget')) {
+    initMarkdownPreview(markdownWidget);
+  }
+}

+ 67 - 72
netbox/project-static/src/tables/interfaceTable.ts

@@ -1,6 +1,5 @@
 import { getElements, replaceAll, findFirstAdjacent } from '../util';
 import { getElements, replaceAll, findFirstAdjacent } from '../util';
 
 
-type InterfaceState = 'enabled' | 'disabled';
 type ShowHide = 'show' | 'hide';
 type ShowHide = 'show' | 'hide';
 
 
 function isShowHide(value: unknown): value is ShowHide {
 function isShowHide(value: unknown): value is ShowHide {
@@ -27,54 +26,23 @@ class ButtonState {
    * Underlying Button DOM Element
    * Underlying Button DOM Element
    */
    */
   public button: HTMLButtonElement;
   public button: HTMLButtonElement;
-  /**
-   * Table rows with `data-enabled` set to `"enabled"`
-   */
-  private enabledRows: NodeListOf<HTMLTableRowElement>;
-  /**
-   * Table rows with `data-enabled` set to `"disabled"`
-   */
-  private disabledRows: NodeListOf<HTMLTableRowElement>;
-
-  constructor(button: HTMLButtonElement, table: HTMLTableElement) {
-    this.button = button;
-    this.enabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]');
-    this.disabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]');
-  }
 
 
   /**
   /**
-   * This button's controlled type. For example, a button with the class `toggle-disabled` has
-   * directive 'disabled' because it controls the visibility of rows with
-   * `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with
-   * `data-enabled="enabled"`.
+   * Table rows provided in constructor
    */
    */
-  private get directive(): InterfaceState {
-    if (this.button.classList.contains('toggle-disabled')) {
-      return 'disabled';
-    } else if (this.button.classList.contains('toggle-enabled')) {
-      return 'enabled';
-    }
-    // If this class has been instantiated but doesn't contain these classes, it's probably because
-    // the classes are missing in the HTML template.
-    console.warn(this.button);
-    throw new Error('Toggle button does not contain expected class');
-  }
+  private rows: NodeListOf<HTMLTableRowElement>;
 
 
-  /**
-   * Toggle visibility of rows with `data-enabled="enabled"`.
-   */
-  private toggleEnabledRows(): void {
-    for (const row of this.enabledRows) {
-      row.classList.toggle('d-none');
-    }
+  constructor(button: HTMLButtonElement, rows: NodeListOf<HTMLTableRowElement>) {
+    this.button = button;
+    this.rows = rows;
   }
   }
 
 
   /**
   /**
-   * Toggle visibility of rows with `data-enabled="disabled"`.
+   * Remove visibility of button state rows.
    */
    */
-  private toggleDisabledRows(): void {
-    for (const row of this.disabledRows) {
-      row.classList.toggle('d-none');
+  private hideRows(): void {
+    for (const row of this.rows) {
+      row.classList.add('d-none');
     }
     }
   }
   }
 
 
@@ -111,17 +79,6 @@ class ButtonState {
     }
     }
   }
   }
 
 
-  /**
-   * Toggle visibility for the rows this element controls.
-   */
-  private toggleRows(): void {
-    if (this.directive === 'enabled') {
-      this.toggleEnabledRows();
-    } else if (this.directive === 'disabled') {
-      this.toggleDisabledRows();
-    }
-  }
-
   /**
   /**
    * Toggle the DOM element's `data-state` attribute.
    * Toggle the DOM element's `data-state` attribute.
    */
    */
@@ -139,17 +96,20 @@ class ButtonState {
   private toggle(): void {
   private toggle(): void {
     this.toggleState();
     this.toggleState();
     this.toggleButton();
     this.toggleButton();
-    this.toggleRows();
   }
   }
 
 
   /**
   /**
-   * When the button is clicked, toggle all controlled elements.
+   * When the button is clicked, toggle all controlled elements and hide rows based on
+   * buttonstate.
    */
    */
   public handleClick(event: Event): void {
   public handleClick(event: Event): void {
     const button = event.currentTarget as HTMLButtonElement;
     const button = event.currentTarget as HTMLButtonElement;
     if (button.isEqualNode(this.button)) {
     if (button.isEqualNode(this.button)) {
       this.toggle();
       this.toggle();
     }
     }
+    if (this.buttonState === 'hide') {
+      this.hideRows();
+    }
   }
   }
 }
 }
 
 
@@ -174,14 +134,25 @@ class TableState {
   // @ts-expect-error null handling is performed in the constructor
   // @ts-expect-error null handling is performed in the constructor
   private disabledButton: ButtonState;
   private disabledButton: ButtonState;
 
 
+  /**
+   * Instance of ButtonState for the 'show/hide virtual rows' button.
+   */
+  // @ts-expect-error null handling is performed in the constructor
+  private virtualButton: ButtonState;
+
   /**
   /**
    * Underlying DOM Table Caption Element.
    * Underlying DOM Table Caption Element.
    */
    */
   private caption: Nullable<HTMLTableCaptionElement> = null;
   private caption: Nullable<HTMLTableCaptionElement> = null;
 
 
+  /**
+   * All table rows in table
+   */
+  private rows: NodeListOf<HTMLTableRowElement>;
+
   constructor(table: HTMLTableElement) {
   constructor(table: HTMLTableElement) {
     this.table = table;
     this.table = table;
-
+    this.rows = this.table.querySelectorAll('tr');
     try {
     try {
       const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
       const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
         this.table,
         this.table,
@@ -191,6 +162,10 @@ class TableState {
         this.table,
         this.table,
         'button.toggle-disabled',
         'button.toggle-disabled',
       );
       );
+      const toggleVirtualButton = findFirstAdjacent<HTMLButtonElement>(
+        this.table,
+        'button.toggle-virtual',
+      );
 
 
       const caption = this.table.querySelector('caption');
       const caption = this.table.querySelector('caption');
       this.caption = caption;
       this.caption = caption;
@@ -203,13 +178,28 @@ class TableState {
         throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
         throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
       }
       }
 
 
+      if (toggleVirtualButton === null) {
+        throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
+      }
+
       // Attach event listeners to the buttons elements.
       // Attach event listeners to the buttons elements.
       toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
+      toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
 
 
       // Instantiate ButtonState for each button for state management.
       // Instantiate ButtonState for each button for state management.
-      this.enabledButton = new ButtonState(toggleEnabledButton, this.table);
-      this.disabledButton = new ButtonState(toggleDisabledButton, this.table);
+      this.enabledButton = new ButtonState(
+        toggleEnabledButton,
+        table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]'),
+      );
+      this.disabledButton = new ButtonState(
+        toggleDisabledButton,
+        table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]'),
+      );
+      this.virtualButton = new ButtonState(
+        toggleVirtualButton,
+        table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
+      );
     } catch (err) {
     } catch (err) {
       if (err instanceof TableStateError) {
       if (err instanceof TableStateError) {
         // This class is useless for tables that don't have toggle buttons.
         // This class is useless for tables that don't have toggle buttons.
@@ -246,37 +236,42 @@ class TableState {
   private toggleCaption(): void {
   private toggleCaption(): void {
     const showEnabled = this.enabledButton.buttonState === 'show';
     const showEnabled = this.enabledButton.buttonState === 'show';
     const showDisabled = this.disabledButton.buttonState === 'show';
     const showDisabled = this.disabledButton.buttonState === 'show';
+    const showVirtual = this.virtualButton.buttonState === 'show';
 
 
-    if (showEnabled && !showDisabled) {
+    if (showEnabled && !showDisabled && !showVirtual) {
       this.captionText = 'Showing Enabled Interfaces';
       this.captionText = 'Showing Enabled Interfaces';
-    } else if (showEnabled && showDisabled) {
+    } else if (showEnabled && showDisabled && !showVirtual) {
       this.captionText = 'Showing Enabled & Disabled Interfaces';
       this.captionText = 'Showing Enabled & Disabled Interfaces';
-    } else if (!showEnabled && showDisabled) {
+    } else if (!showEnabled && showDisabled && !showVirtual) {
       this.captionText = 'Showing Disabled Interfaces';
       this.captionText = 'Showing Disabled Interfaces';
-    } else if (!showEnabled && !showDisabled) {
-      this.captionText = 'Hiding Enabled & Disabled Interfaces';
+    } else if (!showEnabled && !showDisabled && !showVirtual) {
+      this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
+    } else if (!showEnabled && !showDisabled && showVirtual) {
+      this.captionText = 'Showing Virtual Interfaces';
+    } else if (showEnabled && !showDisabled && showVirtual) {
+      this.captionText = 'Showing Enabled & Virtual Interfaces';
+    } else if (showEnabled && showDisabled && showVirtual) {
+      this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
     } else {
     } else {
       this.captionText = '';
       this.captionText = '';
     }
     }
   }
   }
 
 
   /**
   /**
-   * When toggle buttons are clicked, pass the event to the relevant button's handler and update
-   * this instance's state.
+   * When toggle buttons are clicked, reapply visability all rows and
+   * pass the event to all button handlers
    *
    *
    * @param event onClick event for toggle buttons.
    * @param event onClick event for toggle buttons.
    * @param instance Instance of TableState (`this` cannot be used since that's context-specific).
    * @param instance Instance of TableState (`this` cannot be used since that's context-specific).
    */
    */
   public handleClick(event: Event, instance: TableState): void {
   public handleClick(event: Event, instance: TableState): void {
-    const button = event.currentTarget as HTMLButtonElement;
-    const enabled = button.isEqualNode(instance.enabledButton.button);
-    const disabled = button.isEqualNode(instance.disabledButton.button);
-
-    if (enabled) {
-      instance.enabledButton.handleClick(event);
-    } else if (disabled) {
-      instance.disabledButton.handleClick(event);
+    for (const row of this.rows) {
+      row.classList.remove('d-none');
     }
     }
+
+    instance.enabledButton.handleClick(event);
+    instance.disabledButton.handleClick(event);
+    instance.virtualButton.handleClick(event);
     instance.toggleCaption();
     instance.toggleCaption();
   }
   }
 }
 }

+ 22 - 7
netbox/project-static/styles/netbox.scss

@@ -236,12 +236,12 @@ table {
   }
   }
 
 
   th.asc > a::after {
   th.asc > a::after {
-    content: "\f0140";
+    content: '\f0140';
     font-family: 'Material Design Icons';
     font-family: 'Material Design Icons';
   }
   }
 
 
   th.desc > a::after {
   th.desc > a::after {
-    content: "\f0143";
+    content: '\f0143';
     font-family: 'Material Design Icons';
     font-family: 'Material Design Icons';
   }
   }
 
 
@@ -416,18 +416,18 @@ nav.search {
   }
   }
 }
 }
 
 
-// Styles for the quicksearch and its clear button; 
+// Styles for the quicksearch and its clear button;
 // Overrides input-group styles and adds transition effects
 // Overrides input-group styles and adds transition effects
 .quicksearch {
 .quicksearch {
-  input[type="search"] {
-    border-radius: $border-radius  !important;
+  input[type='search'] {
+    border-radius: $border-radius !important;
   }
   }
 
 
   button {
   button {
     margin-left: -32px !important;
     margin-left: -32px !important;
     z-index: 100 !important;
     z-index: 100 !important;
     outline: none !important;
     outline: none !important;
-    border-radius: $border-radius  !important;
+    border-radius: $border-radius !important;
     transition: visibility 0s, opacity 0.2s linear;
     transition: visibility 0s, opacity 0.2s linear;
   }
   }
 
 
@@ -998,9 +998,24 @@ div.card-overlay {
   padding: 8px;
   padding: 8px;
 }
 }
 
 
+/* Markdown widget */
+.markdown-widget {
+  .nav-link {
+    border-bottom: 0;
+
+    &.active {
+      background-color: var(--nbx-body-bg);
+    }
+  }
+
+  .nav-tabs {
+    background-color: var(--nbx-pre-bg);
+  }
+}
+
 // Preformatted text blocks
 // Preformatted text blocks
 td pre {
 td pre {
-  margin-bottom: 0
+  margin-bottom: 0;
 }
 }
 pre.block {
 pre.block {
   padding: $spacer;
   padding: $spacer;

+ 1 - 0
netbox/templates/dcim/device/inc/interface_table_controls.html

@@ -7,5 +7,6 @@
   <ul class="dropdown-menu">
   <ul class="dropdown-menu">
     <button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
     <button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
     <button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
     <button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
+    <button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
   </ul>
   </ul>
 {% endblock extra_table_controls %}
 {% endblock extra_table_controls %}

+ 1 - 2
netbox/tenancy/forms/bulk_edit.py

@@ -2,7 +2,7 @@ from django import forms
 
 
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea
+from utilities.forms import CommentField, DynamicModelChoiceField
 
 
 __all__ = (
 __all__ = (
     'ContactBulkEditForm',
     'ContactBulkEditForm',
@@ -106,7 +106,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 

+ 1 - 1
netbox/utilities/forms/fields/fields.py

@@ -27,7 +27,7 @@ class CommentField(forms.CharField):
     """
     """
     A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
     A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
     """
     """
-    widget = forms.Textarea
+    widget = widgets.MarkdownWidget
     help_text = f"""
     help_text = f"""
         <i class="mdi mdi-information-outline"></i>
         <i class="mdi mdi-information-outline"></i>
         <a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">
         <a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">

+ 1 - 1
netbox/utilities/forms/forms.py

@@ -180,7 +180,7 @@ class ImportForm(BootstrapMixin, forms.Form):
         if 'data_file' in self.files:
         if 'data_file' in self.files:
             self.data_field = 'data_file'
             self.data_field = 'data_file'
             file = self.files.get('data_file')
             file = self.files.get('data_file')
-            data = file.read().decode('utf-8')
+            data = file.read().decode('utf-8-sig')
         else:
         else:
             data = self.cleaned_data['data']
             data = self.cleaned_data['data']
 
 

+ 1 - 0
netbox/utilities/forms/utils.py

@@ -195,6 +195,7 @@ def parse_csv(reader):
     # `site.slug` header, to indicate the related site is being referenced by its slug.
     # `site.slug` header, to indicate the related site is being referenced by its slug.
 
 
     for header in next(reader):
     for header in next(reader):
+        header = header.strip()
         if '.' in header:
         if '.' in header:
             field, to_field = header.split('.', 1)
             field, to_field = header.split('.', 1)
             headers[field] = to_field
             headers[field] = to_field

+ 5 - 0
netbox/utilities/forms/widgets.py

@@ -16,6 +16,7 @@ __all__ = (
     'ColorSelect',
     'ColorSelect',
     'DatePicker',
     'DatePicker',
     'DateTimePicker',
     'DateTimePicker',
+    'MarkdownWidget',
     'NumericArrayField',
     'NumericArrayField',
     'SelectDurationWidget',
     'SelectDurationWidget',
     'SelectSpeedWidget',
     'SelectSpeedWidget',
@@ -116,6 +117,10 @@ class SelectDurationWidget(forms.NumberInput):
     template_name = 'widgets/select_duration.html'
     template_name = 'widgets/select_duration.html'
 
 
 
 
+class MarkdownWidget(forms.Textarea):
+    template_name = 'widgets/markdown_input.html'
+
+
 class NumericArrayField(SimpleArrayField):
 class NumericArrayField(SimpleArrayField):
 
 
     def clean(self, value):
     def clean(self, value):

+ 0 - 2
netbox/utilities/paginator.py

@@ -76,8 +76,6 @@ def get_paginate_count(request):
     if 'per_page' in request.GET:
     if 'per_page' in request.GET:
         try:
         try:
             per_page = int(request.GET.get('per_page'))
             per_page = int(request.GET.get('per_page'))
-            if request.user.is_authenticated:
-                request.user.config.set('pagination.per_page', per_page, commit=True)
             return _max_allowed(per_page)
             return _max_allowed(per_page)
         except ValueError:
         except ValueError:
             pass
             pass

+ 1 - 1
netbox/utilities/templates/form_helpers/render_field.html

@@ -6,7 +6,7 @@
   {# Render the field label, except for: #}
   {# Render the field label, except for: #}
   {#   1. Checkboxes (label appears to the right of the field #}
   {#   1. Checkboxes (label appears to the right of the field #}
   {#   2. Textareas with no label set (will expand across entire row) #}
   {#   2. Textareas with no label set (will expand across entire row) #}
-  {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' and not label %}
+  {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %}
   {% else %}
   {% else %}
     <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
     <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
       {{ label }}
       {{ label }}

+ 22 - 0
netbox/utilities/templates/widgets/markdown_input.html

@@ -0,0 +1,22 @@
+<div class="border rounded markdown-widget"> 
+    <ul class="nav nav-tabs px-3 pt-2 rounded-top border-0">
+        <li class="nav-item" role="presentation">
+          <button class="nav-link active " id="{{ widget.name }}-input-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-input" type="button" role="tab" aria-controls="{{ widget.name }}-input" aria-selected="true">
+            Write
+          </button>
+        </li>
+        <li class="nav-item" role="presentation">
+            <button hx-target="#{{ widget.name }}-preview" hx-swap="innerHTML" hx-post="{% url 'extras:render_markdown' %}" class="nav-link preview-button" id="{{ widget.name }}-markdown-preview-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-markdown-preview" type="button" role="tab" aria-controls="{{ widget.name }}-markdown-preview" aria-selected="false">
+              Preview   
+            </button>
+          </li>
+      </ul>
+      <div class="tab-content bg-body rounded-bottom border-top">
+        <div class="tab-pane show active" id="{{ widget.name }}-input" role="tabpanel" aria-labelledby="{{ widget.name }}-input-tab">
+        {% include "django/forms/widgets/textarea.html" %}
+        </div>
+        <div class="tab-pane show" id="{{ widget.name }}-markdown-preview" role="tabpanel" aria-labelledby="{{ widget.name }}-markdown-preview-tab">
+            <div id="{{ widget.name }}-preview" class="preview px-3 py-2">Testing</div>
+        </div>
+    </div>
+</div>

+ 4 - 0
netbox/utilities/testing/api.py

@@ -17,6 +17,8 @@ from utilities.api import get_graphql_type_for_model
 from .base import ModelTestCase
 from .base import ModelTestCase
 from .utils import disable_warnings
 from .utils import disable_warnings
 
 
+from ipam.graphql.types import IPAddressFamilyType
+
 
 
 __all__ = (
 __all__ = (
     'APITestCase',
     'APITestCase',
@@ -460,6 +462,8 @@ class APIViewTestCases:
                     # TODO: Come up with something more elegant
                     # TODO: Come up with something more elegant
                     # Temporary hack to support automated testing of reverse generic relations
                     # Temporary hack to support automated testing of reverse generic relations
                     fields_string += f'{field_name} {{ id }}\n'
                     fields_string += f'{field_name} {{ id }}\n'
+                elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):
+                    fields_string += f'{field_name} {{ value, label }}\n'
                 else:
                 else:
                     fields_string += f'{field_name}\n'
                     fields_string += f'{field_name}\n'
 
 

+ 6 - 6
netbox/utilities/utils.py

@@ -359,18 +359,18 @@ def prepare_cloned_fields(instance):
     return QueryDict(urlencode(params), mutable=True)
     return QueryDict(urlencode(params), mutable=True)
 
 
 
 
-def shallow_compare_dict(source_dict, destination_dict, exclude=None):
+def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
     """
     """
     Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
     Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
     the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
     the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
     """
     """
     difference = {}
     difference = {}
 
 
-    for key in destination_dict:
-        if source_dict.get(key) != destination_dict[key]:
-            if isinstance(exclude, (list, tuple)) and key in exclude:
-                continue
-            difference[key] = destination_dict[key]
+    for key, value in destination_dict.items():
+        if key in exclude:
+            continue
+        if source_dict.get(key) != value:
+            difference[key] = value
 
 
     return difference
     return difference
 
 

+ 3 - 1
netbox/virtualization/filtersets.py

@@ -238,7 +238,9 @@ class VirtualMachineFilterSet(
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             Q(name__icontains=value) |
-            Q(comments__icontains=value)
+            Q(comments__icontains=value) |
+            Q(primary_ip4__address__startswith=value) |
+            Q(primary_ip6__address__startswith=value)
         )
         )
 
 
     def _has_primary_ip(self, queryset, name, value):
     def _has_primary_ip(self, queryset, name, value):

+ 1 - 3
netbox/virtualization/forms/bulk_edit.py

@@ -9,7 +9,7 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
     add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect
+    DynamicModelMultipleChoiceField, StaticSelect
 )
 )
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import *
 from virtualization.models import *
@@ -90,7 +90,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label=_('Comments')
         label=_('Comments')
     )
     )
 
 
@@ -163,7 +162,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label=_('Comments')
         label=_('Comments')
     )
     )
 
 

+ 1 - 3
netbox/wireless/forms/bulk_edit.py

@@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea
+from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField
 from wireless.choices import *
 from wireless.choices import *
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.constants import SSID_MAX_LENGTH
 from wireless.models import *
 from wireless.models import *
@@ -74,7 +74,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 
@@ -119,7 +118,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField(
     comments = CommentField(
-        widget=SmallTextarea,
         label='Comments'
         label='Comments'
     )
     )
 
 

+ 5 - 5
requirements.txt

@@ -1,6 +1,6 @@
 bleach==5.0.1
 bleach==5.0.1
 Django==4.1.7
 Django==4.1.7
-django-cors-headers==3.13.0
+django-cors-headers==3.14.0
 django-debug-toolbar==3.8.1
 django-debug-toolbar==3.8.1
 django-filter==22.1
 django-filter==22.1
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
@@ -8,9 +8,9 @@ django-mptt==0.14
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.2.0
 django-prometheus==2.2.0
 django-redis==5.2.0
 django-redis==5.2.0
-django-rich==1.4.0
+django-rich==1.5.0
 django-rq==2.7.0
 django-rq==2.7.0
-django-tables2==2.5.2
+django-tables2==2.5.3
 django-taggit==3.1.0
 django-taggit==3.1.0
 django-timezone-field==5.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
 djangorestframework==3.14.0
@@ -19,13 +19,13 @@ graphene-django==3.0.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.0.13
+mkdocs-material==9.1.2
 mkdocstrings[python-legacy]==0.20.0
 mkdocstrings[python-legacy]==0.20.0
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.4.0
 Pillow==9.4.0
 psycopg2-binary==2.9.5
 psycopg2-binary==2.9.5
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.15.0
+sentry-sdk==1.16.0
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 social-auth-core[openidconnect]==4.3.0
 social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3
 svgwrite==1.4.3

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно