Compare commits

..

66 Commits

Author SHA1 Message Date
Jeremy Stretch
66c4d23119 Merge pull request #7510 from netbox-community/develop
Release v3.0.7
2021-10-08 14:04:34 -04:00
jeremystretch
d66fc8f661 Release v3.0.7 2021-10-08 13:49:15 -04:00
jeremystretch
031876964f #2102: Implement q search filter for device type components 2021-10-08 13:42:43 -04:00
jeremystretch
c63766c4c6 Fix test for #7051 2021-10-07 14:19:29 -04:00
jeremystretch
af6237e12e Fixes #7479: Fix parent interface choices when bulk editing VM interfaces 2021-10-07 13:57:00 -04:00
jeremystretch
00328226ec Fixes #7051: Fix permissions evaluation and improve error handling for connected device REST API endpoint 2021-10-07 13:15:59 -04:00
jeremystretch
b31ba4e9d2 Changelog & UI tweaks for #6879 2021-10-07 12:41:24 -04:00
Jeremy Stretch
4be5d3f9e9 Merge pull request #6960 from candlerb/candlerb/6879-v3
Display device names in front of device front/rear images
2021-10-07 12:35:17 -04:00
jeremystretch
53154746fc Changelog for #7485 2021-10-07 10:40:51 -04:00
Jeremy Stretch
2f4c1b6e8f Merge pull request #7475 from HumanEquivalentUnit/patch-1
Mention data in custom fields, link Jinja2 docs.
2021-10-07 10:38:01 -04:00
Jeremy Stretch
045ec7d3a0 Merge pull request #7486 from alexanderhofstaetter/patch-1
Added "USB Micro AB" combo type to choices
2021-10-07 10:35:40 -04:00
jeremystretch
b73db750e5 Fixes #7471: Correct redirect URL when attaching images via "add another" button 2021-10-07 09:58:42 -04:00
jeremystretch
3f766ffea8 Fixes #7474: Fix AttributeError exception when rendering a report or custom script 2021-10-07 09:37:21 -04:00
Alexander Hofstätter
f28761202f Added "USB Micro AB" combo type to choices 2021-10-07 14:31:54 +02:00
HumanEquivalentUnit
6d1f07df05 Mention data in custom fields, link Jinja2 docs.
Resolves #7367
2021-10-07 00:51:07 +01:00
Brian Candler
eb9f2b36ab Display device names in front of device front/rear images
Fixes #6879
2021-10-06 18:07:28 +00:00
jeremystretch
2bd29127dc PRVB 2021-10-06 14:04:24 -04:00
Jeremy Stretch
3eef6363fd Merge pull request #7465 from netbox-community/develop
Release v3.0.6
2021-10-06 14:02:44 -04:00
jeremystretch
d451f30bfc Release v3.0.6 2021-10-06 13:45:02 -04:00
jeremystretch
105956f8e6 Closes #7464: Improve documentation for executing housekeeping task as a cron job 2021-10-06 13:24:13 -04:00
jeremystretch
39256afb67 Closes #7394: Enable filtering cables by termination type & ID in REST API 2021-10-06 12:06:32 -04:00
jeremystretch
69aaf28b9c Closes #6955: Include type, ID, and slug on object view 2021-10-06 11:23:06 -04:00
jeremystretch
b806220074 Closes #6850: Default to current user when creating journal entries via REST API 2021-10-06 10:56:50 -04:00
jeremystretch
d2bdf4e822 Closes #7462: Include count of assigned virtual machines under platform view 2021-10-06 10:12:44 -04:00
jeremystretch
3ab5682e7a Fixes #7460: Fix filtering connections by site ID 2021-10-06 10:08:56 -04:00
jeremystretch
c0010ec100 Fixes #7459: Pre-populate location data when adding a device to a rack 2021-10-06 10:00:31 -04:00
jeremystretch
6897c5fadd Fixes #7455: Fix site/provider network validation for circuit termination API serializer 2021-10-06 09:11:42 -04:00
jeremystretch
745aa23ed6 Fixes #7458: Correct tenants count label under tenant group view 2021-10-06 08:44:59 -04:00
jeremystretch
9089f5cf67 #7450: Clean up object edit forms 2021-10-05 15:37:49 -04:00
jeremystretch
dd79aae137 #7450: Misc UI cleanup 2021-10-05 15:21:49 -04:00
jeremystretch
26e470f521 #7449: Use lighter color for top-level nav menu items 2021-10-05 14:57:35 -04:00
jeremystretch
a34c8b80e5 #7449: Use original primary color 2021-10-05 14:52:10 -04:00
jeremystretch
854a12982f #7449: Lighten dropdown widget caret color 2021-10-05 14:36:33 -04:00
jeremystretch
cf173d4f50 #7449: Remove color from table header links 2021-10-05 14:16:19 -04:00
jeremystretch
7041486b93 #7449: Remove color from panel headers on home view 2021-10-05 14:03:14 -04:00
jeremystretch
548a8c3be3 #7449: Fix login banner color 2021-10-05 14:01:24 -04:00
jeremystretch
087a018faf Fix changelog for v3.0.5 2021-10-05 12:08:07 -04:00
jeremystretch
e09024e86f Fixes #7446: Fix exception when viewing a large number of child IPs within a prefix 2021-10-05 12:07:03 -04:00
jeremystretch
1757102536 Fixes #7442: Fix missing actions column on user-configured tables 2021-10-05 09:34:30 -04:00
jeremystretch
c262af550d PRVB 2021-10-04 14:18:42 -04:00
Jeremy Stretch
d9c6609b24 Merge pull request #7437 from netbox-community/develop
Release v3.0.5
2021-10-04 14:15:53 -04:00
jeremystretch
339bcb89bb Release v3.0.5 2021-10-04 13:46:34 -04:00
jeremystretch
b5884a5b54 Fixes #7215: Prevent rack elevations from overlapping when higher width is specified 2021-10-04 13:41:16 -04:00
thatmattlove
c818d63043 Fixes #7427: Don't select hidden rows when selecting all in a table 2021-10-04 09:19:18 -07:00
jeremystretch
c9c537a1b9 Fixes #6817: Custom field columns should be removed from tables upon their deletion 2021-10-01 20:22:54 -04:00
jeremystretch
1be748b479 Fixes #6433: Fix bulk editing of child prefixes under aggregate view 2021-10-01 16:21:16 -04:00
jeremystretch
376c776520 Fixes #7425: Housekeeping command should honor zero verbosity 2021-10-01 15:29:22 -04:00
jeremystretch
a1f271d7d9 Fixes #7417: Prevent exception when filtering objects list by invalid tag 2021-10-01 14:07:26 -04:00
jeremystretch
724997cb48 Closes #5925: Always show IP addresses tab under prefix view 2021-10-01 13:39:29 -04:00
jeremystretch
f3fe3f9a18 Closes #6708: Add image attachment support for circuits, power panels 2021-10-01 12:50:51 -04:00
jeremystretch
357a5d1e65 Refactor image attachments panel template 2021-10-01 12:45:41 -04:00
jeremystretch
460e3fd5d6 Introduce a common URL for the creation of image attachments 2021-10-01 12:34:30 -04:00
jeremystretch
257c0afdb5 Closes #6423: Cache rendered REST API specifications 2021-10-01 12:02:04 -04:00
jeremystretch
ed3bc7cdcc Changelog for #7387 2021-10-01 09:30:17 -04:00
Jeremy Stretch
bd181ac84f Merge pull request #7400 from maximumG/7387-order-scripts
Fixes #7387 : possibility to order scripts
2021-10-01 09:20:08 -04:00
jeremystretch
d1f5988db7 Closes #7319: Remove migrations check from upgrade.sh to avoid misleading error messages 2021-10-01 08:57:17 -04:00
jeremystretch
a5b99e7148 Fixes #7412: Fix exception in UI when adding child device to device bay 2021-09-30 12:29:08 -04:00
jeremystretch
114500e7f4 Fixes #7401: Pin jsonschema package to v3.2.0 to fix REST API docs rendering 2021-09-30 11:41:32 -04:00
jeremystretch
d9f178e315 Fixes #7411: Fix exception in UI when adding member devices to virtual chassis 2021-09-30 11:36:16 -04:00
maximumG
7337630704 chore: introduce the script_order notion in the documentation 2021-09-30 09:21:38 +02:00
maximumG
0fdd081869 feat: scripts within a module can now be ordered 2021-09-30 09:17:33 +02:00
jeremystretch
a9761e8dd2 Fixes #7397: Fix AttributeError exception when rendering export template for devices via REST API 2021-09-29 21:09:12 -04:00
jeremystretch
1f1a05dc67 Fixes #6895: Remove errant markup for null values in CSV export 2021-09-29 21:00:45 -04:00
thatmattlove
14b065cf5f Fixes #7373: Improve handling of mismatched server, client, and browser color-mode preferences 2021-09-29 17:44:28 -07:00
jeremystretch
47c3a20fda Correct version number referenced for installation video 2021-09-29 12:40:20 -04:00
jeremystretch
19c984bdab PRVB 2021-09-29 09:46:39 -04:00
91 changed files with 854 additions and 721 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.4
placeholder: v3.0.7
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.4
placeholder: v3.0.7
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

