소스 검색

Merge branch 'develop' into feature

jeremystretch 4 년 전
부모
커밋
9c8432cf13
44개의 변경된 파일330개의 추가작업 그리고 286개의 파일을 삭제
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 14 0
      docs/customization/custom-scripts.md
  4. 1 1
      docs/installation/index.md
  5. 23 1
      docs/release-notes/version-3.0.md
  6. 4 0
      netbox/circuits/models.py
  7. 2 0
      netbox/dcim/forms/models.py
  8. 4 0
      netbox/dcim/models/power.py
  9. 2 2
      netbox/dcim/tables/cables.py
  10. 4 4
      netbox/dcim/tables/devices.py
  11. 1 5
      netbox/dcim/tables/template_code.py
  12. 1 5
      netbox/dcim/urls.py
  13. 39 23
      netbox/extras/management/commands/housekeeping.py
  14. 5 3
      netbox/extras/scripts.py
  15. 1 0
      netbox/extras/urls.py
  16. 13 9
      netbox/extras/views.py
  17. 5 13
      netbox/ipam/tables/ip.py
  18. 6 16
      netbox/ipam/tables/vlans.py
  19. 4 6
      netbox/ipam/tables/vrfs.py
  20. 1 0
      netbox/ipam/views.py
  21. 1 1
      netbox/netbox/api/views.py
  22. 1 1
      netbox/netbox/settings.py
  23. 4 4
      netbox/netbox/urls.py
  24. 0 0
      netbox/project-static/dist/netbox.js
  25. 0 0
      netbox/project-static/dist/netbox.js.map
  26. 1 1
      netbox/project-static/src/buttons/selectAll.ts
  27. 57 32
      netbox/templates/base/base.html
  28. 1 0
      netbox/templates/circuits/circuit.html
  29. 1 16
      netbox/templates/dcim/device.html
  30. 1 16
      netbox/templates/dcim/location.html
  31. 2 1
      netbox/templates/dcim/powerpanel.html
  32. 1 16
      netbox/templates/dcim/rack.html
  33. 1 1
      netbox/templates/dcim/rack_elevation_list.html
  34. 1 16
      netbox/templates/dcim/site.html
  35. 10 16
      netbox/templates/dcim/virtualchassis_add_member.html
  36. 0 38
      netbox/templates/inc/image_attachments.html
  37. 52 0
      netbox/templates/inc/image_attachments_panel.html
  38. 3 3
      netbox/templates/ipam/aggregate.html
  39. 5 7
      netbox/templates/ipam/prefix/base.html
  40. 1 1
      netbox/templates/utilities/obj_table.html
  41. 48 9
      netbox/utilities/tables.py
  42. 3 0
      netbox/utilities/templatetags/helpers.py
  43. 4 1
      requirements.txt
  44. 0 16
      upgrade.sh

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

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v3.0.4
+      placeholder: v3.0.5
     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.0.4
+      placeholder: v3.0.5
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 14 - 0
docs/customization/custom-scripts.md

