mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-24 10:58:06 +01:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47abd62c55 | ||
|
|
db781437fc | ||
|
|
a40f52ee62 | ||
|
|
89e6fd68e5 | ||
|
|
ecf0f15c17 | ||
|
|
04a6e2de9d | ||
|
|
0cd29daea2 | ||
|
|
b392502b9b | ||
|
|
a301c974e4 | ||
|
|
4c7c2edf9a | ||
|
|
3d3748d6f5 | ||
|
|
fa3199d41c | ||
|
|
efbda6d5af | ||
|
|
8640f500d1 | ||
|
|
3d90e3aee9 | ||
|
|
6c676d21c3 | ||
|
|
7e6af88966 | ||
|
|
3b53cf5e84 | ||
|
|
713f02ca3f | ||
|
|
19844e81d1 | ||
|
|
5fbe766a0a | ||
|
|
1430c0a6e6 | ||
|
|
e3e928f1c4 | ||
|
|
e155acbbd4 | ||
|
|
be1b6b6aa3 | ||
|
|
b4ba5cbb7a | ||
|
|
f28474d86e | ||
|
|
1964073072 | ||
|
|
856d2e3176 | ||
|
|
3409a1bfba | ||
|
|
fc8f02c180 | ||
|
|
03e48161a1 | ||
|
|
def63329f0 | ||
|
|
0680b01a96 | ||
|
|
592e788a7d | ||
|
|
aabc1a8265 | ||
|
|
a23ff4e519 | ||
|
|
edc015d9bf | ||
|
|
d5a0e12283 | ||
|
|
90e8f26cd4 | ||
|
|
d4e83ca1c0 | ||
|
|
137aa9da2c | ||
|
|
87c600aa7c | ||
|
|
08dfe64301 | ||
|
|
0e48ee5f9e | ||
|
|
b6e532f01d | ||
|
|
60baa5e59e | ||
|
|
9eb64dc6a4 | ||
|
|
3de04094fb | ||
|
|
5e962719ca | ||
|
|
fefc623343 | ||
|
|
5c40081d84 | ||
|
|
e739d6aa05 | ||
|
|
0994719b91 | ||
|
|
f469920759 | ||
|
|
3c9be8cd08 | ||
|
|
a0e82e1817 | ||
|
|
69bf451b20 | ||
|
|
58699a220b | ||
|
|
3f70f685bb | ||
|
|
d838a76461 | ||
|
|
e13d96a6f2 | ||
|
|
1e1e2d5f54 | ||
|
|
c51d2a56ac | ||
|
|
e9d888bf63 | ||
|
|
47b7ec8d00 | ||
|
|
c8f09f28b1 | ||
|
|
5a32b9599a | ||
|
|
a6cb7965dc | ||
|
|
601cbd2306 | ||
|
|
a77658a6bf | ||
|
|
4a2d2882c6 | ||
|
|
0accaedad0 | ||
|
|
aa10430c7b | ||
|
|
98983e7e1a | ||
|
|
3441216aca | ||
|
|
d16a7e108c | ||
|
|
359ae5d116 | ||
|
|
a9a2509d39 | ||
|
|
e73c225965 | ||
|
|
39e6872288 | ||
|
|
af3c4905ea | ||
|
|
7873952e7a | ||
|
|
d989ce2b70 | ||
|
|
249948e174 | ||
|
|
8ae3331d04 | ||
|
|
b2e05aafc1 | ||
|
|
cc1a43e5d9 | ||
|
|
6f39e6599d | ||
|
|
1fe5857411 | ||
|
|
fce61295c9 | ||
|
|
396b0dace8 | ||
|
|
fe2e33a9e1 | ||
|
|
8d9d4cec05 | ||
|
|
ddd10ba8af | ||
|
|
e4f22bc494 | ||
|
|
09633ee11b | ||
|
|
dc6dbdf3c4 | ||
|
|
8f4197c020 | ||
|
|
5fe5fd71b5 |
3
.github/stale.yml
vendored
3
.github/stale.yml
vendored
@@ -1,8 +1,5 @@
|
||||
# Configuration for Stale (https://github.com/apps/stale)
|
||||
|
||||
# Pull requests are exempt from being marked as stale
|
||||
only: issues
|
||||
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 45
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
|
||||
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
Questions? Comments? Start by perusing our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) for the topic you have in mind.
|
||||
|
||||
### Build Status
|
||||
|
||||
|
||||
@@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
|
||||
When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable.
|
||||
|
||||
!!! warning
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
|
||||
@@ -66,7 +66,7 @@ class DeviceConnectionsReport(Report):
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
connected_ports += 1
|
||||
if not power_port.connection_status:
|
||||
if not power_port.path.is_active:
|
||||
self.log_warning(
|
||||
device,
|
||||
"Power connection for {} marked as planned".format(power_port.name)
|
||||
|
||||
@@ -56,7 +56,7 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
Default: 900
|
||||
|
||||
The number of seconds to cache entries will be retained before expiring.
|
||||
The number of seconds that cache entries will be retained before expiring.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
|
||||
|
||||
!!! note
|
||||
This parameter must always be defined as a list or tuple, even if only value is provided.
|
||||
This parameter must always be defined as a list or tuple, even if only a single value is provided.
|
||||
|
||||
The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
|
||||
|
||||
@@ -101,7 +101,7 @@ REDIS = {
|
||||
|
||||
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
|
||||
configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from
|
||||
above and the addition of two new keys.
|
||||
above and the addition of three new keys.
|
||||
|
||||
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
|
||||
of the Redis server and port for each sentinel instance to connect to
|
||||
|
||||
@@ -27,13 +27,13 @@ base_requirements.txt contrib docs mkdocs.yml NOTICE requ
|
||||
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
|
||||
```
|
||||
|
||||
The NetBox project utilizes three long-term branches:
|
||||
The NetBox project utilizes three persistent git branches to track work:
|
||||
|
||||
* `master` - Serves as a snapshot of the current stable release
|
||||
* `develop` - All development on the upcoming stable release occurs here
|
||||
* `develop-x.y` - Tracks work on an upcoming major release
|
||||
* `feature` - Tracks work on an upcoming major release
|
||||
|
||||
Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch.
|
||||
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
|
||||
|
||||
### Enable Pre-Commit Hooks
|
||||
|
||||
|
||||
@@ -52,10 +52,7 @@ Close the release milestone on GitHub after ensuring there are no remaining open
|
||||
|
||||
### Merge the Release Branch
|
||||
|
||||
Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases.
|
||||
|
||||
!!! warning
|
||||
No further releases for the current major version can be published once this pull request is merged.
|
||||
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ $ sudo -u postgres psql
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
postgres=# CREATE DATABASE netbox ENCODING 'UTF8' LC_COLLATE='C.UTF-8' LC_CTYPE='C.UTF-8';
|
||||
CREATE DATABASE
|
||||
postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
CREATE ROLE
|
||||
|
||||
@@ -7,12 +7,12 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
Begin by installing all system packages required by NetBox and its dependencies.
|
||||
|
||||
!!! note
|
||||
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
|
||||
NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
### CentOS
|
||||
@@ -83,7 +83,7 @@ Checking connectivity... done.
|
||||
```
|
||||
|
||||
!!! note
|
||||
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
|
||||
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release.
|
||||
|
||||
## Create the NetBox System User
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
|
||||
## Troubleshooting LDAP
|
||||
|
||||
`systemctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`:
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ 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.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10.5 (2021-02-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5315](https://github.com/netbox-community/netbox/issues/5315) - Fix site unassignment from VLAN when using "None" option
|
||||
* [#5626](https://github.com/netbox-community/netbox/issues/5626) - Fix REST API representation for circuit terminations connected to non-interface endpoints
|
||||
* [#5716](https://github.com/netbox-community/netbox/issues/5716) - Fix filtering rack reservations by custom field
|
||||
* [#5718](https://github.com/netbox-community/netbox/issues/5718) - Fix bulk editing of services when no port(s) are defined
|
||||
* [#5735](https://github.com/netbox-community/netbox/issues/5735) - Ensure consistent treatment of duplicate IP addresses
|
||||
* [#5738](https://github.com/netbox-community/netbox/issues/5738) - Fix redirect to device components view after disconnecting a cable
|
||||
* [#5753](https://github.com/netbox-community/netbox/issues/5753) - Fix Redis Sentinel password application for caching
|
||||
* [#5786](https://github.com/netbox-community/netbox/issues/5786) - Allow setting null tenant group on tenant via REST API
|
||||
* [#5841](https://github.com/netbox-community/netbox/issues/5841) - Disallow the creation of available prefixes/IP addresses in violation of assigned permission constraints
|
||||
|
||||
---
|
||||
|
||||
## v2.10.4 (2021-01-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5542](https://github.com/netbox-community/netbox/issues/5542) - Show cable trace lengths in both meters and feet
|
||||
* [#5570](https://github.com/netbox-community/netbox/issues/5570) - Add "management only" filter widget for interfaces list
|
||||
* [#5586](https://github.com/netbox-community/netbox/issues/5586) - Allow filtering virtual chassis by name and master
|
||||
* [#5612](https://github.com/netbox-community/netbox/issues/5612) - Add GG45 and TERA port types, and CAT7a and CAT8 cable types
|
||||
* [#5678](https://github.com/netbox-community/netbox/issues/5678) - Show available type choices for all device component import forms
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5232](https://github.com/netbox-community/netbox/issues/5232) - Correct swagger definition for ip_prefixes_available-ips_create API
|
||||
* [#5574](https://github.com/netbox-community/netbox/issues/5574) - Restrict the creation of device bay templates on non-parent device types
|
||||
* [#5584](https://github.com/netbox-community/netbox/issues/5584) - Restore power utilization panel under device view
|
||||
* [#5597](https://github.com/netbox-community/netbox/issues/5597) - Fix ordering devices by primary IP address
|
||||
* [#5603](https://github.com/netbox-community/netbox/issues/5603) - Fix display of white cables in trace view
|
||||
* [#5639](https://github.com/netbox-community/netbox/issues/5639) - Fix filtering connection lists by device name
|
||||
* [#5640](https://github.com/netbox-community/netbox/issues/5640) - Fix permissions assessment when adding VM interfaces in bulk
|
||||
* [#5648](https://github.com/netbox-community/netbox/issues/5648) - Include VC member interfaces on interfaces tab count when viewing VC master
|
||||
* [#5665](https://github.com/netbox-community/netbox/issues/5665) - Validate rack group is assigned to same site when creating a rack
|
||||
* [#5683](https://github.com/netbox-community/netbox/issues/5683) - Correct rack elevation displayed when viewing a reservation
|
||||
|
||||
---
|
||||
|
||||
## v2.10.3 (2021-01-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors
|
||||
* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters
|
||||
* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site
|
||||
* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar
|
||||
* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields
|
||||
* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned
|
||||
* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices
|
||||
* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis
|
||||
* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis
|
||||
* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list
|
||||
* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API
|
||||
* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields
|
||||
* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link
|
||||
* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw`
|
||||
* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field
|
||||
* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data
|
||||
|
||||
---
|
||||
|
||||
## v2.10.2 (2020-12-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -78,8 +78,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
|
||||
- `nisw` - negated case insensitive starts with
|
||||
- `iew` - case insensitive ends with
|
||||
- `niew` - negated case insensitive ends with
|
||||
- `ie` - case sensitive exact match
|
||||
- `nie` - negated case sensitive exact match
|
||||
- `ie` - case insensitive exact match
|
||||
- `nie` - negated case insensitive exact match
|
||||
|
||||
### Foreign Keys & Other Fields
|
||||
|
||||
|
||||
@@ -40,14 +40,16 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count']
|
||||
|
||||
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||
site = NestedSiteSerializer()
|
||||
connected_endpoint = NestedInterfaceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
|
||||
fields = [
|
||||
'id', 'url', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
|
||||
'connected_endpoint_type', 'connected_endpoint_reachable',
|
||||
]
|
||||
|
||||
|
||||
class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
|
||||
@@ -65,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
filterset_class = filters.CircuitTerminationFilterSet
|
||||
brief_prefetch_fields = ['circuit']
|
||||
|
||||
@@ -258,6 +258,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filters.DeviceTypeFilterSet
|
||||
brief_prefetch_fields = ['manufacturer']
|
||||
|
||||
|
||||
#
|
||||
@@ -493,6 +494,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filters.ConsolePortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
@@ -501,18 +503,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
filterset_class = filters.ConsoleServerPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
filterset_class = filters.PowerOutletFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
@@ -521,30 +526,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filterset_class = filters.InterfaceFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
|
||||
serializer_class = serializers.FrontPortSerializer
|
||||
filterset_class = filters.FrontPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
|
||||
serializer_class = serializers.RearPortSerializer
|
||||
filterset_class = filters.RearPortFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class DeviceBayViewSet(ModelViewSet):
|
||||
queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
filterset_class = filters.DeviceBayFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(ModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filters.InventoryItemFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
#
|
||||
@@ -600,6 +610,7 @@ class VirtualChassisViewSet(ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
filterset_class = filters.VirtualChassisFilterSet
|
||||
brief_prefetch_fields = ['master']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -873,6 +873,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_GG45 = 'gg45'
|
||||
TYPE_TERA4P = 'tera-4p'
|
||||
TYPE_TERA2P = 'tera-2p'
|
||||
TYPE_TERA1P = 'tera-1p'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
@@ -898,6 +902,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_GG45, 'GG45'),
|
||||
(TYPE_TERA4P, 'TERA 4P'),
|
||||
(TYPE_TERA2P, 'TERA 2P'),
|
||||
(TYPE_TERA1P, 'TERA 1P'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
@@ -936,6 +944,8 @@ class CableTypeChoices(ChoiceSet):
|
||||
TYPE_CAT6 = 'cat6'
|
||||
TYPE_CAT6A = 'cat6a'
|
||||
TYPE_CAT7 = 'cat7'
|
||||
TYPE_CAT7A = 'cat7a'
|
||||
TYPE_CAT8 = 'cat8'
|
||||
TYPE_DAC_ACTIVE = 'dac-active'
|
||||
TYPE_DAC_PASSIVE = 'dac-passive'
|
||||
TYPE_MRJ21_TRUNK = 'mrj21-trunk'
|
||||
@@ -960,6 +970,8 @@ class CableTypeChoices(ChoiceSet):
|
||||
(TYPE_CAT6, 'CAT6'),
|
||||
(TYPE_CAT6A, 'CAT6a'),
|
||||
(TYPE_CAT7, 'CAT7'),
|
||||
(TYPE_CAT7A, 'CAT7a'),
|
||||
(TYPE_CAT8, 'CAT8'),
|
||||
(TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
|
||||
(TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
|
||||
(TYPE_MRJ21_TRUNK, 'MRJ21 Trunk'),
|
||||
|
||||
@@ -264,7 +264,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
)
|
||||
|
||||
|
||||
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
|
||||
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@@ -1016,6 +1016,16 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
master_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Master (ID)',
|
||||
)
|
||||
master = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='master__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Master (name)',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='master__site__region',
|
||||
@@ -1055,7 +1065,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'domain']
|
||||
fields = ['id', 'domain', 'name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -1142,7 +1152,7 @@ class ConnectionFilterSet:
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(device_id__in=value)
|
||||
return queryset.filter(**{f'{name}__in': value})
|
||||
|
||||
|
||||
class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
|
||||
@@ -134,6 +134,7 @@ class ComponentForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
|
||||
if self.cleaned_data['label_pattern']:
|
||||
@@ -783,7 +784,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class RackReservationCSVForm(CSVModelForm):
|
||||
class RackReservationCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -833,7 +834,7 @@ class RackReservationCSVForm(CSVModelForm):
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RackReservation.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -1438,6 +1439,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
|
||||
self.fields['rear_port_set'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
|
||||
front_port_count = len(self.cleaned_data['name_pattern'])
|
||||
@@ -1781,9 +1783,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'group_id': '$rack_group',
|
||||
}
|
||||
)
|
||||
position = forms.TypedChoiceField(
|
||||
position = forms.IntegerField(
|
||||
required=False,
|
||||
empty_value=None,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||
@@ -1856,6 +1857,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
"config context",
|
||||
}
|
||||
widgets = {
|
||||
'face': StaticSelect2(),
|
||||
'status': StaticSelect2(),
|
||||
'primary_ip4': StaticSelect2(),
|
||||
'primary_ip6': StaticSelect2(),
|
||||
@@ -1902,6 +1904,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
|
||||
)
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
self.initial['site'] = self.instance.parent_bay.device.site_id
|
||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
||||
|
||||
else:
|
||||
|
||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||
@@ -1911,31 +1920,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
# Rack position
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
try:
|
||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.data['rack']) \
|
||||
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||
elif self.initial.get('rack') and str(self.initial.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack']) \
|
||||
.get_rack_units(face=self.initial.get('face'), exclude=pk)
|
||||
else:
|
||||
position_choices = []
|
||||
except Rack.DoesNotExist:
|
||||
position_choices = []
|
||||
self.fields['position'].choices = [('', '---------')] + [
|
||||
(p['id'], {
|
||||
'label': p['name'],
|
||||
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
|
||||
}) for p in position_choices
|
||||
]
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
self.initial['site'] = self.instance.parent_bay.device.site_id
|
||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
||||
position = self.data.get('position') or self.initial.get('position')
|
||||
if position:
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
|
||||
@@ -2365,6 +2352,11 @@ class ConsolePortCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False,
|
||||
help_text='Port type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
@@ -2438,6 +2430,11 @@ class ConsoleServerPortCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False,
|
||||
help_text='Port type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
@@ -2523,6 +2520,11 @@ class PowerPortCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
required=False,
|
||||
help_text='Port type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
@@ -2643,6 +2645,11 @@ class PowerOutletCSVForm(CSVModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
required=False,
|
||||
help_text='Outlet type'
|
||||
)
|
||||
power_port = CSVModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
required=False,
|
||||
@@ -2700,6 +2707,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC address'
|
||||
@@ -2944,6 +2957,7 @@ class InterfaceBulkEditForm(
|
||||
self.fields['lag'].widget.attrs['disabled'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
@@ -3092,6 +3106,7 @@ class FrontPortCreateForm(ComponentCreateForm):
|
||||
self.fields['rear_port_set'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
|
||||
front_port_count = len(self.cleaned_data['name_pattern'])
|
||||
@@ -3786,7 +3801,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class CableCSVForm(CSVModelForm):
|
||||
class CableCSVForm(CustomFieldModelCSVForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -3881,7 +3896,7 @@ class CableCSVForm(CSVModelForm):
|
||||
return length_unit if length_unit is not None else ''
|
||||
|
||||
|
||||
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Cable.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -3924,6 +3939,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate length/unit
|
||||
length = self.cleaned_data.get('length')
|
||||
@@ -4267,7 +4283,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
|
||||
return device
|
||||
|
||||
|
||||
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -4281,7 +4297,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
|
||||
nullable_fields = ['domain']
|
||||
|
||||
|
||||
class VirtualChassisCSVForm(CSVModelForm):
|
||||
class VirtualChassisCSVForm(CustomFieldModelCSVForm):
|
||||
master = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -4368,7 +4384,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class PowerPanelCSVForm(CSVModelForm):
|
||||
class PowerPanelCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -4394,7 +4410,7 @@ class PowerPanelCSVForm(CSVModelForm):
|
||||
self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
|
||||
|
||||
|
||||
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -4422,9 +4438,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
'rack_group',
|
||||
)
|
||||
nullable_fields = ['rack_group']
|
||||
|
||||
|
||||
class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
|
||||
@@ -164,6 +164,15 @@ class PowerPortTemplate(ComponentTemplateModel):
|
||||
allocated_draw=self.allocated_draw
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
raise ValidationError({
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
|
||||
class PowerOutletTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -193,6 +202,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device_type != self.device_type:
|
||||
@@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device_type != self.device_type:
|
||||
@@ -352,3 +363,9 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
name=self.name,
|
||||
label=self.label
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
|
||||
raise ValidationError(
|
||||
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
|
||||
)
|
||||
|
||||
@@ -316,6 +316,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
@@ -425,6 +426,7 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device != self.device:
|
||||
@@ -503,7 +505,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
)
|
||||
mgmt_only = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='OOB Management',
|
||||
verbose_name='Management only',
|
||||
help_text='This interface is used only for out-of-band management'
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
@@ -555,6 +557,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and (
|
||||
@@ -668,6 +671,7 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
@@ -711,6 +715,7 @@ class RearPort(CableTermination, ComponentModel):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that positions count is greater than or equal to the number of associated FrontPorts
|
||||
frontport_count = self.frontports.count()
|
||||
@@ -768,6 +773,7 @@ class DeviceBay(ComponentModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
|
||||
@@ -640,7 +640,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# Validate site/rack combination
|
||||
if self.rack and self.site != self.rack.site:
|
||||
raise ValidationError({
|
||||
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
|
||||
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
|
||||
})
|
||||
|
||||
if self.rack is None:
|
||||
@@ -650,7 +650,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
})
|
||||
if self.position:
|
||||
raise ValidationError({
|
||||
'face': "Cannot select a rack position without assigning a rack.",
|
||||
'position': "Cannot select a rack position without assigning a rack.",
|
||||
})
|
||||
|
||||
# Validate position/face combination
|
||||
@@ -662,7 +662,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# Prevent 0U devices from being assigned to a specific position
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
|
||||
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
|
||||
})
|
||||
|
||||
if self.rack:
|
||||
@@ -688,8 +688,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
)
|
||||
if self.position and self.position not in available_units:
|
||||
raise ValidationError({
|
||||
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
|
||||
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
|
||||
'position': f"U{self.position} is already occupied or does not have sufficient space to "
|
||||
f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
|
||||
})
|
||||
|
||||
except DeviceType.DoesNotExist:
|
||||
|
||||
@@ -109,6 +109,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Parent RackGroup (if any) must belong to the same Site
|
||||
if self.parent and self.parent.site != self.site:
|
||||
@@ -298,6 +299,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate group/site assignment
|
||||
if self.site and self.group and self.group.site != self.site:
|
||||
raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).")
|
||||
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
raise ValidationError("Must specify a unit when setting an outer width/depth")
|
||||
@@ -326,22 +331,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
'group': "Rack group must be from the same site, {}.".format(self.site)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Record the original site assignment for this rack.
|
||||
_site_id = None
|
||||
if self.pk:
|
||||
_site_id = Rack.objects.get(pk=self.pk).site_id
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update racked devices if the assigned Site has been changed.
|
||||
if _site_id is not None and self.site_id != _site_id:
|
||||
devices = Device.objects.filter(rack=self)
|
||||
for device in devices:
|
||||
device.site = self.site
|
||||
device.save()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.site.name,
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableStatusChoices
|
||||
from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
|
||||
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
|
||||
|
||||
|
||||
def create_cablepath(node):
|
||||
@@ -36,6 +36,43 @@ def rebuild_paths(obj):
|
||||
create_cablepath(cp.origin)
|
||||
|
||||
|
||||
#
|
||||
# Site/rack/device assignment
|
||||
#
|
||||
|
||||
@receiver(post_save, sender=RackGroup)
|
||||
def handle_rackgroup_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
|
||||
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
|
||||
"""
|
||||
if not created:
|
||||
for rackgroup in instance.get_children():
|
||||
rackgroup.site = instance.site
|
||||
rackgroup.save()
|
||||
for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
|
||||
rack.site = instance.site
|
||||
rack.save()
|
||||
for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
|
||||
powerpanel.site = instance.site
|
||||
powerpanel.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rack)
|
||||
def handle_rack_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child Devices if Site assignment has changed.
|
||||
"""
|
||||
if not created:
|
||||
for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
|
||||
device.site = instance.site
|
||||
device.save()
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@receiver(post_save, sender=VirtualChassis)
|
||||
def assign_virtualchassis_master(instance, created, **kwargs):
|
||||
"""
|
||||
@@ -60,6 +97,11 @@ def clear_virtualchassis_members(instance, **kwargs):
|
||||
device.save()
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
|
||||
@receiver(post_save, sender=Cable)
|
||||
def update_connected_endpoints(instance, created, raw=False, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -129,6 +129,7 @@ class DeviceTable(BaseTable):
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip6', 'primary_ip4'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
primary_ip4 = tables.Column(
|
||||
@@ -406,6 +407,7 @@ class BaseInterfaceTable(BaseTable):
|
||||
|
||||
|
||||
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
|
||||
mgmt_only = BooleanColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
|
||||
@@ -26,7 +26,8 @@ class RackGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
|
||||
@@ -19,7 +19,8 @@ class RegionTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
site_count = tables.Column(
|
||||
verbose_name='Sites'
|
||||
|
||||
@@ -57,13 +57,10 @@ INTERFACE_TAGGED_VLANS = """
|
||||
"""
|
||||
|
||||
MPTT_LINK = """
|
||||
{% if record.get_children %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
|
||||
{% else %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}9px">
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
</span>
|
||||
{% for i in record.get_ancestors %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
"""
|
||||
|
||||
POWERFEED_CABLE = """
|
||||
@@ -98,6 +95,11 @@ CONSOLEPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -118,6 +120,11 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -138,6 +145,11 @@ POWERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -157,6 +169,11 @@ POWEROUTLET_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -175,6 +192,11 @@ INTERFACE_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif record.is_connectable and perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -196,6 +218,11 @@ FRONTPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
@@ -219,6 +246,11 @@ REARPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
|
||||
@@ -740,7 +740,10 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
manufacturer=manufacturer,
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
|
||||
)
|
||||
|
||||
device_bay_templates = (
|
||||
|
||||
@@ -2399,9 +2399,9 @@ class VirtualChassisTestCase(TestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
virtual_chassis = (
|
||||
VirtualChassis(master=devices[0], domain='Domain 1'),
|
||||
VirtualChassis(master=devices[2], domain='Domain 2'),
|
||||
VirtualChassis(master=devices[4], domain='Domain 3'),
|
||||
VirtualChassis(name='VC 1', master=devices[0], domain='Domain 1'),
|
||||
VirtualChassis(name='VC 2', master=devices[2], domain='Domain 2'),
|
||||
VirtualChassis(name='VC 3', master=devices[4], domain='Domain 3'),
|
||||
)
|
||||
VirtualChassis.objects.bulk_create(virtual_chassis)
|
||||
|
||||
@@ -2417,6 +2417,17 @@ class VirtualChassisTestCase(TestCase):
|
||||
params = {'domain': ['Domain 1', 'Domain 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_master(self):
|
||||
masters = Device.objects.all()
|
||||
params = {'master_id': [masters[0].pk, masters[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'master': [masters[0].name, masters[2].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['VC 1', 'VC 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
|
||||
@@ -82,7 +82,7 @@ class DeviceTestCase(TestCase):
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertTrue(form.save())
|
||||
|
||||
def test_non_racked_device_with_face_position(self):
|
||||
def test_non_racked_device_with_face(self):
|
||||
form = DeviceForm(data={
|
||||
'name': 'New Device',
|
||||
'device_role': DeviceRole.objects.first().pk,
|
||||
@@ -92,12 +92,26 @@ class DeviceTestCase(TestCase):
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'face': DeviceFaceChoices.FACE_REAR,
|
||||
'position': 10,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('face', form.errors)
|
||||
|
||||
def test_non_racked_device_with_position(self):
|
||||
form = DeviceForm(data={
|
||||
'name': 'New Device',
|
||||
'device_role': DeviceRole.objects.first().pk,
|
||||
'tenant': None,
|
||||
'manufacturer': Manufacturer.objects.first().pk,
|
||||
'device_type': DeviceType.objects.first().pk,
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'position': 10,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,42 @@ from dcim.models import *
|
||||
from tenancy.models import Tenant
|
||||
|
||||
|
||||
class RackGroupTestCase(TestCase):
|
||||
|
||||
def test_change_rackgroup_site(self):
|
||||
"""
|
||||
Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
|
||||
Site A
|
||||
- RackGroup A1
|
||||
- RackGroup A2
|
||||
- Rack 2
|
||||
- Rack 1
|
||||
"""
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
|
||||
rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
|
||||
rackgroup_a1.save()
|
||||
rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
|
||||
rackgroup_a2.save()
|
||||
|
||||
rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
|
||||
rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
|
||||
|
||||
powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
|
||||
|
||||
# Move RackGroup A1 to Site B
|
||||
rackgroup_a1.site = site_b
|
||||
rackgroup_a1.save()
|
||||
|
||||
# Check that all objects within RackGroup A1 now belong to Site B
|
||||
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
|
||||
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
|
||||
self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
|
||||
self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
|
||||
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
|
||||
|
||||
|
||||
class RackTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@@ -154,6 +190,34 @@ class RackTestCase(TestCase):
|
||||
)
|
||||
self.assertTrue(pdu)
|
||||
|
||||
def test_change_rack_site(self):
|
||||
"""
|
||||
Check that child Devices get updated when a Rack is moved to a new Site.
|
||||
"""
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Device Role 1', slug='device-role-1', color='ff0000'
|
||||
)
|
||||
|
||||
# Create Rack1 in Site A
|
||||
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
|
||||
|
||||
# Create Device1 in Rack1
|
||||
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
|
||||
|
||||
# Move Rack1 to Site B
|
||||
rack1.site = site_b
|
||||
rack1.save()
|
||||
|
||||
# Check that Device1 is now assigned to Site B
|
||||
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -396,6 +396,7 @@ manufacturer: Generic
|
||||
model: TEST-1000
|
||||
slug: test-1000
|
||||
u_height: 2
|
||||
subdevice_role: parent
|
||||
comments: test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
@@ -831,8 +832,8 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ class ConfigContextQuerySetMixin:
|
||||
Provides a get_queryset() method which deals with adding the config context
|
||||
data annotation or not.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Build the proper queryset based on the request context
|
||||
@@ -49,11 +48,11 @@ class ConfigContextQuerySetMixin:
|
||||
|
||||
Else, return the queryset annotated with config context data
|
||||
"""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
|
||||
return self.queryset
|
||||
return self.queryset.annotate_config_context_data()
|
||||
if self.brief or 'config_context' in request.query_params.get('exclude', []):
|
||||
return queryset
|
||||
return queryset.annotate_config_context_data()
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor):
|
||||
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
|
||||
try:
|
||||
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
|
||||
except ValueError as e:
|
||||
except Exception as e:
|
||||
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
|
||||
raise e
|
||||
model.objects.filter(pk=cfv.obj_id).update(**cf_data)
|
||||
|
||||
@@ -47,6 +47,8 @@ class CustomFieldModel(models.Model):
|
||||
])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
|
||||
|
||||
# Validate all field values
|
||||
@@ -172,6 +174,8 @@ class CustomField(models.Model):
|
||||
obj.save()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the field's default value (if any)
|
||||
if self.default is not None:
|
||||
try:
|
||||
@@ -192,7 +196,8 @@ class CustomField(models.Model):
|
||||
})
|
||||
|
||||
# Regex validation can be set only for text fields
|
||||
if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT:
|
||||
regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL)
|
||||
if self.validation_regex and self.type not in regex_types:
|
||||
raise ValidationError({
|
||||
'validation_regex': "Regular expression validation is supported only for text and URL fields"
|
||||
})
|
||||
|
||||
@@ -117,11 +117,15 @@ class Webhook(models.Model):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# At least one action type must be selected
|
||||
if not self.type_create and not self.type_delete and not self.type_update:
|
||||
raise ValidationError(
|
||||
"You must select at least one type: create, update, and/or delete."
|
||||
)
|
||||
|
||||
# CA file path requires SSL verification enabled
|
||||
if not self.ssl_verification and self.ca_file_path:
|
||||
raise ValidationError({
|
||||
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
|
||||
@@ -436,6 +440,7 @@ class ConfigContext(ChangeLoggedModel):
|
||||
return reverse('extras:configcontext', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if type(self.data) is not dict:
|
||||
@@ -482,7 +487,6 @@ class ConfigContextModel(models.Model):
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
|
||||
@@ -89,6 +89,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
}
|
||||
base_query = Q(
|
||||
Q(platforms=OuterRef('platform')) | Q(platforms=None),
|
||||
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
|
||||
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
|
||||
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
|
||||
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
|
||||
Q(
|
||||
@@ -111,8 +113,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
|
||||
elif self.model._meta.model_name == 'virtualmachine':
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
|
||||
base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
|
||||
region_field = 'cluster__site__region'
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import random
|
||||
import secrets
|
||||
|
||||
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
|
||||
secure_random = random.SystemRandom()
|
||||
print(''.join(secure_random.sample(charset, 50)))
|
||||
print(''.join(secrets.choice(charset) for _ in range(50)))
|
||||
|
||||
@@ -25,8 +25,18 @@ from .nested_serializers import *
|
||||
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
|
||||
export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
|
||||
import_targets = SerializedPKRelatedField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
serializer=NestedRouteTargetSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
export_targets = SerializedPKRelatedField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
serializer=NestedRouteTargetSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
ipaddress_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@@ -162,7 +164,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
# Create the new Prefix(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -178,7 +185,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
|
||||
request_body=serializers.AvailableIPSerializer(many=False))
|
||||
request_body=serializers.AvailableIPSerializer(many=True))
|
||||
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def available_ips(self, request, pk=None):
|
||||
@@ -225,7 +232,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -734,13 +734,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
|
||||
self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
|
||||
) or (
|
||||
self.vrf and self.vrf.enforce_unique
|
||||
)):
|
||||
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
|
||||
duplicate_ips = self.get_duplicates()
|
||||
if duplicate_ips:
|
||||
if duplicate_ips and (
|
||||
self.role not in IPADDRESS_ROLES_NONUNIQUE or
|
||||
any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips)
|
||||
):
|
||||
raise ValidationError({
|
||||
'address': "Duplicate IP address found in {}: {}".format(
|
||||
"VRF {}".format(self.vrf) if self.vrf else "global table",
|
||||
|
||||
@@ -270,7 +270,7 @@ class PrefixTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
prefix = tables.TemplateColumn(
|
||||
template_code=PREFIX_LINK,
|
||||
attrs={'th': {'style': 'padding-left: 17px'}}
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
|
||||
@@ -259,6 +259,18 @@ class TestIPAddress(TestCase):
|
||||
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_nonrole_role(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_role_nonrole(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
|
||||
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_role(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
|
||||
|
||||
@@ -804,7 +804,7 @@ class ServiceListView(generic.ObjectListView):
|
||||
filterset = filters.ServiceFilterSet
|
||||
filterset_form = forms.ServiceFilterForm
|
||||
table = tables.ServiceTable
|
||||
action_buttons = ('export',)
|
||||
action_buttons = ('import', 'export')
|
||||
|
||||
|
||||
class ServiceView(generic.ObjectView):
|
||||
|
||||
@@ -9,11 +9,11 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import mixins, status
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
|
||||
from rq.worker import Worker
|
||||
|
||||
from netbox.api import BulkOperationSerializer
|
||||
@@ -120,17 +120,13 @@ class BulkDestroyModelMixin:
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ModelViewSet(mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
BulkUpdateModelMixin,
|
||||
BulkDestroyModelMixin,
|
||||
GenericViewSet):
|
||||
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
|
||||
"""
|
||||
Accept either a single object or a list of objects to create.
|
||||
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
||||
"""
|
||||
brief = False
|
||||
brief_prefetch_fields = []
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
@@ -142,22 +138,34 @@ class ModelViewSet(mixins.CreateModelMixin,
|
||||
def get_serializer_class(self):
|
||||
logger = logging.getLogger('netbox.api.views.ModelViewSet')
|
||||
|
||||
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
|
||||
# exists
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief'):
|
||||
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
|
||||
if self.brief:
|
||||
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
||||
try:
|
||||
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
|
||||
logger.debug(f"Using serializer {serializer}")
|
||||
return serializer
|
||||
except SerializerNotFound:
|
||||
pass
|
||||
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
|
||||
|
||||
# Fall back to the hard-coded serializer class
|
||||
logger.debug(f"Using serializer {self.serializer_class}")
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
def initialize_request(self, request, *args, **kwargs):
|
||||
# Check if brief=True has been passed
|
||||
if request.method == 'GET' and request.GET.get('brief'):
|
||||
self.brief = True
|
||||
|
||||
return super().initialize_request(request, *args, **kwargs)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.10.2'
|
||||
VERSION = '2.10.5'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -391,6 +391,7 @@ if CACHING_REDIS_USING_SENTINEL:
|
||||
'locations': CACHING_REDIS_SENTINELS,
|
||||
'service_name': CACHING_REDIS_SENTINEL_SERVICE,
|
||||
'db': CACHING_REDIS_DATABASE,
|
||||
'password': CACHING_REDIS_PASSWORD,
|
||||
}
|
||||
else:
|
||||
if CACHING_REDIS_SSL:
|
||||
|
||||
@@ -792,14 +792,14 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
# Normal fields
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
elif form.cleaned_data[name] not in (None, '', []):
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
|
||||
# Update custom fields
|
||||
for name in custom_fields:
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
obj.custom_field_data.pop(name, None)
|
||||
else:
|
||||
obj.custom_field_data[name] = None
|
||||
elif form.cleaned_data.get(name) not in (None, ''):
|
||||
obj.custom_field_data[name] = form.cleaned_data[name]
|
||||
|
||||
obj.full_clean()
|
||||
|
||||
@@ -14,21 +14,21 @@ body {
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
|
||||
margin: 0 auto -48px; /* the bottom margin is the negative value of the footer's height */
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
.navbar-brand {
|
||||
padding: 12px 15px 8px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 60px; /* .push must be the same height as .footer */
|
||||
height: 48px; /* .push must be the same height as .footer */
|
||||
}
|
||||
.footer {
|
||||
background-color: #f5f5f5;
|
||||
border-top: 1px solid #d0d0d0;
|
||||
}
|
||||
footer p {
|
||||
margin: 20px 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
#navbar_search {
|
||||
padding: 0 8px;
|
||||
@@ -177,6 +177,10 @@ nav ul.pagination {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.pagination > li > a > .mdi::before {
|
||||
top: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Devices */
|
||||
table.component-list td.subtable {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:object_list' %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
<h5>Total segments: {{ traced_path|length }}</h5>
|
||||
<h5>Total length:
|
||||
{% if total_length %}
|
||||
{{ total_length|floatformat:"-2" }} Meters
|
||||
{{ total_length|floatformat:"-2" }} Meters /
|
||||
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if power_ports and poweroutlets %}
|
||||
{% if object.powerports.exists and object.poweroutlets.exists %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Utilization</strong>
|
||||
@@ -217,10 +217,10 @@
|
||||
<th>Available</th>
|
||||
<th>Utilization</th>
|
||||
</tr>
|
||||
{% for pp in power_ports %}
|
||||
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
|
||||
{% for powerport in object.powerports.all %}
|
||||
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
|
||||
<tr>
|
||||
<td>{{ pp }}</td>
|
||||
<td>{{ powerport }}</td>
|
||||
<td>{{ utilization.outlet_count }}</td>
|
||||
<td>{{ utilization.allocated }}VA</td>
|
||||
{% if powerfeed.available_power %}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<li role="presentation" {% if active_tab == 'device' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device' pk=object.pk %}">Device</a>
|
||||
</li>
|
||||
{% with interface_count=object.interfaces.count %}
|
||||
{% with interface_count=object.vc_interfaces.count %}
|
||||
{% if interface_count %}
|
||||
<li role="presentation" {% if active_tab == 'interfaces' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<tr id="{{ iface.name }}">
|
||||
<td>{{ iface }}</td>
|
||||
{% if iface.connected_endpoint.device %}
|
||||
<td class="configured_device" data="{{ iface.connected_endpoint.device }}">
|
||||
<td class="configured_device" data="{{ iface.connected_endpoint.device }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
|
||||
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
|
||||
</td>
|
||||
<td class="configured_interface" data="{{ iface.connected_endpoint }}">
|
||||
@@ -61,6 +61,7 @@ $(document).ready(function() {
|
||||
|
||||
// Glean configured hostnames/interfaces from the DOM
|
||||
var configured_device = row.children('td.configured_device').attr('data');
|
||||
var configured_chassis = row.children('td.configured_device').attr('data-chassis');
|
||||
var configured_interface = row.children('td.configured_interface').attr('data');
|
||||
var configured_interface_short = null;
|
||||
if (configured_interface) {
|
||||
@@ -81,9 +82,9 @@ $(document).ready(function() {
|
||||
// Apply colors to rows
|
||||
if (!configured_device && lldp_device) {
|
||||
row.addClass('info');
|
||||
} else if (configured_device == lldp_device && configured_interface == lldp_interface) {
|
||||
} else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface == lldp_interface) {
|
||||
row.addClass('success');
|
||||
} else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
|
||||
} else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface_short == lldp_interface) {
|
||||
row.addClass('success');
|
||||
} else {
|
||||
row.addClass('danger');
|
||||
|
||||
@@ -9,8 +9,3 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ object.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
<td>Cable</td>
|
||||
<td>
|
||||
<a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<a href="{% url 'dcim:powerfeed_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.group %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.facility_id %}
|
||||
{% render_field form.group %}
|
||||
{% render_field form.status %}
|
||||
{% render_field form.role %}
|
||||
{% render_field form.serial %}
|
||||
|
||||
@@ -127,22 +127,20 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% with rack=object.rack %}
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
<div class="rack_header">
|
||||
<h4>Front</h4>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
<div class="rack_header">
|
||||
<h4>Rear</h4>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
<div class="rack_header">
|
||||
<h4>Front</h4>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
<div class="rack_header">
|
||||
<h4>Rear</h4>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="cable" style="border-left-color: #{{ cable.color|default:'606060' }}; {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
|
||||
<div class="cable" style="border-left-color: #{% if cable.color == 'ffffff' %}909090; border-left-style: double; border-left-width: 6px;{% else %}{{ cable.color|default:'606060' }};{% endif %} {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
|
||||
<strong>
|
||||
<a href="{% url 'dcim:cable' pk=cable.pk %}">
|
||||
{% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
|
||||
{% if perms.virtualization.add_vminterface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -24,7 +24,7 @@ class TenantGroupSerializer(ValidatedModelSerializer):
|
||||
|
||||
class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
|
||||
group = NestedTenantGroupSerializer(required=False)
|
||||
group = NestedTenantGroupSerializer(required=False, allow_null=True)
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
ipaddress_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -4,13 +4,10 @@ from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagCol
|
||||
from .models import Tenant, TenantGroup
|
||||
|
||||
MPTT_LINK = """
|
||||
{% if record.get_children %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
|
||||
{% else %}
|
||||
<span style="padding-left: {{ record.get_ancestors|length }}9px">
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
</span>
|
||||
{% for i in record.get_ancestors %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
||||
"""
|
||||
|
||||
COL_TENANT = """
|
||||
@@ -30,7 +27,8 @@ class TenantGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
tenant_count = LinkedCountColumn(
|
||||
viewname='tenancy:tenant_list',
|
||||
|
||||
@@ -56,6 +56,7 @@ class TenantTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Tenant
|
||||
brief_fields = ['id', 'name', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'group': None,
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,8 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
self.instance.actions.remove(action)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
object_types = self.cleaned_data.get('object_types')
|
||||
constraints = self.cleaned_data.get('constraints')
|
||||
|
||||
|
||||
@@ -28,29 +28,38 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
|
||||
serializer = super().get_request_serializer()
|
||||
|
||||
if serializer is not None and self.method in self.implicit_body_methods:
|
||||
properties = {}
|
||||
for child_name, child in serializer.fields.items():
|
||||
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
|
||||
properties[child_name] = None
|
||||
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
|
||||
properties[child_name] = None
|
||||
|
||||
if properties:
|
||||
if type(serializer) not in self.writable_serializers:
|
||||
writable_name = 'Writable' + type(serializer).__name__
|
||||
meta_class = getattr(type(serializer), 'Meta', None)
|
||||
if meta_class:
|
||||
ref_name = 'Writable' + get_serializer_ref_name(serializer)
|
||||
writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
|
||||
properties['Meta'] = writable_meta
|
||||
|
||||
self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
|
||||
|
||||
writable_class = self.writable_serializers[type(serializer)]
|
||||
serializer = writable_class()
|
||||
|
||||
writable_class = self.get_writable_class(serializer)
|
||||
if writable_class is not None:
|
||||
if hasattr(serializer, 'child'):
|
||||
child_serializer = self.get_writable_class(serializer.child)
|
||||
serializer = writable_class(child=child_serializer)
|
||||
else:
|
||||
serializer = writable_class()
|
||||
return serializer
|
||||
|
||||
def get_writable_class(self, serializer):
|
||||
properties = {}
|
||||
fields = {} if hasattr(serializer, 'child') else serializer.fields
|
||||
for child_name, child in fields.items():
|
||||
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
|
||||
properties[child_name] = None
|
||||
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
|
||||
properties[child_name] = None
|
||||
|
||||
if properties:
|
||||
if type(serializer) not in self.writable_serializers:
|
||||
writable_name = 'Writable' + type(serializer).__name__
|
||||
meta_class = getattr(type(serializer), 'Meta', None)
|
||||
if meta_class:
|
||||
ref_name = 'Writable' + get_serializer_ref_name(serializer)
|
||||
writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
|
||||
properties['Meta'] = writable_meta
|
||||
|
||||
self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
|
||||
|
||||
writable_class = self.writable_serializers[type(serializer)]
|
||||
return writable_class
|
||||
|
||||
|
||||
class SerializedPKRelatedFieldInspector(FieldInspector):
|
||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||
|
||||
@@ -5,6 +5,7 @@ from io import StringIO
|
||||
|
||||
import django_filters
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
|
||||
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.db.models import Count
|
||||
@@ -355,7 +356,15 @@ class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
|
||||
Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
|
||||
rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
|
||||
"""
|
||||
pass
|
||||
|
||||
def clean(self, value):
|
||||
"""
|
||||
When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
|
||||
string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
|
||||
"""
|
||||
if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
|
||||
return None
|
||||
return super().clean(value)
|
||||
|
||||
|
||||
class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
|
||||
|
||||
@@ -82,6 +82,7 @@ class BulkRenameForm(forms.Form):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate regular expression in "find" field
|
||||
if self.cleaned_data['use_regex']:
|
||||
@@ -124,6 +125,7 @@ class ImportForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
data = self.cleaned_data['data']
|
||||
format = self.cleaned_data['format']
|
||||
|
||||
@@ -114,7 +114,10 @@ class ContentTypeSelect(StaticSelect2):
|
||||
class NumericArrayField(SimpleArrayField):
|
||||
|
||||
def to_python(self, value):
|
||||
value = ','.join([str(n) for n in parse_numeric_range(value)])
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
value = ','.join([str(n) for n in parse_numeric_range(value)])
|
||||
return super().to_python(value)
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +220,14 @@ def as_range(n):
|
||||
return range(n)
|
||||
|
||||
|
||||
@register.filter()
|
||||
def meters_to_feet(n):
|
||||
"""
|
||||
Convert a length from meters to feet.
|
||||
"""
|
||||
return float(n) * 3.28084
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
@@ -84,3 +84,4 @@ class VMInterfaceViewSet(ModelViewSet):
|
||||
)
|
||||
serializer_class = serializers.VMInterfaceSerializer
|
||||
filterset_class = filters.VMInterfaceFilterSet
|
||||
brief_prefetch_fields = ['virtual_machine']
|
||||
|
||||
@@ -444,6 +444,7 @@ class VMInterface(BaseInterface):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
|
||||
|
||||
@@ -396,3 +396,6 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
|
||||
model_form = forms.VMInterfaceForm
|
||||
filterset = filters.VirtualMachineFilterSet
|
||||
table = tables.VirtualMachineTable
|
||||
|
||||
def get_required_permission(self):
|
||||
return f'virtualization.add_vminterface'
|
||||
|
||||
12
upgrade.sh
12
upgrade.sh
@@ -29,19 +29,25 @@ eval $COMMAND || {
|
||||
# Activate the virtual environment
|
||||
source "${VIRTUALENV}/bin/activate"
|
||||
|
||||
# Upgrade pip
|
||||
COMMAND="pip install --upgrade pip"
|
||||
echo "Updating pip ($COMMAND)..."
|
||||
eval $COMMAND || exit 1
|
||||
pip -V
|
||||
|
||||
# Install necessary system packages
|
||||
COMMAND="pip3 install wheel"
|
||||
COMMAND="pip install wheel"
|
||||
echo "Installing Python system packages ($COMMAND)..."
|
||||
eval $COMMAND || exit 1
|
||||
|
||||
# Install required Python packages
|
||||
COMMAND="pip3 install -r requirements.txt"
|
||||
COMMAND="pip install -r requirements.txt"
|
||||
echo "Installing core dependencies ($COMMAND)..."
|
||||
eval $COMMAND || exit 1
|
||||
|
||||
# Install optional packages (if any)
|
||||
if [ -s "local_requirements.txt" ]; then
|
||||
COMMAND="pip3 install -r local_requirements.txt"
|
||||
COMMAND="pip install -r local_requirements.txt"
|
||||
echo "Installing local dependencies ($COMMAND)..."
|
||||
eval $COMMAND || exit 1
|
||||
elif [ -f "local_requirements.txt" ]; then
|
||||
|
||||
Reference in New Issue
Block a user