Compare commits

...

67 Commits

Author SHA1 Message Date
Jeremy Stretch
47abd62c55 Merge pull request #5865 from netbox-community/develop
Release v2.10.5
2021-02-24 15:36:29 -05:00
Jeremy Stretch
db781437fc Release v2.10.5 2021-02-24 15:15:33 -05:00
Jeremy Stretch
a40f52ee62 Changelog for #5753 2021-02-24 15:02:13 -05:00
Jeremy Stretch
89e6fd68e5 Merge pull request #5753 from nerzhul/patch-1
fix: add missing password when using redis in sentinel mode
2021-02-24 15:01:03 -05:00
Jeremy Stretch
ecf0f15c17 Merge pull request #5763 from candlerb/candlerb/5760
Update docs to to create database explicitly with UTF8 encoding.
2021-02-24 14:57:50 -05:00
Jeremy Stretch
04a6e2de9d Changelog and test for #5786 2021-02-24 14:39:09 -05:00
Jeremy Stretch
0cd29daea2 Merge pull request #5787 from pgnuta/patch-1
Update /netbox/tenancy/api/serializers.py to allow nullable group
2021-02-24 14:29:11 -05:00
Jeremy Stretch
b392502b9b Fixes #5841: Disallow the creation of available prefixes/IP addresses in violation of assigned permission constraints 2021-02-24 14:21:42 -05:00
Jeremy Stretch
a301c974e4 Merge pull request #5851 from cpmills1975/5847-pagination-font-error
Fixes: #5847 - Provide custom CSS for pagination chevrons
2021-02-23 11:37:35 -05:00
Chris Mills
4c7c2edf9a Fixes: #5847 - Provide custom CSS for pagination chevrons 2021-02-22 23:28:30 +00:00
Daniel Sheppard
3d3748d6f5 Fixes: #5315 - Make "null_option" on DynamicModelChoiceField also null the value on the model. (#5704)
Fixes: #5315 - Fix site unassignment from VLAN when using "None" option
2021-02-12 10:53:40 -06:00
pgnuta
fa3199d41c Update serializers.py
Group should be nullable via API to match frontend functionality and Swagger documentation.
2021-02-11 11:49:57 +13:00
Jeremy Stretch
efbda6d5af Apply stale rules to PRs 2021-02-09 15:15:49 -05:00
Jeremy Stretch
8640f500d1 Closes #5776: Upgrade pip when running upgrade.sh 2021-02-09 13:32:29 -05:00
Jeremy Stretch
3d90e3aee9 Fixes #5626: Fix REST API representation for circuit terminations connected to non-interface endpoints 2021-02-08 16:44:04 -05:00
Jeremy Stretch
6c676d21c3 Changelog for #5735 2021-02-08 15:09:20 -05:00
Jeremy Stretch
7e6af88966 Merge pull request #5761 from candlerb/candlerb/5735
Fixes #5735: enforcement of duplicate IP address detection with roles
2021-02-08 15:07:53 -05:00
Brian Candler
3b53cf5e84 Update docs to to create database explicitly with UTF8 encoding.
Fixes #5760
2021-02-07 19:46:30 +00:00
root
713f02ca3f Fixes #5735: enforcement of duplicate IP address detection with roles 2021-02-07 10:31:56 +00:00
Jeremy Stretch
19844e81d1 Merge pull request #5754 from ypid/fix/branding
NetBox should always be referred to as NetBox
2021-02-04 16:01:58 -05:00
Robin Schneider
5fbe766a0a NetBox should always be referred to as NetBox
Fix all instances of "Netbox" except the one that is used as an example
how not to write it.

