Просмотр исходного кода

Merge pull request #10758 from netbox-community/develop

Release v3.3.6
Jeremy Stretch 3 лет назад
Родитель
Сommit
f1a7bceef2
76 измененных файлов с 915 добавлено и 714 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 9 5
      .github/ISSUE_TEMPLATE/documentation_change.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  4. 5 4
      .github/PULL_REQUEST_TEMPLATE.md
  5. 11 1
      docs/installation/6-ldap.md
  6. 16 16
      docs/plugins/development/forms.md
  7. 4 4
      docs/plugins/development/graphql-api.md
  8. 9 9
      docs/plugins/development/tables.md
  9. 10 10
      docs/plugins/development/views.md
  10. 29 0
      docs/release-notes/version-3.3.md
  11. 1 1
      mkdocs.yml
  12. 6 0
      netbox/circuits/forms/models.py
  13. 4 6
      netbox/circuits/tables/circuits.py
  14. 3 5
      netbox/circuits/tables/providers.py
  15. 7 1
      netbox/dcim/filtersets.py
  16. 1 1
      netbox/dcim/forms/connections.py
  17. 43 0
      netbox/dcim/forms/models.py
  18. 20 9
      netbox/dcim/tables/devices.py
  19. 16 7
      netbox/dcim/tables/devicetypes.py
  20. 4 5
      netbox/dcim/tables/power.py
  21. 3 6
      netbox/dcim/tables/racks.py
  22. 7 18
      netbox/dcim/tables/sites.py
  23. 2 0
      netbox/dcim/tests/test_filtersets.py
  24. 20 1
      netbox/ipam/forms/models.py
  25. 2 0
      netbox/ipam/models/services.py
  26. 1 1
      netbox/ipam/tables/ip.py
  27. 6 0
      netbox/ipam/views.py
  28. 10 8
      netbox/netbox/api/authentication.py
  29. 2 3
      netbox/netbox/api/viewsets/mixins.py
  30. 8 0
      netbox/netbox/authentication.py
  31. 5 2
      netbox/netbox/settings.py
  32. 53 61
      netbox/netbox/views/__init__.py
  33. 2 2
      netbox/netbox/views/generic/object_views.py
  34. 1 2
      netbox/project-static/.eslintrc
  35. 1 1
      netbox/project-static/dist/cable_trace.css
  36. 0 0
      netbox/project-static/dist/config.js
  37. 0 0
      netbox/project-static/dist/config.js.map
  38. 0 0
      netbox/project-static/dist/graphiql.css
  39. 0 0
      netbox/project-static/dist/graphiql.js
  40. 0 0
      netbox/project-static/dist/graphiql.js.map
  41. 0 0
      netbox/project-static/dist/lldp.js
  42. 0 0
      netbox/project-static/dist/lldp.js.map
  43. BIN
      netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff
  44. BIN
      netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2
  45. BIN
      netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot
  46. BIN
      netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf
  47. 0 0
      netbox/project-static/dist/netbox-dark.css
  48. 0 0
      netbox/project-static/dist/netbox-external.css
  49. 0 0
      netbox/project-static/dist/netbox-light.css
  50. 0 0
      netbox/project-static/dist/netbox-print.css
  51. 0 0
      netbox/project-static/dist/netbox.js
  52. 0 0
      netbox/project-static/dist/netbox.js.map
  53. 1 1
      netbox/project-static/dist/rack_elevation.css
  54. 0 0
      netbox/project-static/dist/status.js
  55. 0 0
      netbox/project-static/dist/status.js.map
  56. 28 33
      netbox/project-static/package.json
  57. 5 7
      netbox/project-static/src/netbox.ts
  58. 1 1
      netbox/project-static/styles/select.scss
  59. 461 433
      netbox/project-static/yarn.lock
  60. 3 9
      netbox/templates/circuits/circuit.html
  61. 2 2
      netbox/templates/dcim/device.html
  62. 2 2
      netbox/templates/dcim/powerport.html
  63. 4 4
      netbox/templates/dcim/rearport.html
  64. 2 0
      netbox/templates/extras/tag.html
  65. 2 2
      netbox/templates/home.html
  66. 4 0
      netbox/templates/virtualization/cluster.html
  67. 2 2
      netbox/templates/virtualization/virtualmachine.html
  68. 18 0
      netbox/tenancy/forms/models.py
  69. 10 0
      netbox/tenancy/tables/columns.py
  70. 3 5
      netbox/tenancy/tables/tenants.py
  71. 9 0
      netbox/users/utils.py
  72. 12 0
      netbox/virtualization/forms/models.py
  73. 5 10
      netbox/virtualization/tables/clusters.py
  74. 4 7
      netbox/virtualization/tables/virtualmachines.py
  75. 6 0
      netbox/wireless/forms/models.py
  76. 8 5
      requirements.txt

+ 1 - 1
.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.3.5
+      placeholder: v3.3.6
     validations:
       required: true
   - type: dropdown

+ 9 - 5
.github/ISSUE_TEMPLATE/documentation_change.yaml

@@ -19,11 +19,15 @@ body:
       label: Area
       description: To what section of the documentation does this change primarily pertain?
       options:
-        - Installation instructions
-        - Configuration parameters
-        - Functionality/features
-        - REST API
-        - Administration/development
+        - Features
+        - Installation/upgrade
+        - Getting started
+        - Configuration
+        - Customization
+        - Integrations/API
+        - Plugins
+        - Administration
+        - Development
         - Other
     validations:
       required: true

