Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-10-06 14:04:53 -04:00
commit ca59cd1eb8
43 changed files with 346 additions and 320 deletions

View File

@ -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.5
placeholder: v3.0.6
validations:
required: true
- type: dropdown

View File

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

View File

@ -5,6 +5,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be copied into your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
The `housekeeping` command can also be run manually at any time: Running the command outside of scheduled execution times will not interfere with its operation.
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.

View File

@ -259,10 +259,10 @@ python3 manage.py createsuperuser
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@ -111,10 +111,10 @@ sudo systemctl restart netbox netbox-rq
## Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@ -1,5 +1,24 @@
# NetBox v3.0
## v3.0.6 (2021-10-06)
### Enhancements
* [#6850](https://github.com/netbox-community/netbox/issues/6850) - Default to current user when creating journal entries via REST API
* [#6955](https://github.com/netbox-community/netbox/issues/6955) - Include type, ID, and slug on object view
* [#7394](https://github.com/netbox-community/netbox/issues/7394) - Enable filtering cables by termination type & ID in REST API
* [#7462](https://github.com/netbox-community/netbox/issues/7462) - Include count of assigned virtual machines under platform view
### Bug Fixes
* [#7442](https://github.com/netbox-community/netbox/issues/7442) - Fix missing actions column on user-configured tables
* [#7446](https://github.com/netbox-community/netbox/issues/7446) - Fix exception when viewing a large number of child IPs within a prefix
* [#7455](https://github.com/netbox-community/netbox/issues/7455) - Fix site/provider network validation for circuit termination API serializer
* [#7459](https://github.com/netbox-community/netbox/issues/7459) - Pre-populate location data when adding a device to a rack
* [#7460](https://github.com/netbox-community/netbox/issues/7460) - Fix filtering connections by site ID
---
## v3.0.5 (2021-10-04)
### Enhancements
@ -8,7 +27,6 @@
* [#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
@ -23,6 +41,7 @@
* [#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
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
---

View File

@ -3,10 +3,10 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
from dcim.api.serializers import CableTerminationSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import (
BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
)
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@ -90,11 +90,11 @@ class CircuitSerializer(PrimaryModelSerializer):
]
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False)
provider_network = NestedProviderNetworkSerializer(required=False)
site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta:

View File

@ -136,14 +136,20 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
SIDE_A = CircuitTerminationSideChoices.SIDE_A
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
provider_networks = (
ProviderNetwork(provider=provider, name='Provider Network 1'),
ProviderNetwork(provider=provider, name='Provider Network 2'),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
@ -153,10 +159,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
@ -164,13 +170,13 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
{
'circuit': circuits[2].pk,
'term_side': SIDE_A,
'site': sites[1].pk,
'site': sites[0].pk,
'port_speed': 200000,
},
{
'circuit': circuits[2].pk,
'term_side': SIDE_Z,
'site': sites[1].pk,
'provider_network': provider_networks[0].pk,
'port_speed': 200000,
},
]

View File

@ -10,14 +10,14 @@ from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.choices import ColorChoices
from utilities.filters import (
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .choices import *
from .constants import *
from .models import *
__all__ = (
'CableFilterSet',
'CableTerminationFilterSet',
@ -1184,6 +1184,10 @@ class CableFilterSet(PrimaryModelFilterSet):
method='search',
label='Search',
)
termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter()
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@ -1228,7 +1232,7 @@ class CableFilterSet(PrimaryModelFilterSet):
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
def search(self, queryset, name, value):
if not value.strip():
@ -1243,73 +1247,6 @@ class CableFilterSet(PrimaryModelFilterSet):
return queryset
class ConnectionFilterSet(BaseFilterSet):
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(**{f'{name}__in': value})
class ConsoleConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = ConsolePort
fields = ['name']
class PowerConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = PowerPort
fields = ['name']
class InterfaceConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = Interface
fields = []
class PowerPanelFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
@ -1441,3 +1378,52 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
#
# Connection filter sets
#
class ConnectionFilterSet(BaseFilterSet):
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
)
site = MultiValueCharFilter(
method='filter_connections',
field_name='device__site__slug'
)
device_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device_id'
)
device = MultiValueCharFilter(
method='filter_connections',
field_name='device__name'
)
def filter_connections(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(**{f'{name}__in': value})
class ConsoleConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = ConsolePort
fields = ['name']
class PowerConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = PowerPort
fields = ['name']
class InterfaceConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = Interface
fields = []

View File

@ -132,14 +132,18 @@ class RackElevationSVG:
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link_url = '{}?{}'.format(
reverse('dcim:device_add'),
urlencode({
'site': rack.site.pk,
'location': rack.location.pk if rack.location else '',
'rack': rack.pk,
'face': face_id,
'position': id_
})
)
link = drawing.add(
drawing.a(
href='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
drawing.a(href=link_url, target='_top')
)
if reservation:
link.set_desc('{}{} · {}'.format(

View File

@ -2851,6 +2851,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Interface.objects.bulk_create(interfaces)
console_port = ConsolePort.objects.create(device=devices[0], name='Console Port 1')
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
@ -2858,6 +2861,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@ -2877,7 +2881,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_status(self):
params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'status': [CableStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@ -2888,30 +2892,44 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site': [site[0].slug, site[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_tenant(self):
tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'termination_b_type': 'dcim.consoleserverport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self):
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@ -1229,6 +1229,7 @@ class PlatformView(generic.ObjectView):
return {
'devices_table': devices_table,
'virtualmachine_count': VirtualMachine.objects.filter(platform=instance).count()
}

View File

@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
@ -30,6 +31,7 @@ __all__ = (
'ExportTemplateSerializer',
'ImageAttachmentSerializer',
'JobResultSerializer',
'JournalEntrySerializer',
'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
@ -192,6 +194,12 @@ class JournalEntrySerializer(ValidatedModelSerializer):
queryset=ContentType.objects.all()
)
assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField(
allow_null=True,
queryset=User.objects.all(),
required=False,
default=serializers.CurrentUserDefault()
)
kind = ChoiceField(
choices=JournalEntryKindChoices,
required=False

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -73,16 +73,6 @@
color: color-contrast($value);
}
}
// Use proper foreground color in the alert body. Note: this is applied to p, & small because
// we *don't* want to override the h1-h6 colors for alerts, since those are set to a color
// similar to the alert color.
.alert.alert-#{$color} {
p,
small {
color: color-contrast($value);
}
}
}
// Ensure progress bars (utilization graph) in tables aren't too narrow to display the percentage.
@ -200,16 +190,21 @@ div#advanced-search-content div.card div.card-body div.col:not(:last-child) {
}
table {
a {
text-decoration: none;
&:hover {
text-decoration: underline;
td {
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important;
th {
a, a:hover {
color: $body-color;
text-decoration: none;
}
}
td,
th {
font-size: $font-size-sm;
@ -234,6 +229,11 @@ table {
}
}
&.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important;
}
&.object-list {
th {
font-size: $font-size-xs;

View File

@ -70,6 +70,7 @@ $spacing-s: $input-padding-x;
span.arrow-down,
span.arrow-up {
border-color: currentColor;
color: $text-muted;
}
}
// Don't show the depth indicator outside of the menu.

View File

@ -7,6 +7,7 @@ $input-border-color: $gray-200;
$theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,

View File

@ -23,7 +23,7 @@
--nbx-color-mode-toggle-color: #{$primary};
--nbx-sidenav-link-color: #{$gray-800};
--nbx-sidenav-pin-color: #{$orange};
--nbx-sidenav-parent-color: #{$gray-900};
--nbx-sidenav-parent-color: #{$gray-800};
--nbx-sidenav-group-color: #{$gray-800};
&[data-netbox-color-mode='dark'] {
@ -49,7 +49,7 @@
--nbx-color-mode-toggle-color: #{$yellow-300};
--nbx-sidenav-link-color: #{$gray-200};
--nbx-sidenav-pin-color: #{$yellow};
--nbx-sidenav-parent-color: #{$gray-100};
--nbx-sidenav-parent-color: #{$gray-200};
--nbx-sidenav-group-color: #{$gray-600};
}
}

View File

@ -5,7 +5,7 @@
{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Circuit Termination</h5>
</div>
@ -53,9 +53,8 @@
</div>
{% endwith %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Termination Details</h5>
</div>

View File

@ -17,9 +17,7 @@
<div class="row my-3">
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header">
A Side
</h5>
<h5 class="card-header offset-sm-3">A Side</h5>
<div class="card-body">
{% if termination_a.device %}
{# Device component #}
@ -100,9 +98,7 @@
</div>
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header">
B Side
</h5>
<h5 class="card-header offset-sm-3">B Side</h5>
<div class="card-body">
{% if tabs %}
<ul class="nav nav-tabs">
@ -154,7 +150,7 @@
<div class="row my-3 justify-content-center">
<div class="col col-md-8">
<div class="card">
<h5 class="card-header">Cable</h5>
<h5 class="card-header offset-sm-3">Cable</h5>
<div class="card-body">
{% include 'dcim/inc/cable_form.html' %}
</div>

View File

@ -4,111 +4,104 @@
{% block form %}
{% render_errors form %}
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Device</h5>
</div>
{% render_field form.name %}
{% render_field form.device_role %}
{% render_field form.tags %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Device</h5>
</div>
{% render_field form.name %}
{% render_field form.device_role %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.device_type %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.device_type %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Location</h5>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Location</h5>
</div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Device</label>
<div class="col">
<input class="form-control" value="{{ obj.parent_bay.device }}" disabled />
</div>
</div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Device</label>
<div class="col">
<input class="form-control" value="{{ obj.parent_bay.device }}" disabled />
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Bay</label>
<div class="col">
<div class="input-group">
<input class="form-control" value="{{ obj.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;Remove
</a>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Bay</label>
<div class="col">
<div class="input-group">
<input class="form-control" value="{{ obj.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;Remove
</a>
</div>
</div>
</div>
{% else %}
{% render_field form.face %}
{% render_field form.position %}
{% endif %}
</div>
{% else %}
{% render_field form.face %}
{% render_field form.position %}
{% endif %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Management</h5>
</div>
{% render_field form.status %}
{% render_field form.platform %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Management</h5>
</div>
{% render_field form.status %}
{% render_field form.platform %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5>
</div>
{% render_field form.cluster_group %}
{% render_field form.cluster %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5>
</div>
{% render_field form.cluster_group %}
{% render_field form.cluster %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
{% if form.custom_fields %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
<hr />
{% endif %}
<div class="field-group my-4">
<h5 class="text-center">Local Config Context Data</h5>
{% render_field form.local_context_data %}
<div class="field-group my-5">
<h5 class="text-center">Local Config Context Data</h5>
{% render_field form.local_context_data %}
</div>
<hr />
<div class="field-group my-4">
{% render_field form.comments label='Comments' %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@ -16,7 +16,6 @@
</div>
{% render_field form.tags %}
{% if form.custom_fields %}
<hr />
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>

View File

@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Interface</h5>
</div>
@ -27,9 +27,8 @@
{% render_field form.mgmt_only %}
{% render_field form.mark_connected %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">802.1Q Switching</h5>
</div>
@ -40,8 +39,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -46,6 +46,12 @@
<a href="{% url 'dcim:device_list' %}?platform_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
</td>
</tr>
<tr>
<th scope="row">Virtual Machines</th>
<td>
<a href="{% url 'virtualization:virtualmachine_list' %}?platform_id={{ object.pk }}">{{ virtualmachine_count }}</a>
</td>
</tr>
</table>
</div>
</div>

View File

@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Rack</h5>
</div>
@ -15,9 +15,8 @@
{% render_field form.role %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Inventory Control</h5>
</div>
@ -25,18 +24,16 @@
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Dimensions</h5>
</div>
@ -45,34 +42,33 @@
{% render_field form.u_height %}
<div class="row mb-3">
<label class="col col-md-3 col-form-label text-lg-end">Outer Dimensions</label>
<div class="col col-md-3">
<div class="col col-md-3 mb-1">
{{ form.outer_width }}
<div class="form-text">Width</div>
</div>
<div class="col col-md-3">
<div class="col col-md-3 mb-1">
{{ form.outer_depth }}
<div class="form-text">Depth</div>
</div>
<div class="col col-md-3">
<div class="col col-md-3 mb-1">
{{ form.outer_unit }}
<div class="form-text">Unit</div>
</div>
</div>
{% render_field form.desc_units %}
</div>
<hr />
{% if form.custom_fields %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
<hr />
{% endif %}
<div class="field-group my-4">
{% render_field form.comments label='Comments' %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtual Chassis</h5>
</div>
@ -10,9 +10,8 @@
{% render_field form.domain %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Member Devices</h5>
</div>
@ -25,8 +24,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -11,7 +11,7 @@
{% csrf_token %}
{{ pk_form.pk }}
{{ formset.management_form }}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtual Chassis</h5>
</div>
@ -20,16 +20,14 @@
{% render_field vc_form.master %}
{% render_field vc_form.tags %}
</div>
<hr />
{% if vc_form.custom_fields %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields vc_form %}
</div>
<hr />
{% endif %}
<div class="field-group mb-5">

View File

@ -8,6 +8,12 @@
{% block header %}
{# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<div class="float-end">
<code class="text-muted" title="Object type and ID">
{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}
{% if object.slug %}({{ object.slug }}){% endif %}
</code>
</div>
<ol class="breadcrumb">
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li>

View File

@ -6,18 +6,6 @@
{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}
{% endblock title %}
{% block controls %}
{% if obj and settings.DOCS_ROOT %}
<div class="controls">
<div class="control-group">
<a href="{{ obj|get_docs_url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="View model documentation">
<i class="mdi mdi-help-circle"></i> Help
</a>
</div>
</div>
{% endif %}
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
@ -31,6 +19,16 @@
{% block content-wrapper %}
<div class="tab-content">
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
{# Link to model documentation #}
{% if obj and settings.DOCS_ROOT %}
<div class="float-end">
<a href="{{ obj|get_docs_url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="View model documentation">
<i class="mdi mdi-help-circle"></i> Help
</a>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
{% csrf_token %}
{% for field in form.hidden_fields %}
@ -42,7 +40,7 @@
{# Render grouped fields according to Form #}
{% for group, fields in form.Meta.fieldsets %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{{ group }}</h5>
</div>
@ -50,14 +48,10 @@
{% render_field form|getfield:name %}
{% endfor %}
</div>
{% if not forloop.last %}
<hr />
{% endif %}
{% endfor %}
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
@ -66,15 +60,15 @@
{% endif %}
{% if form.comments %}
<hr />
<div class="field-group my-4">
{% render_field form.comments label='Comments' %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endif %}
{% else %}
{# Render all fields in a single group #}
<div class="field-group my-4">
<div class="field-group my-5">
{% block form_fields %}{% render_form form %}{% endblock %}
</div>
{% endif %}

View File

@ -4,6 +4,8 @@
{% load render_table from django_tables2 %}
{% load static %}
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
{% block controls %}
<div class="controls">
<div class="control-group">
@ -26,7 +28,7 @@
{% block tab_items %}
<li class="nav-item" role="presentation">
<button class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
Records
{% badge table.page.paginator.count %}
</button>
</li>

View File

@ -30,7 +30,7 @@
{% for section, items, icon in stats %}
<div class="col col-sm-12 col-lg-6 col-xl-4 my-2 masonry-item">
<div class="card">
<h6 class="card-header text-primary text-center">
<h6 class="card-header text-center">
<i class="mdi mdi-{{ icon }}"></i>
<span class="ms-1">{{ section }}</span>
</h6>
@ -67,7 +67,7 @@
<div class="row my-4 flex-grow-1 changelog-container">
<div class="col">
<div class="card">
<h6 class="card-header text-primary text-center">
<h6 class="card-header text-center">
<i class="mdi mdi-clipboard-clock"></i>
<span class="ms-1">Change Log</span>
</h6>

View File

@ -4,8 +4,8 @@
<input
type="text"
class="form-control object-filter"
placeholder="Filter"
title="Filter text (regular expressions supported)"
placeholder="Quick find"
title="Find in the results below (regular expressions supported)"
/>
</div>
</div>

View File

@ -9,7 +9,7 @@
{% endblock %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">IP Addresses</h5>
</div>
@ -20,9 +20,8 @@
{% render_field model_form.description %}
{% render_field model_form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
@ -30,8 +29,7 @@
{% render_field model_form.tenant %}
</div>
{% if model_form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -8,7 +8,7 @@
{% endblock tabs %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">IP Address</h5>
</div>
@ -20,18 +20,16 @@
{% render_field form.description %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Interface Assignment</h5>
</div>
@ -81,9 +79,8 @@
</div>
{% endwith %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">NAT IP (Inside)</h5>
</div>
@ -152,8 +149,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Service</h5>
</div>
@ -43,7 +43,6 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -4,7 +4,7 @@
{% load helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">VLAN</h5>
</div>
@ -15,18 +15,16 @@
{% render_field form.description %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Assignment</h5>
</div>
@ -58,8 +56,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -37,7 +37,7 @@
</td>
</tr>
<tr>
<th scope="row">Sites</th>
<th scope="row">Tenants</th>
<td>
<a href="{% url 'tenancy:tenant_list' %}?group_id={{ object.pk }}">{{ tenants_table.rows|length }}</a>
</td>

View File

@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Interface</h5>
</div>
@ -22,9 +22,8 @@
{% render_field form.description %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">802.1Q Switching</h5>
</div>
@ -35,8 +34,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@ -57,14 +57,14 @@ class BaseTable(tables.Table):
if user is not None and not isinstance(user, AnonymousUser):
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)
# Show only persistent or selected columns
for name, column in self.columns.items():
if name in selected_columns:
if name in ['pk', 'actions', *selected_columns]:
self.columns.show(name)
else:
self.columns.hide(name)
# 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 = [
@ -72,12 +72,14 @@ class BaseTable(tables.Table):
*[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:
self.base_columns['pk'] = pk
# PK column should always come first
if 'pk' in self.sequence:
self.sequence.remove('pk')
self.sequence.insert(0, 'pk')
if actions:
self.base_columns['actions'] = actions
# Actions column should always come last
if 'actions' in self.sequence:
self.sequence.remove('actions')
self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
@ -128,7 +130,7 @@ class BaseTable(tables.Table):
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'))
self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
return self._objects_count

View File

@ -1,5 +1,5 @@
Django==3.2.7
django-cors-headers==3.9.0
Django==3.2.8
django-cors-headers==3.10.0
django-debug-toolbar==3.2.2
django-filter==21.1
django-graphiql-debug-toolbar==0.2.0
@ -8,14 +8,14 @@ django-pglocks==1.0.4
django-prometheus==2.1.0
django-redis==5.0.0
django-rq==2.4.1
django-tables2==2.4.0
django-tables2==2.4.1
django-taggit==1.5.1
django-timezone-field==4.2.1
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
graphene_django==2.15.0
gunicorn==20.1.0
Jinja2==3.0.1
Jinja2==3.0.2
Markdown==3.3.4
markdown-include==0.6.0
mkdocs-material==7.3.1