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

Merge branch 'develop' into feature

jeremystretch 4 лет назад
Родитель
Сommit
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
         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.)
-      placeholder: v3.0.4
+      placeholder: v3.0.5
     validations:
       required: true
   - type: dropdown

+ 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.0.4
+      placeholder: v3.0.5
     validations:
       required: true
   - 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.
 
+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
 
 ### `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)
 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>
 

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

@@ -1,6 +1,28 @@
 # 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.db import models
 from django.urls import reverse
@@ -202,6 +203,9 @@ class Circuit(PrimaryModel):
     comments = models.TextField(
         blank=True
     )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
 
     # Cache associated CircuitTerminations
     termination_a = models.ForeignKey(

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

@@ -38,6 +38,7 @@ __all__ = (
     'LocationForm',
     'ManufacturerForm',
     'PlatformForm',
+    'PopulateDeviceBayForm',
     'PowerFeedForm',
     'PowerOutletForm',
     'PowerOutletTemplateForm',
@@ -52,6 +53,7 @@ __all__ = (
     'RegionForm',
     'SiteForm',
     'SiteGroupForm',
+    'VCMemberSelectForm',
     '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.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel):
     name = models.CharField(
         max_length=100
     )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
 
     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 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
 
 __all__ = (
@@ -45,7 +45,7 @@ class CableTable(BaseTable):
         verbose_name='Termination B'
     )
     status = ChoiceFieldColumn()
-    length = tables.TemplateColumn(
+    length = TemplateColumn(
         template_code=CABLE_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 utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
-    MarkdownColumn, TagColumn, ToggleColumn,
+    MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
 )
 from .template_code import (
     CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@@ -258,7 +258,7 @@ class CableTerminationTable(BaseTable):
         orderable=False,
         verbose_name='Cable Color'
     )
-    cable_peer = tables.TemplateColumn(
+    cable_peer = TemplateColumn(
         accessor='_cable_peer',
         template_code=CABLETERMINATION,
         orderable=False,
@@ -268,7 +268,7 @@ class CableTerminationTable(BaseTable):
 
 
 class PathEndpointTable(CableTerminationTable):
-    connection = tables.TemplateColumn(
+    connection = TemplateColumn(
         accessor='_path.last_node',
         template_code=CABLETERMINATION,
         verbose_name='Connection',
@@ -470,7 +470,7 @@ class BaseInterfaceTable(BaseTable):
         verbose_name='IP Addresses'
     )
     untagged_vlan = tables.Column(linkify=True)
-    tagged_vlans = tables.TemplateColumn(
+    tagged_vlans = TemplateColumn(
         template_code=INTERFACE_TAGGED_VLANS,
         orderable=False,
         verbose_name='Tagged VLANs'

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

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

+ 1 - 5
netbox/dcim/urls.py

@@ -1,6 +1,6 @@
 from django.urls import path
 
-from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
+from extras.views import ObjectChangeLogView, ObjectJournalView
 from ipam.views import ServiceEditView
 from utilities.views import SlugRedirectView
 from . import views
@@ -43,7 +43,6 @@ urlpatterns = [
     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>/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
     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>/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:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
 
     # Rack roles
     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>/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:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
     # Manufacturers
     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>/config/', views.DeviceConfigView.as_view(), name='device_config'),
     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
     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):
 
         # 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)
         try:
             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:
-            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
-        self.stdout.write("[*] Checking for expired changelog records")
+        if options['verbosity']:
+            self.stdout.write("[*] Checking for expired changelog records")
         if settings.CHANGELOG_RETENTION:
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
             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}")
             expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
             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)
-                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(
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
             )
 
         # 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:
             headers = {
                 'Accept': 'application/vnd.github.v3+json',
             }
 
             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(
                     url=settings.RELEASE_CHECK_URL,
                     headers=headers,
@@ -73,15 +85,19 @@ class Command(BaseCommand):
                         continue
                     releases.append((version.parse(release['tag_name']), release.get('html_url')))
                 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.set('latest_release', latest_release, None)
 
             except requests.exceptions.RequestException as exc:
-                self.stdout.write(f"\tRequest error: {exc}")
+                self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
         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.
     """
     scripts = OrderedDict()
-
     # Iterate through all modules within the reports path. These are the user-created files in which reports are
     # defined.
     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'):
             module_name = module.name
         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:
             scripts[module_name] = module_scripts
 

+ 1 - 0
netbox/extras/urls.py

@@ -78,6 +78,7 @@ urlpatterns = [
          kwargs={'model': models.ConfigContext}),
 
     # 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>/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()
     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
-            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):
     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 %}
     <a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
 {% 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 %}
 """
 
@@ -218,8 +210,8 @@ class PrefixTable(BaseTable):
         linkify=True,
         verbose_name='VLAN'
     )
-    role = tables.TemplateColumn(
-        template_code=PREFIX_ROLE_LINK
+    role = tables.Column(
+        linkify=True
     )
     is_pool = BooleanColumn(
         verbose_name='Pool'
@@ -264,8 +256,8 @@ class IPRangeTable(BaseTable):
     status = ChoiceFieldColumn(
         default=AVAILABLE_LABEL
     )
-    role = tables.TemplateColumn(
-        template_code=PREFIX_ROLE_LINK
+    role = tables.Column(
+        linkify=True
     )
     tenant = TenantColumn()
 

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

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

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

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 
 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 *
 
 __all__ = (
@@ -11,9 +11,7 @@ __all__ = (
 
 VRF_TARGETS = """
 {% 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 %}
 """
 
@@ -34,11 +32,11 @@ class VRFTable(BaseTable):
     enforce_unique = BooleanColumn(
         verbose_name='Unique'
     )
-    import_targets = tables.TemplateColumn(
+    import_targets = TemplateColumn(
         template_code=VRF_TARGETS,
         orderable=False
     )
-    export_targets = tables.TemplateColumn(
+    export_targets = TemplateColumn(
         template_code=VRF_TARGETS,
         orderable=False
     )

+ 1 - 0
netbox/ipam/views.py

@@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView):
         return {
             'prefix_table': prefix_table,
             'permissions': permissions,
+            'bulk_querystring': f'within={instance.prefix}',
             '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.
         """
         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'])
             queryset = self.filter_queryset(self.get_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
 #
 
-VERSION = '3.0.5-dev'
+VERSION = '3.0.6-dev'
 
 # Hostname
 HOSTNAME = platform.node()

+ 4 - 4
netbox/netbox/urls.py

@@ -17,7 +17,7 @@ from .admin import admin_site
 
 openapi_info = openapi.Info(
     title="NetBox API",
-    default_version='v2',
+    default_version='v3',
     description="API to access NetBox",
     terms_of_service="https://github.com/netbox-community/netbox",
     license=openapi.License(name="Apache v2 License"),
@@ -59,9 +59,9 @@ _patterns = [
     path('api/users/', include('users.api.urls')),
     path('api/virtualization/', include('virtualization.api.urls')),
     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
     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) {
     for (const element of table.querySelectorAll<HTMLInputElement>(
-      'input[type="checkbox"][name="pk"]',
+      'tr:not(.d-none) input[type="checkbox"][name="pk"]',
     )) {
       if (tableSelectAll.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"
   data-netbox-url-name="{{ request.resolver_match.url_name }}"
   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>
     <meta charset="UTF-8" />
@@ -23,34 +27,55 @@
     <title>{% block title %}Home{% endblock %} | NetBox</title>
 
     <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>
 
     {# Static resources #}

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

@@ -72,6 +72,7 @@
 	<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_z side='Z' %}
+        {% include 'inc/image_attachments_panel.html' %}
         {% plugin_right_page object %}
     </div>
 </div>

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

@@ -290,22 +290,7 @@
                 </div>
                 {% endif %}
             </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">
                 <h5 class="card-header">
                     Related Devices

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

@@ -59,22 +59,7 @@
   </div>
 	<div class="col col-md-6">
     {% 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 %}
 	</div>
 </div>

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

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

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

@@ -210,22 +210,7 @@
                 </div>
             </div>
         {% 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">
             <h5 class="card-header">
                 Reservations

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

@@ -30,7 +30,7 @@
       {% if page %}
           <div style="white-space: nowrap; overflow-x: scroll;">
               {% 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 class="text-center">
                           <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 %}
             </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 %}
 	</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 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 %}
-        <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 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>
     </form>
 {% 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 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>
 {% endblock %}

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

@@ -25,11 +25,9 @@
       Child Ranges {% badge object.get_child_ranges.count %}
     </a>
   </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 %}

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

@@ -24,7 +24,7 @@
                 <div class="form-check">
                     <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
                     <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>
                 </div>
               </div>

+ 48 - 9
netbox/utilities/tables.py

@@ -29,13 +29,18 @@ class BaseTable(tables.Table):
             '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
         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
         if self.empty_text is None:
@@ -50,17 +55,22 @@ class BaseTable(tables.Table):
 
         # Apply custom column ordering for user
         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)
                 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)
                     else:
                         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
                 if pk:
@@ -111,6 +121,16 @@ class BaseTable(tables.Table):
     def selected_columns(self):
         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
@@ -157,6 +177,25 @@ class BooleanColumn(tables.Column):
         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):
     """
     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 = []
     for filter_name in form.changed_data:
+        if filter_name not in form.cleaned_data:
+            continue
+
         querydict = query_params.copy()
         if filter_name not in querydict:
             continue

+ 4 - 1
requirements.txt

@@ -18,10 +18,13 @@ gunicorn==20.1.0
 Jinja2==3.0.1
 Markdown==3.3.4
 markdown-include==0.6.0
-mkdocs-material==7.3.0
+mkdocs-material==7.3.1
 netaddr==0.8.0
 Pillow==8.3.2
 psycopg2-binary==2.9.1
 PyYAML==5.4.1
 svgwrite==1.4.1
 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)"
 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
 COMMAND="python3 netbox/manage.py migrate"
 echo "Applying database migrations ($COMMAND)..."

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