+ 1 - 1
.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.3.5
+      placeholder: v3.3.6
     validations:
       required: true
   - type: dropdown

+ 5 - 4
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,13 +1,14 @@
 <!--
     Thank you for your interest in contributing to NetBox! Please note that
     our contribution policy requires that a feature request or bug report be
-    approved and assigned prior to filing a pull request. This helps avoid
-    wasting time and effort on something that we might not be able to accept.
+    approved and assigned prior to opening a pull request. This helps avoid
+    waste time and effort on a proposed change that we might not be able to
+    accept.
 
     IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
-    TO YOU, IT WE BE CLOSED AUTOMATICALLY.
+    TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
 
-    Specify your assigned issue number on the line below.
+    Please specify your assigned issue number on the line below.
 -->
 ### Fixes: #1234
 

+ 11 - 1
docs/installation/6-ldap.md

@@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt
 ### General Server Configuration
 
 !!! info
-    When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
+    When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
 
 ```python
 import ldap
@@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
 # Note that this is a NetBox-specific setting which sets:
 #     ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 LDAP_IGNORE_CERT_ERRORS = True
+
+# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
+# Note that this is a NetBox-specific setting which sets:
+#     ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
+LDAP_CA_CERT_DIR = '/etc/ssl/certs'
+
+# Include this setting if you want to validate the LDAP server certificates against your own CA.
+# Note that this is a NetBox-specific setting which sets:
+#     ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
+LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
 ```
 
 STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.

+ 16 - 16
docs/plugins/development/forms.md

@@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
 In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
 
 ::: utilities.forms.ColorField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CommentField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.JSONField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.MACAddressField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.SlugField
-    selection:
+    options:
       members: false
 
 ## Choice Fields
 
 ::: utilities.forms.ChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.MultipleChoiceField
-    selection:
+    options:
       members: false
 
 ## Dynamic Object Fields
 
 ::: utilities.forms.DynamicModelChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.DynamicModelMultipleChoiceField
-    selection:
+    options:
       members: false
 
 ## Content Type Fields
 
 ::: utilities.forms.ContentTypeChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.ContentTypeMultipleChoiceField
-    selection:
+    options:
       members: false
 
 ## CSV Import Fields
 
 ::: utilities.forms.CSVChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVMultipleChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVModelChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVContentTypeField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVMultipleContentTypeField
-    selection:
+    options:
       members: false

+ 4 - 4
docs/plugins/development/graphql-api.md

@@ -32,11 +32,11 @@ schema = MyQuery
 NetBox provides two object type classes for use by plugins.
 
 ::: netbox.graphql.types.BaseObjectType
-    selection:
+    options:
       members: false
 
 ::: netbox.graphql.types.NetBoxObjectType
-    selection:
+    options:
       members: false
 
 ## GraphQL Fields
@@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
 NetBox provides two field classes for use by plugins.
 
 ::: netbox.graphql.fields.ObjectField
-    selection:
+    options:
       members: false
 
 ::: netbox.graphql.fields.ObjectListField
-    selection:
+    options:
       members: false

+ 9 - 9
docs/plugins/development/tables.md