Ref: docs/development/style-guide.md
2021-02-04 21:39:55 +01:00
Jeremy Stretch
1430c0a6e6 Fixes #5738: Fix redirect to device components view after disconnecting a cable 2021-02-04 13:19:42 -05:00
Jeremy Stretch
e3e928f1c4 Fixes #5718: Fix bulk editing of services when no port(s) are defined 2021-02-04 13:01:55 -05:00
Jeremy Stretch
e155acbbd4 Merge pull request #5732 from candlerb/candlerb/sample-report-connection-status
Fix sample report in documentation
2021-02-04 11:47:18 -05:00
Loïc Blot
be1b6b6aa3 fix: add missing password when using redis in sentinel mode 2021-02-04 16:50:07 +01:00
Jeremy Stretch
b4ba5cbb7a Fixes #5716: Fix filtering rack reservations by custom field 2021-02-02 11:49:38 -05:00
Brian Candler
f28474d86e Fix sample report in documentation
Raised in #5729
2021-02-02 08:16:35 +00:00
Jeremy Stretch
1964073072 PRVB 2021-01-26 16:02:14 -05:00
Jeremy Stretch
856d2e3176 Merge pull request #5694 from netbox-community/develop
Release v2.10.4
2021-01-26 16:01:00 -05:00
Daniel Sheppard
3409a1bfba Merge remote-tracking branch 'origin/develop' into develop 2021-01-26 14:04:22 -06:00
Daniel Sheppard
fc8f02c180 Corrects error with ListSerializer as request_body 2021-01-26 14:03:46 -06:00
Jeremy Stretch
03e48161a1 Release v2.10.4 2021-01-26 13:06:29 -05:00
Jeremy Stretch
def63329f0 Merge pull request #5693 from aaroneg/patch-1
update python package name
2021-01-26 13:03:11 -05:00
Aaron
0680b01a96 update python package name
At least on ubuntu 20.04, the python3 package is now 3.8, but the package 'python3' points to the current best version of python available without needing to specialize a minor version and should require fewer changes to the document.
2021-01-26 11:47:33 -06:00
Daniel Sheppard
592e788a7d Merge branch 'develop' of https://github.com/netbox-community/netbox into develop 2021-01-26 10:42:45 -06:00
Daniel Sheppard
aabc1a8265 Fixes: #5232 - Corrects swagger definition 2021-01-26 10:42:01 -06:00
Daniel Sheppard
a23ff4e519 Fixes: #5232 - Corrects swagger definition 2021-01-26 10:34:07 -06:00
Jeremy Stretch
edc015d9bf Emphasize use of GitHub discussions in README 2021-01-26 11:20:06 -05:00
Jeremy Stretch
d5a0e12283 Certain component types are optional 2021-01-26 10:35:03 -05:00
Jeremy Stretch
90e8f26cd4 Closes #5678: Show available type choices for all device component import forms 2021-01-26 10:17:58 -05:00
Jeremy Stretch
d4e83ca1c0 Fixes #5683: Correct rack elevation displayed when viewing a reservation 2021-01-26 09:57:33 -05:00
Jeremy Stretch
137aa9da2c Fixes #5648: Include VC member interfaces on interfaces tab count when viewing VC master 2021-01-25 14:29:03 -05:00
Jeremy Stretch
87c600aa7c Fixes #5665: Validate rack group is assigned to same site when creating a rack 2021-01-25 14:19:32 -05:00
Jeremy Stretch
08dfe64301 Merge pull request #5677 from Alef-Burzmali/develop
Fix how SECRET_KEY is generated
2021-01-25 13:55:45 -05:00
Jeremy Stretch
0e48ee5f9e Merge pull request #5675 from rileyL6122428/patch-1
Fixes small typos in Configuration > Required Settings and Configuration > Optional Settings
2021-01-24 18:07:41 -05:00
Thomas Fargeix
b6e532f01d Fix how SECRET_KEY is generated
Use secrets.choice instead of random.sample to generate the secret key.
2021-01-24 21:20:55 +01:00
Riley Littlefield
60baa5e59e Fixes small typo in optional settings 2021-01-23 14:06:48 -05:00
Riley Littlefield
9eb64dc6a4 Fixes another typo 2021-01-23 13:54:44 -05:00
Riley Littlefield
3de04094fb Fixes typo 2021-01-23 13:41:48 -05:00
Jeremy Stretch
5e962719ca Closes #5542: Show cable trace lengths in both meters and feet 2021-01-20 21:29:23 -05:00
Jeremy Stretch
fefc623343 Changelog for #5603 2021-01-20 20:48:24 -05:00
Jeremy Stretch
5c40081d84 Merge pull request #5662 from cpmills1975/5603-white-cable-fix
Fix white cables
2021-01-20 20:47:28 -05:00
Chris Mills
e739d6aa05 Fix white cables 2021-01-20 23:52:54 +00:00
Jeremy Stretch
0994719b91 Add NetBox installation video to docs 2021-01-20 15:36:04 -05:00
Jeremy Stretch
f469920759 Fixes #5640: Fix permissions assessment when adding VM interfaces in bulk 2021-01-20 15:18:13 -05:00
Jeremy Stretch
3c9be8cd08 Fixes #5639: Fix filtering connection lists by device name 2021-01-19 11:24:34 -05:00
Jeremy Stretch
a0e82e1817 Fixes #5574: Restrict the creation of device bay templates on non-parent device types 2021-01-19 10:49:56 -05:00
Jeremy Stretch
69bf451b20 Changelog for #5586 and #5612 2021-01-19 09:41:49 -05:00
Jeremy Stretch
58699a220b Merge pull request #5596 from FragmentedPacket/5586-vc-filtering
Closes 5586: Adds name, master, and master_id filtering
2021-01-19 09:31:24 -05:00
Jeremy Stretch
3f70f685bb Merge pull request #5634 from phoerious/5612-gg45-tera-connector
Add choices for GG45 and TERA connectors and Cat7a/Cat8 cables
2021-01-19 09:28:33 -05:00
Janek Bevendorff
d838a76461 Add choices for GG45 and TERA connectors and Cat7a/Cat8 cables
Fixes #5612
2021-01-18 14:13:07 +01:00
Jeremy Stretch
e13d96a6f2 Don't pin Ubuntu installations to Python 3.6 2021-01-17 14:08:59 -05:00
Jeremy Stretch
1e1e2d5f54 Fixes #5597: Fix ordering devices by primary IP address 2021-01-11 11:28:03 -05:00
Mikhail Yohman
c51d2a56ac Closes 5586: Adds name, master, and master_id filtering 2021-01-08 20:18:48 -07:00
Jeremy Stretch
e9d888bf63 Closes #5570: Add "management only" filter widget for interfaces list 2021-01-07 11:29:59 -05:00
Jeremy Stretch
47b7ec8d00 Fixes #5584: Restore power utilization panel under device view 2021-01-07 11:19:11 -05:00
Jeremy Stretch
c8f09f28b1 PRVB 2021-01-05 21:10:58 -05:00
44 changed files with 307 additions and 94 deletions

3
.github/stale.yml vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,46 @@
# 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

View File

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

View File

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

View File

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

View File

@@ -2352,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
@@ -2425,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
@@ -2510,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
@@ -2630,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,
@@ -2687,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'

View File

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

View File

@@ -299,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")

View File

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

View File

@@ -95,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>
@@ -115,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>
@@ -135,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>
@@ -154,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>
@@ -172,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>
@@ -193,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>
@@ -216,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.10.3'
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:

View File

@@ -792,7 +792,7 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ class TenantTest(APIViewTestCases.APIViewTestCase):
model = Tenant
brief_fields = ['id', 'name', 'slug', 'url']
bulk_update_data = {
'group': None,
'description': 'New description',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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