@@ -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`

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

@@ -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>

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,8 +1,8 @@
# Custom Links
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
For example, you might define a link like this:

View File

@@ -1,5 +1,66 @@
# NetBox v3.0
## v3.0.7 (2021-10-08)
### Enhancements
* [#6879](https://github.com/netbox-community/netbox/issues/6879) - Improve ability to toggle images/labels in rack elevations
* [#7485](https://github.com/netbox-community/netbox/issues/7485) - Add USB micro AB type
### Bug Fixes
* [#7051](https://github.com/netbox-community/netbox/issues/7051) - Fix permissions evaluation and improve error handling for connected device REST API endpoint
* [#7471](https://github.com/netbox-community/netbox/issues/7471) - Correct redirect URL when attaching images via "add another" button
* [#7474](https://github.com/netbox-community/netbox/issues/7474) - Fix AttributeError exception when rendering a report or custom script
* [#7479](https://github.com/netbox-community/netbox/issues/7479) - Fix parent interface choices when bulk editing VM interfaces
---
## 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
* [#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
### 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
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
---
## v3.0.4 (2021-09-29)
### Enhancements
@@ -30,6 +91,8 @@
* [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device
* [#7392](https://github.com/netbox-community/netbox/issues/7392) - Fix "help" links for custom fields, other models
---
## v3.0.3 (2021-09-20)
### Enhancements

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

@@ -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(

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

@@ -2,7 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
from django.http import HttpResponseForbidden, HttpResponse
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
@@ -17,10 +17,10 @@ from dcim import filtersets
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from ipam.models import Prefix, VLAN
from netbox.api.views import ModelViewSet
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine
@@ -675,15 +675,25 @@ class ConnectedDeviceViewSet(ViewSet):
if not peer_device_name or not peer_interface_name:
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
# Determine local endpoint from peer interface's connection
peer_device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
name=peer_device_name
)
peer_interface = get_object_or_404(
Interface.objects.all(),
device__name=peer_device_name,
Interface.objects.restrict(request.user, 'view'),
device=peer_device,
name=peer_interface_name
)
local_interface = peer_interface.connected_endpoint
endpoint = peer_interface.connected_endpoint
if local_interface is None:
return Response()
# If an Interface, return the parent device
if type(endpoint) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoint.device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)
# Connected endpoint is none or not an Interface
raise Http404

View File

@@ -192,6 +192,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_OTHER = 'other'
CHOICES = (
@@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
)),
('Other', (
(TYPE_OTHER, 'Other'),
@@ -337,6 +339,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Direct current (DC)
@@ -444,6 +447,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),

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',
@@ -480,12 +480,21 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
field_name='device_type_id',
label='Device type (ID)',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@@ -1184,6 +1193,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 +1241,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 +1256,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 +1387,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

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

View File

@@ -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()

View File

@@ -112,6 +112,9 @@ class RackElevationSVG:
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked")
@@ -129,17 +132,24 @@ class RackElevationSVG:
)
image.fit(scale='slice')
drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
@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

@@ -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'
)

View File

@@ -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'

View File

@@ -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 %}
"""

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -1490,40 +1491,35 @@ class ConnectedDeviceTest(APITestCase):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype1 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.devicetype2 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
)
self.devicerole1 = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
)
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name)
self.assertEqual(response.data['name'], self.device2.name)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):

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

@@ -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'),

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

@@ -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)

View File

@@ -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

View File

@@ -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'),

View File

@@ -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)
#

View File

@@ -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()

View File

@@ -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):

View File

@@ -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
)

View File

@@ -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',
}

View File

@@ -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)

View File

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

View File

@@ -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'),

View File

@@ -282,14 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
messages.success(request, mark_safe(msg))
if '_addanother' in request.POST:
redirect_url = request.path
return_url = request.GET.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
redirect_url = f'{redirect_url}?return_url={return_url}'
redirect_url = request.get_full_path()
# If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'):
redirect_url += f"{'&' if return_url else '?'}{prepare_cloned_fields(obj)}"
redirect_url += f"{'&' if '?' in redirect_url else '?'}{prepare_cloned_fields(obj)}"
return redirect(redirect_url)
@@ -880,6 +877,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type')
elif 'virtual_machine' in request.GET:
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
form = self.form(model, initial=initial_data)
restrict_form_fields(form, request.user)

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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.

View File

@@ -1,92 +1,90 @@
import { rackImagesState } from './stores';
import { rackImagesState, RackViewSelection } from './stores';
import { getElements } from './util';
import type { StateManager } from './state';
type RackToggleState = { hidden: boolean };
export type RackViewState = { view: RackViewSelection };
/**
* Toggle the Rack Image button to reflect the current state. If the current state is hidden and
* the images are therefore hidden, the button should say "Show Images". Likewise, if the current
* state is *not* hidden, and therefore the images are shown, the button should say "Hide Images".
*
* @param hidden Current State - `true` if images are hidden, `false` otherwise.
* @param button Button element.
* Show or hide images and labels to build the desired rack view.
*/
function toggleRackImagesButton(hidden: boolean, button: HTMLButtonElement): void {
const text = hidden ? 'Show Images' : 'Hide Images';
const selected = hidden ? '' : 'selected';
button.setAttribute('selected', selected);
button.innerHTML = `<i class="mdi mdi-file-image-outline"></i>&nbsp;${text}`;
}
/**
* Show all rack images.
*/
function showRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.remove('hidden');
}
}
}
/**
* Hide all rack images.
*/
function hideRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.add('hidden');
}
}
}
/**
* Toggle the visibility of device images and update the toggle button style.
*/
function handleRackImageToggle(
target: HTMLButtonElement,
state: StateManager<RackToggleState>,
function setRackView(
view: RackViewSelection,
elevation: HTMLObjectElement,
): void {
const initiallyHidden = state.get('hidden');
state.set('hidden', !initiallyHidden);
const hidden = state.get('hidden');
if (hidden) {
hideRackImages();
} else {
showRackImages();
switch(view) {
case 'images-and-labels': {
showRackElements('image.device-image', elevation);
showRackElements('text.device-image-label', elevation);
break;
}
case 'images-only': {
showRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
case 'labels-only': {
hideRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
}
}
function showRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.remove('hidden');
}
}
function hideRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.add('hidden');
}
toggleRackImagesButton(hidden, target);
}
/**
* Add onClick callback for toggling rack elevation images. Synchronize the image toggle button
* text and display state of images with the local state.
* Change the visibility of all racks in response to selection.
*/
function handleRackViewSelect(
newView: RackViewSelection,
state: StateManager<RackViewState>,
): void {
state.set('view', newView);
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
setRackView(newView, elevation);
}
}
/**
* Add change callback for selecting rack elevation images, and set
* initial state of select and the images themselves
*/
export function initRackElevation(): void {
const initiallyHidden = rackImagesState.get('hidden');
for (const button of getElements<HTMLButtonElement>('button.toggle-images')) {
toggleRackImagesButton(initiallyHidden, button);
const initialView = rackImagesState.get('view');
button.addEventListener(
'click',
for (const control of getElements<HTMLSelectElement>('select.rack-view')) {
control.selectedIndex = [...control.options].findIndex(o => o.value == initialView);
control.addEventListener(
'change',
event => {
handleRackImageToggle(event.currentTarget as HTMLButtonElement, rackImagesState);
handleRackViewSelect((event.currentTarget as any).value as RackViewSelection, rackImagesState);
},
false,
);
}
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
element.addEventListener('load', () => {
if (initiallyHidden) {
hideRackImages();
} else if (!initiallyHidden) {
showRackImages();
}
setRackView(initialView, element);
});
}
}

View File

@@ -1,6 +1,8 @@
import { createState } from '../state';
export const rackImagesState = createState<{ hidden: boolean }>(
{ hidden: false },
export type RackViewSelection = 'images-and-labels' | 'images-only' | 'labels-only';
export const rackImagesState = createState<{ view: RackViewSelection }>(
{ view: 'images-and-labels' },
{ persist: true },
);

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

@@ -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');
/**
* 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");
})();
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');
}
})();
</script>
{# Static resources #}

View File

@@ -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>

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

@@ -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

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

@@ -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>

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

@@ -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>

View File

@@ -18,10 +18,6 @@
{% endblock %}
{% block extra_controls %}
<button class="btn btn-sm btn-outline-primary toggle-images" selected="selected">
<i class="mdi mdi-file-image-outline"></i>
Hide Images
</button>
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
</a>
@@ -210,22 +206,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
@@ -286,6 +267,13 @@
{% plugin_left_page object %}
</div>
<div class="col col-12 col-xl-7">
<div class="text-end mb-4">
<select class="btn btn-sm btn-outline-dark rack-view">
<option value="images-and-labels" selected="selected">Images and Labels</option>
<option value="images-only">Images only</option>
<option value="labels-only">Labels only</option>
</select>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">

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

@@ -7,9 +7,13 @@
{% block controls %}
<div class="controls">
<div class="control-group">
<button class="btn btn-sm btn-outline-dark toggle-images" selected="selected">
<span class="mdi mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Images
</button>
<div class="btn-group btn-group-sm" role="group">
<select class="btn btn-sm btn-outline-secondary rack-view">
<option value="images-and-labels" selected="selected">Images and Labels</option>
<option value="images-only">Images only</option>
<option value="labels-only">Labels only</option>
</select>
</div>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
@@ -30,7 +34,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>

View File

@@ -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>

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

@@ -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 %}

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

@@ -3,6 +3,10 @@
{% block title %}{{ report.name }}{% endblock %}
{% block object_identifier %}
{{ report.full_name }}
{% endblock %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>

View File

@@ -5,6 +5,10 @@
{% block title %}{{ script }}{% endblock %}
{% block object_identifier %}
{{ script.full_name }}
{% endblock %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>

View File

@@ -8,10 +8,18 @@
{% block header %}
{# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<div class="float-end">
<code class="text-muted">
{% block object_identifier %}
{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}
{% if object.slug %}({{ object.slug }}){% endif %}
{% endblock object_identifier %}
</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>
{% endblock %}
{% endblock breadcrumbs %}
</ol>
</nav>
{{ block.super }}

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

@@ -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 %}

View File

@@ -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>

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

@@ -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 %}

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

@@ -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 %}

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

@@ -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>

View File

@@ -13,7 +13,7 @@
<button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtualmachine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}

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

@@ -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,24 +55,31 @@ 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:
pk = self.base_columns.pop('pk', None)
actions = self.base_columns.pop('actions', None)
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
if selected_columns:
for name, column in self.base_columns.items():
if name in columns:
# Show only persistent or selected columns
for name, column in self.columns.items():
if name in ['pk', 'actions', *selected_columns]:
self.columns.show(name)
else:
self.columns.hide(name)
self.sequence = [c for c in columns if c in self.base_columns]
# Always include PK and actions column, if defined on the table
if pk:
self.base_columns['pk'] = pk
# 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]
]
# 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
@@ -111,6 +123,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 hasattr(obj, 'pk'))
return self._objects_count
#
# Table columns
@@ -157,6 +179,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.

View File

@@ -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

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,20 +8,23 @@ 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.0
mkdocs-material==7.3.2
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

View File

@@ -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)..."