@@ -52,38 +52,38 @@ This will automatically apply any user-specific preferences for the table. (If u
 The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
 
 ::: netbox.tables.BooleanColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ChoiceFieldColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ColorColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ColoredLabelColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ContentTypeColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ContentTypesColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.MarkdownColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.TagColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.TemplateColumn
-    selection:
+    options:
       members:
         - __init__

+ 10 - 10
docs/plugins/development/views.md

@@ -84,24 +84,24 @@ Below are the class definitions for NetBox's object views. These views handle CR
 ::: netbox.views.generic.base.BaseObjectView
 
 ::: netbox.views.generic.ObjectView
-    selection:
+    options:
       members:
         - get_object
         - get_template_name
 
 ::: netbox.views.generic.ObjectEditView
-    selection:
+    options:
       members:
         - get_object
         - alter_object
 
 ::: netbox.views.generic.ObjectDeleteView
-    selection:
+    options:
       members:
         - get_object
 
 ::: netbox.views.generic.ObjectChildrenView
-    selection:
+    options:
       members:
         - get_children
         - prep_table_data
@@ -113,22 +113,22 @@ Below are the class definitions for NetBox's multi-object views. These views han
 ::: netbox.views.generic.base.BaseMultiObjectView
 
 ::: netbox.views.generic.ObjectListView
-    selection:
+    options:
       members:
         - get_table
         - export_table
         - export_template
 
 ::: netbox.views.generic.BulkImportView
-    selection:
+    options:
       members: false
 
 ::: netbox.views.generic.BulkEditView
-    selection:
+    options:
       members: false
 
 ::: netbox.views.generic.BulkDeleteView
-    selection:
+    options:
       members:
         - get_form
 
@@ -137,12 +137,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
 These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
 
 ::: netbox.views.generic.ObjectChangeLogView
-    selection:
+    options:
       members:
         - get_form
 
 ::: netbox.views.generic.ObjectJournalView
-    selection:
+    options:
       members:
         - get_form
 

+ 29 - 0
docs/release-notes/version-3.3.md

@@ -1,5 +1,34 @@
 # NetBox v3.3
 
+## v3.3.6 (2022-10-26)
+
+### Enhancements
+
+* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug
+* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
+* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface
+* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH`
+* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
+
+### Bug Fixes
+
+* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
+* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
+* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
+* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer
+* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
+* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
+* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
+* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
+* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
+* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view
+* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation
+* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs
+* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list
+* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view
+
+---
+
 ## v3.3.5 (2022-10-05)
 
 ### Enhancements

+ 1 - 1
mkdocs.yml

@@ -30,7 +30,7 @@ plugins:
             - os.chdir('netbox/')
             - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
             - django.setup()
-          rendering:
+          options:
             heading_level: 3
             members_order: source
             show_root_heading: true

+ 6 - 0
netbox/circuits/forms/models.py

@@ -76,6 +76,12 @@ class ProviderNetworkForm(NetBoxModelForm):
 class CircuitTypeForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Circuit Type', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = CircuitType
         fields = [

+ 4 - 6
netbox/circuits/tables/circuits.py

@@ -1,8 +1,9 @@
 import django_tables2 as tables
-
 from circuits.models import *
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .columns import CommitRateColumn
 
 __all__ = (
@@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
 
 
-class CircuitTable(TenancyColumnsMixin, NetBoxTable):
+class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     cid = tables.Column(
         linkify=True,
         verbose_name='Circuit ID'
@@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
     )
     commit_rate = CommitRateColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
     )

+ 3 - 5
netbox/circuits/tables/providers.py

@@ -1,7 +1,8 @@
 import django_tables2 as tables
+from circuits.models import *
 from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin
 
-from circuits.models import *
 from netbox.tables import NetBoxTable, columns
 
 __all__ = (
@@ -10,7 +11,7 @@ __all__ = (
 )
 
 
-class ProviderTable(NetBoxTable):
+class ProviderTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
         verbose_name='Circuits'
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='circuits:provider_list'
     )

+ 7 - 1
netbox/dcim/filtersets.py

@@ -800,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         to_field_name='slug',
         label='Manufacturer (slug)',
     )
+    device_type = django_filters.ModelMultipleChoiceFilter(
+        field_name='device_type__slug',
+        queryset=DeviceType.objects.all(),
+        to_field_name='slug',
+        label='Device type (slug)',
+    )
     device_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         label='Device type (ID)',
@@ -1357,7 +1363,7 @@ class InterfaceFilterSet(
         try:
             devices = Device.objects.filter(pk__in=id_list)
             for device in devices:
-                vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
+                vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
             return queryset.none()

+ 1 - 1
netbox/dcim/forms/connections.py

@@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
                         label='Power Feed',
                         disabled_indicator='_occupied',
                         query_params={
-                            'powerpanel_id': f'$termination_{cable_end}_powerpanel',
+                            'power_panel_id': f'$termination_{cable_end}_powerpanel',
                         }
                     )
 

+ 43 - 0
netbox/dcim/forms/models.py

@@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Region', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = Region
         fields = (
@@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Site Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = SiteGroup
         fields = (
@@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
 class RackRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Rack Role', (
+            'name', 'slug', 'color', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = RackRole
         fields = [
@@ -340,6 +358,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
 class ManufacturerForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Manufacturer', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = Manufacturer
         fields = [
@@ -406,6 +430,12 @@ class ModuleTypeForm(NetBoxModelForm):
 class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Device Role', (
+            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = DeviceRole
         fields = [
@@ -422,6 +452,13 @@ class PlatformForm(NetBoxModelForm):
         max_length=64
     )
 
+    fieldsets = (
+        ('Platform', (
+            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+
+        )),
+    )
+
     class Meta:
         model = Platform
         fields = [
@@ -1577,6 +1614,12 @@ class InventoryItemForm(DeviceComponentForm):
 class InventoryItemRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Inventory Item Role', (
+            'name', 'slug', 'color', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = InventoryItemRole
         fields = [

+ 20 - 9
netbox/dcim/tables/devices.py

@@ -1,12 +1,26 @@
 import django_tables2 as tables
-from django_tables2.utils import Accessor
-
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
-    InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
+    ConsolePort,
+    ConsoleServerPort,
+    Device,
+    DeviceBay,
+    DeviceRole,
+    FrontPort,
+    Interface,
+    InventoryItem,
+    InventoryItemRole,
+    ModuleBay,
+    Platform,
+    PowerOutlet,
+    PowerPort,
+    RearPort,
+    VirtualChassis,
 )
+from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .template_code import *
 
 __all__ = (
@@ -137,7 +151,7 @@ class PlatformTable(NetBoxTable):
 # Devices
 #
 
-class DeviceTable(TenancyColumnsMixin, NetBoxTable):
+class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.TemplateColumn(
         order_by=('_name',),
         template_code=DEVICE_LINK
@@ -201,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='VC Priority'
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:device_list'
     )

+ 16 - 7
netbox/dcim/tables/devicetypes.py

@@ -1,10 +1,22 @@
 import django_tables2 as tables
-
 from dcim.models import (
-    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
-    InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    ConsolePortTemplate,
+    ConsoleServerPortTemplate,
+    DeviceBayTemplate,
+    DeviceType,
+    FrontPortTemplate,
+    InterfaceTemplate,
+    InventoryItemTemplate,
+    Manufacturer,
+    ModuleBayTemplate,
+    PowerOutletTemplate,
+    PowerPortTemplate,
+    RearPortTemplate,
 )
+from tenancy.tables import ContactsColumnMixin
+
 from netbox.tables import NetBoxTable, columns
+
 from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
 
 __all__ = (
@@ -27,7 +39,7 @@ __all__ = (
 # Manufacturers
 #
 
-class ManufacturerTable(NetBoxTable):
+class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -43,9 +55,6 @@ class ManufacturerTable(NetBoxTable):
         verbose_name='Platforms'
     )
     slug = tables.Column()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:manufacturer_list'
     )

+ 4 - 5
netbox/dcim/tables/power.py

@@ -1,7 +1,9 @@
 import django_tables2 as tables
-
 from dcim.models import PowerFeed, PowerPanel
+from tenancy.tables import ContactsColumnMixin
+
 from netbox.tables import NetBoxTable, columns
+
 from .devices import CableTerminationTable
 
 __all__ = (
@@ -14,7 +16,7 @@ __all__ = (
 # Power panels
 #
 
-class PowerPanelTable(NetBoxTable):
+class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
         url_params={'power_panel_id': 'pk'},
         verbose_name='Feeds'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:powerpanel_list'
     )

+ 3 - 6
netbox/dcim/tables/racks.py

@@ -1,9 +1,9 @@
 import django_tables2 as tables
+from dcim.models import Rack, RackReservation, RackRole
 from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 
-from dcim.models import Rack, RackReservation, RackRole
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
 
 __all__ = (
     'RackTable',
@@ -37,7 +37,7 @@ class RackRoleTable(NetBoxTable):
 # Racks
 #
 
-class RackTable(TenancyColumnsMixin, NetBoxTable):
+class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         order_by=('_name',),
         linkify=True
@@ -68,9 +68,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         verbose_name='Power'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
     )

+ 7 - 18
netbox/dcim/tables/sites.py

@@ -1,8 +1,9 @@
 import django_tables2 as tables
-
 from dcim.models import Location, Region, Site, SiteGroup
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .template_code import LOCATION_BUTTONS
 
 __all__ = (
@@ -17,7 +18,7 @@ __all__ = (
 # Regions
 #
 
-class RegionTable(NetBoxTable):
+class RegionTable(ContactsColumnMixin, NetBoxTable):
     name = columns.MPTTColumn(
         linkify=True
     )
@@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:region_list'
     )
@@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
 # Site groups
 #
 
-class SiteGroupTable(NetBoxTable):
+class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
     name = columns.MPTTColumn(
         linkify=True
     )
@@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:sitegroup_list'
     )
@@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
 # Sites
 #
 
-class SiteTable(TenancyColumnsMixin, NetBoxTable):
+class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='ASN Count'
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:site_list'
     )
@@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
 # Locations
 #
 
-class LocationTable(TenancyColumnsMixin, NetBoxTable):
+class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = columns.MPTTColumn(
         linkify=True
     )
@@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:location_list'
     )

+ 2 - 0
netbox/dcim/tests/test_filtersets.py

@@ -1643,6 +1643,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_types = DeviceType.objects.all()[:2]
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device_type': [device_types[0].slug, device_types[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_devicerole(self):
         device_roles = DeviceRole.objects.all()[:2]

+ 20 - 1
netbox/ipam/forms/models.py

@@ -88,6 +88,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm):
 class RIRForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('RIR', (
+            'name', 'slug', 'is_private', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = RIR
         fields = [
@@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
 class RoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Role', (
+            'name', 'slug', 'weight', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = Role
         fields = [
@@ -540,6 +552,7 @@ class FHRPGroupForm(NetBoxModelForm):
 
     def save(self, *args, **kwargs):
         instance = super().save(*args, **kwargs)
+        user = getattr(instance, '_user', None)  # Set under FHRPGroupEditView.alter_object()
 
         # Check if we need to create a new IPAddress for the group
         if self.cleaned_data.get('ip_address'):
@@ -553,7 +566,7 @@ class FHRPGroupForm(NetBoxModelForm):
             ipaddress.save()
 
             # Check that the new IPAddress conforms with any assigned object-level permissions
-            if not IPAddress.objects.filter(pk=ipaddress.pk).first():
+            if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first():
                 raise PermissionsViolation()
 
         return instance
@@ -784,6 +797,12 @@ class ServiceTemplateForm(NetBoxModelForm):
         help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
     )
 
+    fieldsets = (
+        ('Service Template', (
+            'name', 'protocol', 'ports', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ServiceTemplate
         fields = ('name', 'protocol', 'ports', 'description', 'tags')

+ 2 - 0
netbox/ipam/models/services.py

@@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
         verbose_name='IP addresses'
     )
 
+    clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
+
     class Meta:
         ordering = ('protocol', 'ports', 'pk')  # (protocol, port) may be non-unique
 

+ 1 - 1
netbox/ipam/tables/ip.py

@@ -375,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     )
     assigned = columns.BooleanColumn(
         accessor='assigned_object_id',
-        linkify=True,
+        linkify=lambda record: record.assigned_object.get_absolute_url(),
         verbose_name='Assigned'
     )
     tags = columns.TagColumn(

+ 6 - 0
netbox/ipam/views.py

@@ -930,6 +930,12 @@ class FHRPGroupEditView(generic.ObjectEditView):
 
         return return_url
 
+    def alter_object(self, obj, request, url_args, url_kwargs):
+        # Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that
+        # we can evaluate permissions during the creation of a new IPAddress within the form.
+        obj._user = request.user
+        return obj
+
 
 class FHRPGroupDeleteView(generic.ObjectDeleteView):
     queryset = FHRPGroup.objects.all()

+ 10 - 8
netbox/netbox/api/authentication.py

@@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication):
         if token.is_expired:
             raise exceptions.AuthenticationFailed("Token expired")
 
-        if not token.user.is_active:
-            raise exceptions.AuthenticationFailed("User inactive")
-
+        user = token.user
         # When LDAP authentication is active try to load user data from LDAP directory
         if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
             from netbox.authentication import LDAPBackend
             ldap_backend = LDAPBackend()
 
             # Load from LDAP if FIND_GROUP_PERMS is active
-            if ldap_backend.settings.FIND_GROUP_PERMS:
-                user = ldap_backend.populate_user(token.user.username)
+            # Always query LDAP when user is not active, otherwise it is never activated again
+            if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
+                ldap_user = ldap_backend.populate_user(token.user.username)
                 # If the user is found in the LDAP directory use it, if not fallback to the local user
-                if user:
-                    return user, token
+                if ldap_user:
+                    user = ldap_user
+
+        if not user.is_active:
+            raise exceptions.AuthenticationFailed("User inactive")
 
-        return token.user, token
+        return user, token
 
 
 class TokenPermissions(DjangoObjectPermissions):

+ 2 - 3
netbox/netbox/api/viewsets/mixins.py

@@ -108,6 +108,5 @@ class ObjectValidationMixin:
             conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
             if conforming_count != len(instance):
                 raise ObjectDoesNotExist
-        else:
-            # Check that the instance is matched by the view's queryset
-            self.queryset.get(pk=instance.pk)
+        elif not self.queryset.filter(pk=instance.pk).exists():
+            raise ObjectDoesNotExist

+ 8 - 0
netbox/netbox/authentication.py

@@ -351,6 +351,14 @@ class LDAPBackend:
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
             ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 
+        # Optionally set CA cert directory
+        if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None):
+            ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir)
+
+        # Optionally set CA cert file
+        if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None):
+            ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file)
+
         return obj
 
 

+ 5 - 2
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.3.5'
+VERSION = '3.3.6'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -85,6 +85,7 @@ CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
+CSRF_COOKIE_PATH = BASE_PATH or '/'
 CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@@ -129,6 +130,8 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE',
 SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
+SESSION_COOKIE_PATH = BASE_PATH or '/'
+LANGUAGE_COOKIE_PATH = BASE_PATH or '/'
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -498,7 +501,7 @@ for param in dir(configuration):
 
 # Force usage of PostgreSQL's JSONB field for extra data
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
-
+SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username'
 
 #
 # Django Prometheus

+ 53 - 61
netbox/netbox/views/__init__.py

@@ -1,5 +1,6 @@
 import platform
 import sys
+from collections import namedtuple
 
 from django.conf import settings
 from django.core.cache import cache
@@ -8,6 +9,7 @@ from django.shortcuts import redirect, render
 from django.template import loader
 from django.template.exceptions import TemplateDoesNotExist
 from django.urls import reverse
+from django.utils.translation import gettext as _
 from django.views.decorators.csrf import requires_csrf_token
 from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
 from django.views.generic import View
@@ -24,100 +26,90 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from netbox.constants import SEARCH_MAX_RESULTS
 from netbox.forms import SearchForm
 from netbox.search import SEARCH_TYPES
-from tenancy.models import Tenant
+from tenancy.models import Contact, Tenant
 from virtualization.models import Cluster, VirtualMachine
 from wireless.models import WirelessLAN, WirelessLink
 
 
+Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
+
+
 class HomeView(View):
     template_name = 'home.html'
 
     def get(self, request):
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
-            return redirect("login")
+            return redirect('login')
 
-        connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
+        console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
             _path__is_complete=True
-        )
-        connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
+        ).count
+        power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
             _path__is_complete=True
-        )
-        connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
+        ).count
+        interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
             _path__is_complete=True
-        )
+        ).count
+
+        def get_count_queryset(model):
+            return model.objects.restrict(request.user, 'view').count
 
         def build_stats():
             org = (
-                ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
-                ("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count),
+                Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
+                Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)),
+                Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)),
             )
             dcim = (
-                ("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count),
-                ("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count),
-                ("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count),
+                Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
+                Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
+                Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
             )
             ipam = (
-                ("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count),
-                ("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count),
-                ("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count),
-                ("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count),
-                ("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count),
-                ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count)
-
+                Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
+                Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
+                Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
+                Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
+                Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
+                Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
             )
             circuits = (
-                ("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count),
-                ("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count),
+                Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
+                Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
             )
             virtualization = (
-                ("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count),
-                ("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count),
-
+                Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
+                     get_count_queryset(Cluster)),
+                Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
+                     get_count_queryset(VirtualMachine)),
             )
             connections = (
-                ("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count),
-                ("dcim.view_consoleport", "Console", connected_consoleports.count),
-                ("dcim.view_interface", "Interfaces", connected_interfaces.count),
-                ("dcim.view_powerport", "Power Connections", connected_powerports.count),
+                Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
+                Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
+                Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
+                Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
             )
             power = (
-                ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count),
-                ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count),
+                Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
+                Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
             )
             wireless = (
-                ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count),
-                ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count),
+                Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
+                     get_count_queryset(WirelessLAN)),
+                Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
+                     get_count_queryset(WirelessLink)),
             )
-            sections = (
-                ("Organization", org, "domain"),
-                ("IPAM", ipam, "counter"),
-                ("Virtualization", virtualization, "monitor"),
-                ("Inventory", dcim, "server"),
-                ("Circuits", circuits, "transit-connection-variant"),
-                ("Connections", connections, "cable-data"),
-                ("Power", power, "flash"),
-                ("Wireless", wireless, "wifi"),
+            stats = (
+                (_('Organization'), org, 'domain'),
+                (_('IPAM'), ipam, 'counter'),
+                (_('Virtualization'), virtualization, 'monitor'),
+                (_('Inventory'), dcim, 'server'),
+                (_('Circuits'), circuits, 'transit-connection-variant'),
+                (_('Connections'), connections, 'cable-data'),
+                (_('Power'), power, 'flash'),
+                (_('Wireless'), wireless, 'wifi'),
             )
 
-            stats = []
-            for section_label, section_items, icon_class in sections:
-                items = []
-                for perm, item_label, get_count in section_items:
-                    app, scope = perm.split(".")
-                    url = ":".join((app, scope.replace("view_", "") + "_list"))
-                    item = {
-                        "label": item_label,
-                        "count": None,
-                        "url": url,
-                        "disabled": True,
-                        "icon": icon_class,
-                    }
-                    if request.user.has_perm(perm):
-                        item["count"] = get_count()
-                        item["disabled"] = False
-                    items.append(item)
-                stats.append((section_label, items, icon_class))
-
             return stats
 
         # Compile changelog table

+ 2 - 2
netbox/netbox/views/generic/object_views.py

@@ -173,7 +173,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
         obj = model_form.save()
 
         # Enforce object-level permissions
-        if not self.queryset.filter(pk=obj.pk).first():
+        if not self.queryset.filter(pk=obj.pk).exists():
             raise PermissionsViolation()
 
         # Iterate through the related object forms (if any), validating and saving each instance.
@@ -390,7 +390,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
                     obj = form.save()
 
                     # Check that the new object conforms with any assigned object-level permissions
-                    if not self.queryset.filter(pk=obj.pk).first():
+                    if not self.queryset.filter(pk=obj.pk).exists():
                         raise PermissionsViolation()
 
                 msg = '{} {}'.format(

+ 1 - 2
netbox/project-static/.eslintrc

@@ -31,8 +31,7 @@
     }
   },
   "rules": {
-    "@typescript-eslint/no-unused-vars": "off",
-    "@typescript-eslint/no-unused-vars-experimental": "error",
+    "@typescript-eslint/no-unused-vars": "error",
     "no-unused-vars": "off",
     "no-inner-declarations": "off",
     "comma-dangle": ["error", "always-multiline"],

+ 1 - 1
netbox/project-static/dist/cable_trace.css

@@ -1 +1 @@
-:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
+:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/graphiql.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/graphiql.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/graphiql.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


BIN
netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff


BIN
netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2


BIN
netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot


BIN
netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.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


+ 1 - 1
netbox/project-static/dist/rack_elevation.css

@@ -1 +1 @@
-svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)}
+svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 28 - 33
netbox/project-static/package.json

@@ -22,43 +22,38 @@
     "validate:formatting:scripts": "prettier -c src/**/*.ts"
   },
   "dependencies": {
-    "@mdi/font": "^5.9.55",
-    "@popperjs/core": "^2.9.2",
+    "@mdi/font": "^7.0.96",
+    "@popperjs/core": "^2.11.6",
     "bootstrap": "~5.0.2",
-    "clipboard": "^2.0.8",
-    "color2k": "^1.2.4",
-    "dayjs": "^1.10.4",
-    "flatpickr": "4.6.3",
-    "htmx.org": "^1.6.1",
-    "just-debounce-it": "^1.4.0",
+    "clipboard": "^2.0.11",
+    "color2k": "^2.0.0",
+    "dayjs": "^1.11.5",
+    "flatpickr": "4.6.13",
+    "htmx.org": "^1.8.0",
+    "just-debounce-it": "^3.1.1",
     "masonry-layout": "^4.2.2",
-    "query-string": "^6.14.1",
-    "sass": "^1.32.8",
-    "simplebar": "^5.3.4",
-    "slim-select": "^1.27.0"
+    "query-string": "^7.1.1",
+    "sass": "^1.55.0",
+    "simplebar": "^5.3.9",
+    "slim-select": "^1.27.1"
   },
   "devDependencies": {
-    "@types/bootstrap": "^5.0.12",
-    "@types/cookie": "^0.4.0",
-    "@types/masonry-layout": "^4.2.2",
-    "@typescript-eslint/eslint-plugin": "^4.29.3",
-    "@typescript-eslint/parser": "^4.29.3",
-    "esbuild": "^0.12.24",
-    "esbuild-sass-plugin": "^1.5.2",
-    "eslint": "^7.32.0",
-    "eslint-config-prettier": "^8.3.0",
-    "eslint-import-resolver-typescript": "^2.4.0",
-    "eslint-plugin-import": "^2.24.2",
-    "eslint-plugin-prettier": "^3.4.1",
-    "prettier": "^2.3.2",
-    "typescript": "~4.3.5"
+    "@types/bootstrap": "^5.0.17",
+    "@types/cookie": "^0.5.1",
+    "@types/masonry-layout": "^4.2.5",
+    "@typescript-eslint/eslint-plugin": "^5.39.0",
+    "@typescript-eslint/parser": "^5.39.0",
+    "esbuild": "^0.13.15",
+    "esbuild-sass-plugin": "^2.3.3",
+    "eslint": "^8.24.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-import-resolver-typescript": "^3.5.1",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "prettier": "^2.7.1",
+    "typescript": "~4.8.4"
   },
   "resolutions": {
-    "eslint-import-resolver-typescript/**/path-parse": "^1.0.7",
-    "slim-select/**/trim-newlines": "^3.0.1",
-    "eslint/glob-parent": "^5.1.2",
-    "esbuild-sass-plugin/**/glob-parent": "^5.1.2",
-    "@typescript-eslint/**/glob-parent": "^5.1.2",
-    "eslint-plugin-import/**/hosted-git-info": "^2.8.9"
+    "@types/bootstrap/**/@popperjs/core": "^2.11.6"
   }
-}
+}

+ 5 - 7
netbox/project-static/src/netbox.ts

@@ -37,14 +37,12 @@ function initDocument(): void {
 }
 
 function initWindow(): void {
-
-  const documentForms = document.forms
-  for (var documentForm of documentForms) {
+  const documentForms = document.forms;
+  for (const documentForm of documentForms) {
     if (documentForm.method.toUpperCase() == 'GET') {
-      // @ts-ignore: Our version of typescript seems to be too old for FormDataEvent
-      documentForm.addEventListener('formdata', function(event: FormDataEvent) {
-      let formData: FormData = event.formData;
-      for (let [name, value] of Array.from(formData.entries())) {
+      documentForm.addEventListener('formdata', function (event: FormDataEvent) {
+        const formData: FormData = event.formData;
+        for (const [name, value] of Array.from(formData.entries())) {
           if (value === '') formData.delete(name);
         }
       });

+ 1 - 1
netbox/project-static/styles/select.scss

@@ -32,7 +32,7 @@ $spacing-s: $input-padding-x;
   }
 }
 
-@import './node_modules/slim-select/src/slim-select/slimselect';
+@import '../node_modules/slim-select/src/slim-select/slimselect';
 
 .ss-main {
   color: $form-select-color;

Разница между файлами не показана из-за своего большого размера
+ 461 - 433
netbox/project-static/yarn.lock


+ 3 - 9
netbox/templates/circuits/circuit.html

@@ -60,23 +60,17 @@
       </div>
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
-      {% include 'inc/panels/comments.html' %}
+      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
+      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
       {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% plugin_right_page object %}
     </div>
   </div>
-  <div class="row">
-    <div class="col col-md-6">
-      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
-    </div>
-    <div class="col col-md-6">
-      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-    </div>
-  </div>
   <div class="row">
     <div class="col col-md-12">
       {% plugin_full_width_page object %}

+ 2 - 2
netbox/templates/dcim/device.html

@@ -178,7 +178,7 @@
                                 {% if object.primary_ip4.nat_inside %}
                                   (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                                 {% elif object.primary_ip4.nat_outside.exists %}
-                                  (NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                                  (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                 {% endif %}
                               {% else %}
                                 {{ ''|placeholder }}
@@ -193,7 +193,7 @@
                                 {% if object.primary_ip6.nat_inside %}
                                   (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                                 {% elif object.primary_ip6.nat_outside.exists %}
-                                  (NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                                  (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                 {% endif %}
                               {% else %}
                                 {{ ''|placeholder }}

+ 2 - 2
netbox/templates/dcim/powerport.html

@@ -77,10 +77,10 @@
                       </button>
                       <ul class="dropdown-menu dropdown-menu-end">
                         <li>
-                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
                         </li>
                         <li>
-                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
                         </li>
                       </ul>
                     </span>

+ 4 - 4
netbox/templates/dcim/rearport.html

@@ -105,16 +105,16 @@
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Interface</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Interface</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Circuit Termination</a>
                                     </li>
                                 </ul>
                             </span>

+ 2 - 0
netbox/templates/extras/tag.html

@@ -39,6 +39,7 @@
           </table>
         </div>
       </div>
+      {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
       <div class="card">
@@ -64,6 +65,7 @@
           </table>
         </div>
       </div>
+      {% plugin_right_page object %}
     </div>
   </div>
   <div class="row">

+ 2 - 2
netbox/templates/home.html

@@ -36,8 +36,8 @@
             <div class="card-body">
               <div class="list-group list-group-flush">
                 {% for item in items %}
-                  {% if not item.disabled %}
-                    <a href="{% url item.url %}" class="list-group-item list-group-item-action">
+                  {% if item.permission in perms %}
+                    <a href="{% url item.viewname %}" class="list-group-item list-group-item-action">
                       <div class="d-flex w-100 justify-content-between align-items-center">
                         {{ item.label }}
                         <h4 class="mb-1">{{ item.count }}</h4>

+ 4 - 0
netbox/templates/virtualization/cluster.html

@@ -19,6 +19,10 @@
                     <th scope="row">Type</th>
                     <td>{{ object.type|linkify }}</td>
                 </tr>
+                <tr>
+                  <th scope="row">Status</th>
+                  <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+                </tr>
                 <tr>
                     <th scope="row">Group</th>
                     <td>{{ object.group|linkify|placeholder }}</td>

+ 2 - 2
netbox/templates/virtualization/virtualmachine.html

@@ -46,7 +46,7 @@
                             {% if object.primary_ip4.nat_inside %}
                               (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                             {% elif object.primary_ip4.nat_outside.exists %}
-                              (NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                              (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                             {% endif %}
                           {% else %}
                             {{ ''|placeholder }}
@@ -61,7 +61,7 @@
                             {% if object.primary_ip6.nat_inside %}
                               (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                             {% elif object.primary_ip6.nat_outside.exists %}
-                              (NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                              (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                             {% endif %}
                           {% else %}
                             {{ ''|placeholder }}

+ 18 - 0
netbox/tenancy/forms/models.py

@@ -27,6 +27,12 @@ class TenantGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Tenant Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = TenantGroup
         fields = [
@@ -64,6 +70,12 @@ class ContactGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Contact Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ContactGroup
         fields = ('parent', 'name', 'slug', 'description', 'tags')
@@ -72,6 +84,12 @@ class ContactGroupForm(NetBoxModelForm):
 class ContactRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Contact Role', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ContactRole
         fields = ('name', 'slug', 'description', 'tags')

+ 10 - 0
netbox/tenancy/tables/columns.py

@@ -1,6 +1,9 @@
 import django_tables2 as tables
 
+from netbox.tables import columns
+
 __all__ = (
+    'ContactsColumnMixin',
     'TenantColumn',
     'TenantGroupColumn',
     'TenancyColumnsMixin',
@@ -55,3 +58,10 @@ class TenantGroupColumn(tables.TemplateColumn):
 class TenancyColumnsMixin(tables.Table):
     tenant_group = TenantGroupColumn()
     tenant = TenantColumn()
+
+
+class ContactsColumnMixin(tables.Table):
+    contacts = columns.ManyToManyColumn(
+        linkify_item=True,
+        transform=lambda obj: obj.contact.name
+    )

+ 3 - 5
netbox/tenancy/tables/tenants.py

@@ -1,7 +1,8 @@
 import django_tables2 as tables
+from tenancy.models import *
+from tenancy.tables import ContactsColumnMixin
 
 from netbox.tables import NetBoxTable, columns
-from tenancy.models import *
 
 __all__ = (
     'TenantGroupTable',
@@ -30,7 +31,7 @@ class TenantGroupTable(NetBoxTable):
         default_columns = ('pk', 'name', 'tenant_count', 'description')
 
 
-class TenantTable(NetBoxTable):
+class TenantTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -38,9 +39,6 @@ class TenantTable(NetBoxTable):
         linkify=True
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='tenancy:contact_list'
     )

+ 9 - 0
netbox/users/utils.py

@@ -0,0 +1,9 @@
+from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX
+
+
+def clean_username(value):
+    """Clean username removing any unsupported character"""
+    value = NO_ASCII_REGEX.sub('', value)
+    value = NO_SPECIAL_REGEX.sub('', value)
+    value = value.replace(':', '')
+    return value

+ 12 - 0
netbox/virtualization/forms/models.py

@@ -28,6 +28,12 @@ __all__ = (
 class ClusterTypeForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Cluster Type', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ClusterType
         fields = (
@@ -38,6 +44,12 @@ class ClusterTypeForm(NetBoxModelForm):
 class ClusterGroupForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Cluster Group', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ClusterGroup
         fields = (

+ 5 - 10
netbox/virtualization/tables/clusters.py

@@ -1,8 +1,8 @@
 import django_tables2 as tables
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
-from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __all__ = (
     'ClusterTable',
@@ -32,7 +32,7 @@ class ClusterTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
-class ClusterGroupTable(NetBoxTable):
+class ClusterGroupTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -41,9 +41,6 @@ class ClusterGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         verbose_name='Clusters'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:clustergroup_list'
     )
@@ -57,7 +54,7 @@ class ClusterGroupTable(NetBoxTable):
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
-class ClusterTable(TenancyColumnsMixin, NetBoxTable):
+class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -67,6 +64,7 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable):
     group = tables.Column(
         linkify=True
     )
+    status = columns.ChoiceFieldColumn()
     site = tables.Column(
         linkify=True
     )
@@ -81,9 +79,6 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='VMs'
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:cluster_list'
     )

+ 4 - 7
netbox/virtualization/tables/virtualmachines.py

@@ -1,10 +1,10 @@
 import django_tables2 as tables
-
 from dcim.tables.devices import BaseInterfaceTable
-from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 from virtualization.models import VirtualMachine, VMInterface
 
+from netbox.tables import NetBoxTable, columns
+
 __all__ = (
     'VirtualMachineTable',
     'VirtualMachineVMInterfaceTable',
@@ -37,7 +37,7 @@ VMINTERFACE_BUTTONS = """
 # Virtual machines
 #
 
-class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
+class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         order_by=('_name',),
         linkify=True
@@ -67,9 +67,6 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:virtualmachine_list'
     )

+ 6 - 0
netbox/wireless/forms/models.py

@@ -19,6 +19,12 @@ class WirelessLANGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Wireless LAN Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = WirelessLANGroup
         fields = [

+ 8 - 5
requirements.txt

@@ -19,18 +19,21 @@ graphene-django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==8.5.6
+mkdocs-material==8.5.7
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.2.0
-psycopg2-binary==2.9.3
+psycopg2-binary==2.9.5
 PyYAML==6.0
-sentry-sdk==1.9.10
+sentry-sdk==1.10.1
 social-auth-app-django==5.0.0
-social-auth-core==4.3.0
+social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3
 tablib==3.2.1
-tzdata==2022.4
+tzdata==2022.5
 
 # Workaround for #7401
 jsonschema==3.2.0
+
+# Temporary fix for #10712
+swagger-spec-validator==2.7.6

Некоторые файлы не были показаны из-за большого количества измененных файлов