@@ -45,6 +45,20 @@ Defining script variables is optional: You may create a script with only a `run(
 
 
 Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
 Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
 
 
+By default, scripts within a module are ordered alphabetically in the scripts list page. To return scripts in a specific order, you can define the `script_order` variable at the end of your module. The `script_order` variable is a tuple which contains each Script class in the desired order. Any scripts that are omitted from this list will be listed last.
+
+```python
+from extras.scripts import Script
+
+class MyCustomScript(Script):
+    ...
+
+class AnotherCustomScript(Script):
+    ...
+
+script_order = (MyCustomScript, AnotherCustomScript)
+```
+
 ## Module Attributes
 ## Module Attributes
 
 
 ### `name`
 ### `name`

+ 1 - 1
docs/installation/index.md

@@ -11,7 +11,7 @@ The following sections detail how to set up a new instance of NetBox:
 5. [HTTP server](5-http-server.md)
 5. [HTTP server](5-http-server.md)
 6. [LDAP authentication](6-ldap.md) (optional)
 6. [LDAP authentication](6-ldap.md) (optional)
 
 
-The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
+The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
 
 
 <iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
 <iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
 
 

+ 23 - 1
docs/release-notes/version-3.0.md

@@ -1,6 +1,28 @@
 # NetBox v3.0
 # NetBox v3.0
 
 
-## v3.0.5 (FUTURE)
+## v3.0.5 (2021-10-04)
+
+### Enhancements
+
+* [#5925](https://github.com/netbox-community/netbox/issues/5925) - Always show IP addresses tab under prefix view
+* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications
+* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels
+* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts
+* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
+
+### Bug Fixes
+
+* [#6433](https://github.com/netbox-community/netbox/issues/6433) - Fix bulk editing of child prefixes under aggregate view
+* [#6817](https://github.com/netbox-community/netbox/issues/6817) - Custom field columns should be removed from tables upon their deletion
+* [#6895](https://github.com/netbox-community/netbox/issues/6895) - Remove errant markup for null values in CSV export
+* [#7215](https://github.com/netbox-community/netbox/issues/7215) - Prevent rack elevations from overlapping when higher width is specified
+* [#7373](https://github.com/netbox-community/netbox/issues/7373) - Fix flashing when server, client, and browser color-mode preferences are mismatched
+* [#7397](https://github.com/netbox-community/netbox/issues/7397) - Fix AttributeError exception when rendering export template for devices via REST API
+* [#7401](https://github.com/netbox-community/netbox/issues/7401) - Pin `jsonschema` package to v3.2.0 to fix REST API docs rendering
+* [#7411](https://github.com/netbox-community/netbox/issues/7411) - Fix exception in UI when adding member devices to virtual chassis
+* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay
+* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag
+* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity
 
 
 ---
 ---
 
 

+ 4 - 0
netbox/circuits/models.py

@@ -1,3 +1,4 @@
+from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
@@ -202,6 +203,9 @@ class Circuit(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
 
 
     # Cache associated CircuitTerminations
     # Cache associated CircuitTerminations
     termination_a = models.ForeignKey(
     termination_a = models.ForeignKey(

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

@@ -38,6 +38,7 @@ __all__ = (
     'LocationForm',
     'LocationForm',
     'ManufacturerForm',
     'ManufacturerForm',
     'PlatformForm',
     'PlatformForm',
+    'PopulateDeviceBayForm',
     'PowerFeedForm',
     'PowerFeedForm',
     'PowerOutletForm',
     'PowerOutletForm',
     'PowerOutletTemplateForm',
     'PowerOutletTemplateForm',
@@ -52,6 +53,7 @@ __all__ = (
     'RegionForm',
     'RegionForm',
     'SiteForm',
     'SiteForm',
     'SiteGroupForm',
     'SiteGroupForm',
+    'VCMemberSelectForm',
     'VirtualChassisForm',
     'VirtualChassisForm',
 )
 )
 
 

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

@@ -1,3 +1,4 @@
+from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel):
     name = models.CharField(
     name = models.CharField(
         max_length=100
         max_length=100
     )
     )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 

+ 2 - 2
netbox/dcim/tables/cables.py

@@ -2,7 +2,7 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
 from dcim.models import Cable
 from dcim.models import Cable
-from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
 from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
 from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
 
 
 __all__ = (
 __all__ = (
@@ -45,7 +45,7 @@ class CableTable(BaseTable):
         verbose_name='Termination B'
         verbose_name='Termination B'
     )
     )
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
-    length = tables.TemplateColumn(
+    length = TemplateColumn(
         template_code=CABLE_LENGTH,
         template_code=CABLE_LENGTH,
         order_by='_abs_length'
         order_by='_abs_length'
     )
     )

+ 4 - 4
netbox/dcim/tables/devices.py

@@ -9,7 +9,7 @@ from dcim.models import (
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
-    MarkdownColumn, TagColumn, ToggleColumn,
+    MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
 )
 )
 from .template_code import (
 from .template_code import (
     CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
     CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@@ -258,7 +258,7 @@ class CableTerminationTable(BaseTable):
         orderable=False,
         orderable=False,
         verbose_name='Cable Color'
         verbose_name='Cable Color'
     )
     )
-    cable_peer = tables.TemplateColumn(
+    cable_peer = TemplateColumn(
         accessor='_cable_peer',
         accessor='_cable_peer',
         template_code=CABLETERMINATION,
         template_code=CABLETERMINATION,
         orderable=False,
         orderable=False,
@@ -268,7 +268,7 @@ class CableTerminationTable(BaseTable):
 
 
 
 
 class PathEndpointTable(CableTerminationTable):
 class PathEndpointTable(CableTerminationTable):
-    connection = tables.TemplateColumn(
+    connection = TemplateColumn(
         accessor='_path.last_node',
         accessor='_path.last_node',
         template_code=CABLETERMINATION,
         template_code=CABLETERMINATION,
         verbose_name='Connection',
         verbose_name='Connection',
@@ -470,7 +470,7 @@ class BaseInterfaceTable(BaseTable):
         verbose_name='IP Addresses'
         verbose_name='IP Addresses'
     )
     )
     untagged_vlan = tables.Column(linkify=True)
     untagged_vlan = tables.Column(linkify=True)
-    tagged_vlans = tables.TemplateColumn(
+    tagged_vlans = TemplateColumn(
         template_code=INTERFACE_TAGGED_VLANS,
         template_code=INTERFACE_TAGGED_VLANS,
         orderable=False,
         orderable=False,
         verbose_name='Tagged VLANs'
         verbose_name='Tagged VLANs'

+ 1 - 5
netbox/dcim/tables/template_code.py

@@ -5,13 +5,11 @@ CABLETERMINATION = """
     <i class="mdi mdi-chevron-right"></i>
     <i class="mdi mdi-chevron-right"></i>
   {% endif %}
   {% endif %}
   <a href="{{ value.get_absolute_url }}">{{ value }}</a>
   <a href="{{ value.get_absolute_url }}">{{ value }}</a>
-{% else %}
-  &mdash;
 {% endif %}
 {% endif %}
 """
 """
 
 
 CABLE_LENGTH = """
 CABLE_LENGTH = """
-{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
+{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
 """
 """
 
 
 CABLE_TERMINATION_PARENT = """
 CABLE_TERMINATION_PARENT = """
@@ -63,8 +61,6 @@ INTERFACE_TAGGED_VLANS = """
     {% endfor %}
     {% endfor %}
 {% elif record.mode == 'tagged-all' %}
 {% elif record.mode == 'tagged-all' %}
   All
   All
-{% else %}
-  &mdash;
 {% endif %}
 {% endif %}
 """
 """
 
 

+ 1 - 5
netbox/dcim/urls.py

@@ -1,6 +1,6 @@
 from django.urls import path
 from django.urls import path
 
 
-from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
+from extras.views import ObjectChangeLogView, ObjectJournalView
 from ipam.views import ServiceEditView
 from ipam.views import ServiceEditView
 from utilities.views import SlugRedirectView
 from utilities.views import SlugRedirectView
 from . import views
 from . import views
@@ -43,7 +43,6 @@ urlpatterns = [
     path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
     path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
     path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
     path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
     path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
     path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
-    path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
 
     # Locations
     # Locations
     path('locations/', views.LocationListView.as_view(), name='location_list'),
     path('locations/', views.LocationListView.as_view(), name='location_list'),
@@ -55,7 +54,6 @@ urlpatterns = [
     path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
     path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
     path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
     path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
     path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
     path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
-    path('locations/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
 
 
     # Rack roles
     # Rack roles
     path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
     path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
@@ -92,7 +90,6 @@ urlpatterns = [
     path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
     path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
     path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
     path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
     path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
     path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
-    path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
 
     # Manufacturers
     # Manufacturers
     path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -229,7 +226,6 @@ urlpatterns = [
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
     path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
     path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
-    path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
 
     # Console ports
     # Console ports
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

+ 39 - 23
netbox/extras/management/commands/housekeeping.py

@@ -18,48 +18,60 @@ class Command(BaseCommand):
     def handle(self, *args, **options):
     def handle(self, *args, **options):
 
 
         # Clear expired authentication sessions (essentially replicating the `clearsessions` command)
         # Clear expired authentication sessions (essentially replicating the `clearsessions` command)
-        self.stdout.write("[*] Clearing expired authentication sessions")
-        if options['verbosity'] >= 2:
-            self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
+        if options['verbosity']:
+            self.stdout.write("[*] Clearing expired authentication sessions")
+            if options['verbosity'] >= 2:
+                self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
         engine = import_module(settings.SESSION_ENGINE)
         engine = import_module(settings.SESSION_ENGINE)
         try:
         try:
             engine.SessionStore.clear_expired()
             engine.SessionStore.clear_expired()
-            self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
+            if options['verbosity']:
+                self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
         except NotImplementedError:
         except NotImplementedError:
-            self.stdout.write(
-                f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
-                f"clearing sessions; skipping."
-            )
+            if options['verbosity']:
+                self.stdout.write(
+                    f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
+                    f"clearing sessions; skipping."
+                )
 
 
         # Delete expired ObjectRecords
         # Delete expired ObjectRecords
-        self.stdout.write("[*] Checking for expired changelog records")
+        if options['verbosity']:
+            self.stdout.write("[*] Checking for expired changelog records")
         if settings.CHANGELOG_RETENTION:
         if settings.CHANGELOG_RETENTION:
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
             if options['verbosity'] >= 2:
             if options['verbosity'] >= 2:
-                self.stdout.write(f"Retention period: {settings.CHANGELOG_RETENTION} days")
+                self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days")
                 self.stdout.write(f"\tCut-off time: {cutoff}")
                 self.stdout.write(f"\tCut-off time: {cutoff}")
             expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
             expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
             if expired_records:
             if expired_records:
-                self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="")
-                self.stdout.flush()
+                if options['verbosity']:
+                    self.stdout.write(
+                        f"\tDeleting {expired_records} expired records... ",
+                        self.style.WARNING,
+                        ending=""
+                    )
+                    self.stdout.flush()
                 ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
                 ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
-                self.stdout.write("Done.", self.style.WARNING)
-            else:
-                self.stdout.write("\tNo expired records found.")
-        else:
+                if options['verbosity']:
+                    self.stdout.write("Done.", self.style.SUCCESS)
+            elif options['verbosity']:
+                self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
+        elif options['verbosity']:
             self.stdout.write(
             self.stdout.write(
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
             )
             )
 
 
         # Check for new releases (if enabled)
         # Check for new releases (if enabled)
-        self.stdout.write("[*] Checking for latest release")
+        if options['verbosity']:
+            self.stdout.write("[*] Checking for latest release")
         if settings.RELEASE_CHECK_URL:
         if settings.RELEASE_CHECK_URL:
             headers = {
             headers = {
                 'Accept': 'application/vnd.github.v3+json',
                 'Accept': 'application/vnd.github.v3+json',
             }
             }
 
 
             try:
             try:
-                self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
+                if options['verbosity'] >= 2:
+                    self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
                 response = requests.get(
                 response = requests.get(
                     url=settings.RELEASE_CHECK_URL,
                     url=settings.RELEASE_CHECK_URL,
                     headers=headers,
                     headers=headers,
@@ -73,15 +85,19 @@ class Command(BaseCommand):
                         continue
                         continue
                     releases.append((version.parse(release['tag_name']), release.get('html_url')))
                     releases.append((version.parse(release['tag_name']), release.get('html_url')))
                 latest_release = max(releases)
                 latest_release = max(releases)
-                self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
-                self.stdout.write(f"\tLatest release: {latest_release[0]}")
+                if options['verbosity'] >= 2:
+                    self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
+                if options['verbosity']:
+                    self.stdout.write(f"\tLatest release: {latest_release[0]}", self.style.SUCCESS)
 
 
                 # Cache the most recent release
                 # Cache the most recent release
                 cache.set('latest_release', latest_release, None)
                 cache.set('latest_release', latest_release, None)
 
 
             except requests.exceptions.RequestException as exc:
             except requests.exceptions.RequestException as exc:
-                self.stdout.write(f"\tRequest error: {exc}")
+                self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
         else:
         else:
-            self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
+            if options['verbosity']:
+                self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
 
 
-        self.stdout.write("Finished.", self.style.SUCCESS)
+        if options['verbosity']:
+            self.stdout.write("Finished.", self.style.SUCCESS)

+ 5 - 3
netbox/extras/scripts.py

@@ -470,7 +470,6 @@ def get_scripts(use_names=False):
     defined name in place of the actual module name.
     defined name in place of the actual module name.
     """
     """
     scripts = OrderedDict()
     scripts = OrderedDict()
-
     # Iterate through all modules within the reports path. These are the user-created files in which reports are
     # Iterate through all modules within the reports path. These are the user-created files in which reports are
     # defined.
     # defined.
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
@@ -478,8 +477,11 @@ def get_scripts(use_names=False):
         if use_names and hasattr(module, 'name'):
         if use_names and hasattr(module, 'name'):
             module_name = module.name
             module_name = module.name
         module_scripts = OrderedDict()
         module_scripts = OrderedDict()
-        for name, cls in inspect.getmembers(module, is_script):
-            module_scripts[name] = cls
+        script_order = getattr(module, "script_order", ())
+        ordered_scripts = [cls for cls in script_order if is_script(cls)]
+        unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
+        for cls in [*ordered_scripts, *unordered_scripts]:
+            module_scripts[cls.__name__] = cls
         if module_scripts:
         if module_scripts:
             scripts[module_name] = module_scripts
             scripts[module_name] = module_scripts
 
 

+ 1 - 0
netbox/extras/urls.py

@@ -78,6 +78,7 @@ urlpatterns = [
          kwargs={'model': models.ConfigContext}),
          kwargs={'model': models.ConfigContext}),
 
 
     # Image attachments
     # Image attachments
+    path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
     path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
     path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
 
 

+ 13 - 9
netbox/extras/views.py

@@ -472,22 +472,26 @@ class ImageAttachmentEditView(generic.ObjectEditView):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     model_form = forms.ImageAttachmentForm
     model_form = forms.ImageAttachmentForm
 
 
-    def alter_obj(self, imageattachment, request, args, kwargs):
-        if not imageattachment.pk:
+    def alter_obj(self, instance, request, args, kwargs):
+        if not instance.pk:
             # Assign the parent object based on URL kwargs
             # Assign the parent object based on URL kwargs
-            model = kwargs.get('model')
-            imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
-        return imageattachment
+            try:
+                app_label, model = request.GET.get('content_type').split('.')
+            except (AttributeError, ValueError):
+                raise Http404("Content type not specified")
+            content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
+            instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
+        return instance
 
 
-    def get_return_url(self, request, imageattachment):
-        return imageattachment.parent.get_absolute_url()
+    def get_return_url(self, request, obj=None):
+        return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
 
 
 
 
 class ImageAttachmentDeleteView(generic.ObjectDeleteView):
 class ImageAttachmentDeleteView(generic.ObjectDeleteView):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
 
 
-    def get_return_url(self, request, imageattachment):
-        return imageattachment.parent.get_absolute_url()
+    def get_return_url(self, request, obj=None):
+        return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
 
 
 
 
 #
 #

+ 5 - 13
netbox/ipam/tables/ip.py

@@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """
 {% if record.pk %}
 {% if record.pk %}
     <a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
     <a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
 {% else %}
 {% else %}
-    &mdash;
-{% endif %}
-"""
-
-PREFIX_ROLE_LINK = """
-{% if record.role %}
-    <a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
-{% else %}
-    &mdash;
+    {{ record.prefix }}
 {% endif %}
 {% endif %}
 """
 """
 
 
@@ -218,8 +210,8 @@ class PrefixTable(BaseTable):
         linkify=True,
         linkify=True,
         verbose_name='VLAN'
         verbose_name='VLAN'
     )
     )
-    role = tables.TemplateColumn(
-        template_code=PREFIX_ROLE_LINK
+    role = tables.Column(
+        linkify=True
     )
     )
     is_pool = BooleanColumn(
     is_pool = BooleanColumn(
         verbose_name='Pool'
         verbose_name='Pool'
@@ -264,8 +256,8 @@ class IPRangeTable(BaseTable):
     status = ChoiceFieldColumn(
     status = ChoiceFieldColumn(
         default=AVAILABLE_LABEL
         default=AVAILABLE_LABEL
     )
     )
-    role = tables.TemplateColumn(
-        template_code=PREFIX_ROLE_LINK
+    role = tables.Column(
+        linkify=True
     )
     )
     tenant = TenantColumn()
     tenant = TenantColumn()
 
 

+ 6 - 16
netbox/ipam/tables/vlans.py

@@ -6,7 +6,7 @@ from dcim.models import Interface
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
-    ToggleColumn,
+    TemplateColumn, ToggleColumn,
 )
 )
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 from ipam.models import *
 from ipam.models import *
@@ -35,19 +35,9 @@ VLAN_LINK = """
 VLAN_PREFIXES = """
 VLAN_PREFIXES = """
 {% for prefix in record.prefixes.all %}
 {% for prefix in record.prefixes.all %}
     <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
     <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
-{% empty %}
-    &mdash;
 {% endfor %}
 {% endfor %}
 """
 """
 
 
-VLAN_ROLE_LINK = """
-{% if record.role %}
-    <a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
-{% else %}
-    &mdash;
-{% endif %}
-"""
-
 VLANGROUP_ADD_VLAN = """
 VLANGROUP_ADD_VLAN = """
 {% with next_vid=record.get_next_available_vid %}
 {% with next_vid=record.get_next_available_vid %}
     {% if next_vid and perms.ipam.add_vlan %}
     {% if next_vid and perms.ipam.add_vlan %}
@@ -115,10 +105,10 @@ class VLANTable(BaseTable):
     status = ChoiceFieldColumn(
     status = ChoiceFieldColumn(
         default=AVAILABLE_LABEL
         default=AVAILABLE_LABEL
     )
     )
-    role = tables.TemplateColumn(
-        template_code=VLAN_ROLE_LINK
+    role = tables.Column(
+        linkify=True
     )
     )
-    prefixes = tables.TemplateColumn(
+    prefixes = TemplateColumn(
         template_code=VLAN_PREFIXES,
         template_code=VLAN_PREFIXES,
         orderable=False,
         orderable=False,
         verbose_name='Prefixes'
         verbose_name='Prefixes'
@@ -190,8 +180,8 @@ class InterfaceVLANTable(BaseTable):
     )
     )
     tenant = TenantColumn()
     tenant = TenantColumn()
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
-    role = tables.TemplateColumn(
-        template_code=VLAN_ROLE_LINK
+    role = tables.Column(
+        linkify=True
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):

+ 4 - 6
netbox/ipam/tables/vrfs.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 import django_tables2 as tables
 
 
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
-from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn
 from ipam.models import *
 from ipam.models import *
 
 
 __all__ = (
 __all__ = (
@@ -11,9 +11,7 @@ __all__ = (
 
 
 VRF_TARGETS = """
 VRF_TARGETS = """
 {% for rt in value.all %}
 {% for rt in value.all %}
-    <a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
-{% empty %}
-    &mdash;
+  <a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
 {% endfor %}
 {% endfor %}
 """
 """
 
 
@@ -34,11 +32,11 @@ class VRFTable(BaseTable):
     enforce_unique = BooleanColumn(
     enforce_unique = BooleanColumn(
         verbose_name='Unique'
         verbose_name='Unique'
     )
     )
-    import_targets = tables.TemplateColumn(
+    import_targets = TemplateColumn(
         template_code=VRF_TARGETS,
         template_code=VRF_TARGETS,
         orderable=False
         orderable=False
     )
     )
-    export_targets = tables.TemplateColumn(
+    export_targets = TemplateColumn(
         template_code=VRF_TARGETS,
         template_code=VRF_TARGETS,
         orderable=False
         orderable=False
     )
     )

+ 1 - 0
netbox/ipam/views.py

@@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView):
         return {
         return {
             'prefix_table': prefix_table,
             'prefix_table': prefix_table,
             'permissions': permissions,
             'permissions': permissions,
+            'bulk_querystring': f'within={instance.prefix}',
             'show_available': request.GET.get('show_available', 'true') == 'true',
             'show_available': request.GET.get('show_available', 'true') == 'true',
         }
         }
 
 

+ 1 - 1
netbox/netbox/api/views.py

@@ -230,7 +230,7 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
         Overrides ListModelMixin to allow processing ExportTemplates.
         Overrides ListModelMixin to allow processing ExportTemplates.
         """
         """
         if 'export' in request.GET:
         if 'export' in request.GET:
-            content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model)
+            content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
             et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
             et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
             queryset = self.filter_queryset(self.get_queryset())
             queryset = self.filter_queryset(self.get_queryset())
             return et.render_to_response(queryset)
             return et.render_to_response(queryset)

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.0.5-dev'
+VERSION = '3.0.6-dev'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 4 - 4
netbox/netbox/urls.py

@@ -17,7 +17,7 @@ from .admin import admin_site
 
 
 openapi_info = openapi.Info(
 openapi_info = openapi.Info(
     title="NetBox API",
     title="NetBox API",
-    default_version='v2',
+    default_version='v3',
     description="API to access NetBox",
     description="API to access NetBox",
     terms_of_service="https://github.com/netbox-community/netbox",
     terms_of_service="https://github.com/netbox-community/netbox",
     license=openapi.License(name="Apache v2 License"),
     license=openapi.License(name="Apache v2 License"),
@@ -59,9 +59,9 @@ _patterns = [
     path('api/users/', include('users.api.urls')),
     path('api/users/', include('users.api.urls')),
     path('api/virtualization/', include('virtualization.api.urls')),
     path('api/virtualization/', include('virtualization.api.urls')),
     path('api/status/', StatusView.as_view(), name='api-status'),
     path('api/status/', StatusView.as_view(), name='api-status'),
-    path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
-    path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
-    re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
+    path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
+    path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
+    re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'),
 
 
     # GraphQL
     # GraphQL
     path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
     path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
netbox/project-static/dist/netbox.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/src/buttons/selectAll.ts

@@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void {
 
 
   if (table !== null) {
   if (table !== null) {
     for (const element of table.querySelectorAll<HTMLInputElement>(
     for (const element of table.querySelectorAll<HTMLInputElement>(
-      'input[type="checkbox"][name="pk"]',
+      'tr:not(.d-none) input[type="checkbox"][name="pk"]',
     )) {
     )) {
       if (tableSelectAll.checked) {
       if (tableSelectAll.checked) {
         // Check all PK checkboxes if the select all checkbox is checked.
         // Check all PK checkboxes if the select all checkbox is checked.

+ 57 - 32
netbox/templates/base/base.html

@@ -6,11 +6,15 @@
   lang="en"
   lang="en"
   data-netbox-url-name="{{ request.resolver_match.url_name }}"
   data-netbox-url-name="{{ request.resolver_match.url_name }}"
   data-netbox-base-path="{{ settings.BASE_PATH }}"
   data-netbox-base-path="{{ settings.BASE_PATH }}"
-  {% if preferences|get_key:'ui.colormode' == 'dark'%}
-    data-netbox-color-mode="dark"
-  {% else %}
-    data-netbox-color-mode="light"
-  {% endif %}
+  {% with preferences|get_key:'ui.colormode' as color_mode %}
+    {% if color_mode == 'dark'%}
+      data-netbox-color-mode="dark"
+    {% elif color_mode == 'light' %}
+      data-netbox-color-mode="light"
+    {% else %}
+      data-netbox-color-mode="unset"
+    {% endif %}
+  {% endwith %}
   >
   >
   <head>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
@@ -23,34 +27,55 @@
     <title>{% block title %}Home{% endblock %} | NetBox</title>
     <title>{% block title %}Home{% endblock %} | NetBox</title>
 
 
     <script type="text/javascript">
     <script type="text/javascript">
-      /**
-       * Determine the best initial color mode to use prior to rendering.
-       */
-      (function() {
-        // Browser prefers dark color scheme.
-        var preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
-        // Browser prefers light color scheme.
-        var preferLight = window.matchMedia('(prefers-color-scheme: light)').matches;
-        // Client NetBox color-mode override.
-        var clientMode = localStorage.getItem('netbox-color-mode');
-        // NetBox server-rendered value.
-        var serverMode = document.documentElement.getAttribute('data-netbox-color-mode');
-
-        if ((clientMode !== null) && (clientMode !== serverMode)) {
-          // If the client mode is set, use its value over the server's value.
-          return document.documentElement.setAttribute('data-netbox-color-mode', clientMode);
-        }
-        if (preferDark && serverMode === 'light') {
-          // If the client value matches the server value, the browser preferrs dark-mode, but
-          // the server value doesn't match the browser preference, use dark mode.
-          return document.documentElement.setAttribute('data-netbox-color-mode', 'dark');
-        }
-        if (preferLight && serverMode === 'dark') {
-          // If the client value matches the server value, the browser preferrs dark-mode, but
-          // the server value doesn't match the browser preference, use light mode.
-          return document.documentElement.setAttribute('data-netbox-color-mode', 'light');
+        /**
+         * Set the color mode on the `<html/>` element and in local storage.
+         */
+        function setMode(mode) {
+            document.documentElement.setAttribute("data-netbox-color-mode", mode);
+            localStorage.setItem("netbox-color-mode", mode);
         }
         }
-      })();
+        /**
+         * Determine the best initial color mode to use prior to rendering.
+         */
+        (function () {
+            try {
+                // Browser prefers dark color scheme.
+                var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+                // Browser prefers light color scheme.
+                var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
+                // Client NetBox color-mode override.
+                var clientMode = localStorage.getItem("netbox-color-mode");
+                // NetBox server-rendered value.
+                var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
+
+                if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
+                    // If the client mode is not set but the server mode is, use the server mode.
+                    return setMode(serverMode);
+                }
+                if (clientMode !== null && clientMode !== serverMode) {
+                    // If the client mode is set and is different than the server mode, use the client mode
+                    // over the server mode, as it should be more recent.
+                    return setMode(clientMode);
+                }
+                if (clientMode === serverMode) {
+                    // If the client and server modes match, use that value.
+                    return setMode(clientMode);
+                }
+                if (preferDark && serverMode === "unset") {
+                    // If the server mode is not set but the browser prefers dark mode, use dark mode.
+                    return setMode("dark");
+                }
+                if (preferLight && serverMode === "unset") {
+                    // If the server mode is not set but the browser prefers light mode, use light mode.
+                    return setMode("light");
+                }
+            } catch (error) {
+                // In the event of an error, log it to the console and set the mode to light mode.
+                console.error(error);
+            }
+            return setMode("light");
+        })();
+
     </script>
     </script>
 
 
     {# Static resources #}
     {# Static resources #}

+ 1 - 0
netbox/templates/circuits/circuit.html

@@ -72,6 +72,7 @@
 	<div class="col col-md-6">
 	<div class="col col-md-6">
         {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
         {% 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 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
+        {% include 'inc/image_attachments_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>

+ 1 - 16
netbox/templates/dcim/device.html

@@ -290,22 +290,7 @@
                 </div>
                 </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
-            <div class="card">
-                <h5 class="card-header">
-                    Images
-                </h5>
-                <div class="card-body">
-                    {% include 'inc/image_attachments.html' with images=object.images.all %}
-                </div>
-                {% if perms.extras.add_imageattachment %}
-                <div class="card-footer text-end noprint">
-                    <a href="{% url 'dcim:device_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
-                        <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-                        Attach an Image
-                    </a>
-                </div>
-                {% endif %}
-            </div>
+            {% include 'inc/image_attachments_panel.html' %}
             <div class="card noprint">
             <div class="card noprint">
                 <h5 class="card-header">
                 <h5 class="card-header">
                     Related Devices
                     Related Devices

+ 1 - 16
netbox/templates/dcim/location.html

@@ -59,22 +59,7 @@
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
-    <div class="card">
-        <h5 class="card-header">
-            Images
-        </h5>
-        <div class="card-body">
-          {% include 'inc/image_attachments.html' with images=object.images.all %}
-        </div>
-        {% if perms.extras.add_imageattachment %}
-          <div class="card-footer text-end noprint">
-              <a href="{% url 'dcim:location_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
-                  <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-                  Attach an Image
-              </a>
-          </div>
-        {% endif %}
-    </div>
+    {% include 'inc/image_attachments_panel.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
 	</div>
 	</div>
 </div>
 </div>

+ 2 - 1
netbox/templates/dcim/powerpanel.html

@@ -39,11 +39,12 @@
                 </table>
                 </table>
             </div>
             </div>
         </div>
         </div>
+        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
         {% plugin_left_page object %}
         {% plugin_left_page object %}
     </div>
     </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'inc/custom_fields_panel.html' %}
-        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
+        {% include 'inc/image_attachments_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>

+ 1 - 16
netbox/templates/dcim/rack.html

@@ -210,22 +210,7 @@
                 </div>
                 </div>
             </div>
             </div>
         {% endif %}
         {% endif %}
-        <div class="card">
-            <h5 class="card-header">
-                Images
-            </h5>
-            <div class="card-body">
-            {% include 'inc/image_attachments.html' with images=object.images.all %}
-            </div>
-            {% if perms.extras.add_imageattachment %}
-            <div class="card-footer text-end noprint">
-                <a href="{% url 'dcim:rack_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
-                    <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                    Attach an Image
-                </a>
-            </div>
-            {% endif %}
-        </div>
+        {% include 'inc/image_attachments_panel.html' %}
         <div class="card">
         <div class="card">
             <h5 class="card-header">
             <h5 class="card-header">
                 Reservations
                 Reservations

+ 1 - 1
netbox/templates/dcim/rack_elevation_list.html

@@ -30,7 +30,7 @@
       {% if page %}
       {% if page %}
           <div style="white-space: nowrap; overflow-x: scroll;">
           <div style="white-space: nowrap; overflow-x: scroll;">
               {% for rack in page %}
               {% for rack in page %}
-                  <div style="display: inline-block; margin-right: 12px; width: 254px">
+                  <div style="display: inline-block; margin-right: 12px">
                   <div style="margin-left: 30px">
                   <div style="margin-left: 30px">
                       <div class="text-center">
                       <div class="text-center">
                           <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
                           <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>

+ 1 - 16
netbox/templates/dcim/site.html

@@ -242,22 +242,7 @@
               {% endif %}
               {% endif %}
             </div>
             </div>
         </div>
         </div>
-        <div class="card">
-            <h5 class="card-header">
-                Images
-            </h5>
-            <div class="card-body">
-            {% include 'inc/image_attachments.html' with images=object.images.all %}
-            </div>
-            {% if perms.extras.add_imageattachment %}
-                <div class="card-footer text-end noprint">
-                    <a href="{% url 'dcim:site_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
-                        <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                        Attach an image
-                    </a>
-                </div>
-            {% endif %}
-        </div>
+        {% include 'inc/image_attachments_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
 	</div>
 	</div>
 </div>
 </div>

+ 10 - 16
netbox/templates/dcim/virtualchassis_add_member.html

@@ -4,25 +4,19 @@
 {% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}
 {% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}
 
 
 {% block content %}
 {% block content %}
-    <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+    <form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
         {% csrf_token %}
         {% csrf_token %}
-        <div class="row mb-3">
-            <div class="col col-md-6">
-                <div class="card">
-                    <h5 class="card-header">Add New Member</h5>
-                    <div class="card-body">
-                        {% render_form member_select_form %}
-                        {% render_form membership_form %}
-                    </div>
-                </div>
+        <div class="card">
+            <h5 class="card-header">Add New Member</h5>
+            <div class="card-body">
+                {% render_form member_select_form %}
+                {% render_form membership_form %}
             </div>
             </div>
         </div>
         </div>
-        <div class="row mb-3">
-            <div class="col col-md-6 text-end">
-                <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-                <button type="submit" name="_addanother" class="btn btn-outline-primary">Add Another</button>
-                <button type="submit" name="_save" class="btn btn-primary">Save</button>
-            </div>
+        <div class="text-end my-3">
+            <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
+            <button type="submit" name="_addanother" class="btn btn-outline-primary">Add Another</button>
+            <button type="submit" name="_save" class="btn btn-primary">Save</button>
         </div>
         </div>
     </form>
     </form>
 {% endblock %}
 {% endblock %}

+ 0 - 38
netbox/templates/inc/image_attachments.html

@@ -1,38 +0,0 @@
-{% load helpers %}
-
-{% if images %}
-    <table class="table table-hover">
-        <tr>
-            <th>Name</th>
-            <th>Size</th>
-            <th>Created</th>
-            <th></th>
-        </tr>
-        {% for attachment in images %}
-            <tr{% if not attachment.size %} class="table-danger"{% endif %}>
-                <td>
-                    <i class="mdi mdi-file-image-outline"></i>
-                    <a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
-                </td>
-                <td>{{ attachment.size|filesizeformat }}</td>
-                <td>{{ attachment.created|annotated_date }}</td>
-                <td class="text-end noprint">
-                    {% if perms.extras.change_imageattachment %}
-                        <a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
-                            <i class="mdi mdi-pencil" aria-hidden="true"></i>
-                        </a>
-                    {% endif %}
-                    {% if perms.extras.delete_imageattachment %}
-                        <a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
-                            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
-                        </a>
-                    {% endif %}
-                </td>
-            </tr>
-        {% endfor %}
-    </table>
-{% else %}
-    <div class="text-muted">
-        None
-    </div>
-{% endif %}

+ 52 - 0
netbox/templates/inc/image_attachments_panel.html

@@ -0,0 +1,52 @@
+{% load helpers %}
+
+<div class="card">
+  <h5 class="card-header">
+    Images
+  </h5>
+  <div class="card-body">
+    {% with images=object.images.all %}
+      {% if images.exists %}
+        <table class="table table-hover">
+          <tr>
+            <th>Name</th>
+            <th>Size</th>
+            <th>Created</th>
+            <th></th>
+          </tr>
+          {% for attachment in images %}
+            <tr{% if not attachment.size %} class="table-danger"{% endif %}>
+              <td>
+                <i class="mdi mdi-file-image-outline"></i>
+                <a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
+              </td>
+              <td>{{ attachment.size|filesizeformat }}</td>
+              <td>{{ attachment.created|annotated_date }}</td>
+              <td class="text-end noprint">
+                {% if perms.extras.change_imageattachment %}
+                  <a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
+                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+                {% if perms.extras.delete_imageattachment %}
+                  <a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
+                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </table>
+      {% else %}
+        <div class="text-muted">None</div>
+      {% endif %}
+    {% endwith %}
+  </div>
+  {% if perms.extras.add_imageattachment %}
+    <div class="card-footer text-end noprint">
+      <a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Attach an image
+      </a>
+    </div>
+  {% endif %}
+</div>

+ 3 - 3
netbox/templates/ipam/aggregate.html

@@ -75,8 +75,8 @@
     </div>
     </div>
 </div>
 </div>
 <div class="row mb-3">
 <div class="row mb-3">
-    <div class="col col-md-12">
-        {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
-	  </div>
+  <div class="col col-md-12">
+    {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
+  </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 5 - 7
netbox/templates/ipam/prefix/base.html

@@ -25,11 +25,9 @@
       Child Ranges {% badge object.get_child_ranges.count %}
       Child Ranges {% badge object.get_child_ranges.count %}
     </a>
     </a>
   </li>
   </li>
-  {% if perms.ipam.view_ipaddress and object.status != 'container' %}
-    <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">
-        IP Addresses {% badge object.get_child_ips.count %}
-      </a>
-    </li>
-  {% endif %}
+  <li role="presentation" class="nav-item">
+    <a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">
+      IP Addresses {% badge object.get_child_ips.count %}
+    </a>
+  </li>
 {% endblock %}
 {% endblock %}

+ 1 - 1
netbox/templates/utilities/obj_table.html

@@ -24,7 +24,7 @@
                 <div class="form-check">
                 <div class="form-check">
                     <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
                     <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
                     <label for="select-all" class="form-check-label">
                     <label for="select-all" class="form-check-label">
-                    Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
+                    Select <strong>all {{ table.objects_count }} {{ table.data.verbose_name_plural }}</strong> matching query
                     </label>
                     </label>
                 </div>
                 </div>
               </div>
               </div>

+ 48 - 9
netbox/utilities/tables.py

@@ -29,13 +29,18 @@ class BaseTable(tables.Table):
             'class': 'table table-hover object-list',
             'class': 'table table-hover object-list',
         }
         }
 
 
-    def __init__(self, *args, user=None, **kwargs):
+    def __init__(self, *args, user=None, extra_columns=None, **kwargs):
         # Add custom field columns
         # Add custom field columns
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         obj_type = ContentType.objects.get_for_model(self._meta.model)
-        for cf in CustomField.objects.filter(content_types=obj_type):
-            self.base_columns[f'cf_{cf.name}'] = CustomFieldColumn(cf)
+        cf_columns = [
+            (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
+        ]
+        if extra_columns is not None:
+            extra_columns.extend(cf_columns)
+        else:
+            extra_columns = cf_columns
 
 
-        super().__init__(*args, **kwargs)
+        super().__init__(*args, extra_columns=extra_columns, **kwargs)
 
 
         # Set default empty_text if none was provided
         # Set default empty_text if none was provided
         if self.empty_text is None:
         if self.empty_text is None:
@@ -50,17 +55,22 @@ class BaseTable(tables.Table):
 
 
         # Apply custom column ordering for user
         # Apply custom column ordering for user
         if user is not None and not isinstance(user, AnonymousUser):
         if user is not None and not isinstance(user, AnonymousUser):
-            columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
-            if columns:
+            selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
+            if selected_columns:
                 pk = self.base_columns.pop('pk', None)
                 pk = self.base_columns.pop('pk', None)
                 actions = self.base_columns.pop('actions', None)
                 actions = self.base_columns.pop('actions', None)
 
 
-                for name, column in self.base_columns.items():
-                    if name in columns:
+                for name, column in self.columns.items():
+                    if name in selected_columns:
                         self.columns.show(name)
                         self.columns.show(name)
                     else:
                     else:
                         self.columns.hide(name)
                         self.columns.hide(name)
-                self.sequence = [c for c in columns if c in self.base_columns]
+                # Rearrange the sequence to list selected columns first, followed by all remaining columns
+                # TODO: There's probably a more clever way to accomplish this
+                self.sequence = [
+                    *[c for c in selected_columns if c in self.columns.names()],
+                    *[c for c in self.columns.names() if c not in selected_columns]
+                ]
 
 
                 # Always include PK and actions column, if defined on the table
                 # Always include PK and actions column, if defined on the table
                 if pk:
                 if pk:
@@ -111,6 +121,16 @@ class BaseTable(tables.Table):
     def selected_columns(self):
     def selected_columns(self):
         return self._get_columns(visible=True)
         return self._get_columns(visible=True)
 
 
+    @property
+    def objects_count(self):
+        """
+        Return the total number of real objects represented by the Table. This is useful when dealing with
+        prefixes/IP addresses/etc., where some table rows may represent available address space.
+        """
+        if not hasattr(self, '_objects_count'):
+            self._objects_count = sum(1 for obj in self.data if getattr(obj, 'pk'))
+        return self._objects_count
+
 
 
 #
 #
 # Table columns
 # Table columns
@@ -157,6 +177,25 @@ class BooleanColumn(tables.Column):
         return str(value)
         return str(value)
 
 
 
 
+class TemplateColumn(tables.TemplateColumn):
+    """
+    Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string.
+    """
+    PLACEHOLDER = mark_safe('&mdash;')
+
+    def render(self, *args, **kwargs):
+        ret = super().render(*args, **kwargs)
+        if not ret.strip():
+            return self.PLACEHOLDER
+        return ret
+
+    def value(self, **kwargs):
+        ret = super().value(**kwargs)
+        if ret == self.PLACEHOLDER:
+            return ''
+        return ret
+
+
 class ButtonsColumn(tables.TemplateColumn):
 class ButtonsColumn(tables.TemplateColumn):
     """
     """
     Render edit, delete, and changelog buttons for an object.
     Render edit, delete, and changelog buttons for an object.

+ 3 - 0
netbox/utilities/templatetags/helpers.py

@@ -398,6 +398,9 @@ def applied_filters(form, query_params):
 
 
     applied_filters = []
     applied_filters = []
     for filter_name in form.changed_data:
     for filter_name in form.changed_data:
+        if filter_name not in form.cleaned_data:
+            continue
+
         querydict = query_params.copy()
         querydict = query_params.copy()
         if filter_name not in querydict:
         if filter_name not in querydict:
             continue
             continue

+ 4 - 1
requirements.txt

@@ -18,10 +18,13 @@ gunicorn==20.1.0
 Jinja2==3.0.1
 Jinja2==3.0.1
 Markdown==3.3.4
 Markdown==3.3.4
 markdown-include==0.6.0
 markdown-include==0.6.0
-mkdocs-material==7.3.0
+mkdocs-material==7.3.1
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==8.3.2
 Pillow==8.3.2
 psycopg2-binary==2.9.1
 psycopg2-binary==2.9.1
 PyYAML==5.4.1
 PyYAML==5.4.1
 svgwrite==1.4.1
 svgwrite==1.4.1
 tablib==3.0.0
 tablib==3.0.0
+
+# Workaround for #7401
+jsonschema==3.2.0

+ 0 - 16
upgrade.sh

@@ -61,22 +61,6 @@ else
   echo "Skipping local dependencies (local_requirements.txt not found)"
   echo "Skipping local dependencies (local_requirements.txt not found)"
 fi
 fi
 
 
-# Test schema migrations integrity
-COMMAND="python3 netbox/manage.py showmigrations"
-eval $COMMAND > /dev/null 2>&1 || {
-  echo "--------------------------------------------------------------------"
-  echo "ERROR: Database schema migrations are out of synchronization. (No"
-  echo "data has been lost.) If attempting to upgrade to NetBox v3.0 or"
-  echo "later, first upgrade to a v2.11 release to ensure schema migrations"
-  echo "have been correctly prepared. For further detail on the exact error,"
-  echo "run the following commands:"
-  echo ""
-  echo "    source ${VIRTUALENV}/bin/activate"
-  echo "    ${COMMAND}"
-  echo "--------------------------------------------------------------------"
-  exit 1
-}
-
 # Apply any database migrations
 # Apply any database migrations
 COMMAND="python3 netbox/manage.py migrate"
 COMMAND="python3 netbox/manage.py migrate"
 echo "Applying database migrations ($COMMAND)..."
 echo "Applying database migrations ($COMMAND)..."

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.