mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-07 04:27:27 -06:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce99929e2 | ||
|
|
22c482bdc3 | ||
|
|
c358097d52 | ||
|
|
26e37c1da6 | ||
|
|
fd564f09d1 | ||
|
|
76c2fd3414 | ||
|
|
712e850951 | ||
|
|
24cedab04b | ||
|
|
4262e2ef09 | ||
|
|
2dd494bc42 | ||
|
|
8faf586e14 | ||
|
|
08975b5ef9 | ||
|
|
9f363f493b | ||
|
|
2972993a84 | ||
|
|
9e1edd55d6 | ||
|
|
61ce8d1cb0 | ||
|
|
e2718973ce | ||
|
|
b081864e66 | ||
|
|
a912d6ed1e | ||
|
|
e45ebdffb1 | ||
|
|
5734c5e093 | ||
|
|
cb570790e6 | ||
|
|
bb4f21d5ee | ||
|
|
a262a8320b | ||
|
|
d39cda2e45 | ||
|
|
b69d2f1367 | ||
|
|
3fd3c7a383 | ||
|
|
8c4add38f4 | ||
|
|
d28cece264 | ||
|
|
a12d94a3bc | ||
|
|
9f4c1e64ce | ||
|
|
86956c8fc3 | ||
|
|
0991a8edaa | ||
|
|
f1e82a3647 | ||
|
|
357bf671ad | ||
|
|
183d475dc8 | ||
|
|
136d3118d2 | ||
|
|
2f5e623284 | ||
|
|
a7829a2deb | ||
|
|
9d243103f4 | ||
|
|
1f9a440598 | ||
|
|
1d0b27c99e | ||
|
|
48576919b2 | ||
|
|
0174983208 | ||
|
|
a7776d2f53 | ||
|
|
85254eb8b5 | ||
|
|
9078cb29cc | ||
|
|
0fd3c83861 | ||
|
|
087ad30d3c | ||
|
|
9c1dd159de | ||
|
|
bc7535c4d2 | ||
|
|
df20abf283 | ||
|
|
96c539c0ee | ||
|
|
ba8b99d3b8 | ||
|
|
cac48924ae | ||
|
|
7788bf3ce3 | ||
|
|
fa9ffb23ad | ||
|
|
a260019a7f | ||
|
|
683ba5eed3 | ||
|
|
d70140f148 | ||
|
|
fec3ee6f08 | ||
|
|
5700ade1a1 | ||
|
|
f807d3a024 | ||
|
|
20ee8ec107 | ||
|
|
e67f08c745 | ||
|
|
95462ce0ec |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,5 @@
|
|||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
|
||||||
|
*.min.* binary
|
||||||
|
*.map binary
|
||||||
|
*.pack.js binary
|
||||||
|
|||||||
BIN
.github/images/netbox_triage_bug.png
vendored
Normal file
BIN
.github/images/netbox_triage_bug.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
.github/images/netbox_triage_feature.png
vendored
Normal file
BIN
.github/images/netbox_triage_feature.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
.github/images/netbox_triage_initial.png
vendored
Normal file
BIN
.github/images/netbox_triage_initial.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
8
.github/stale.yml
vendored
8
.github/stale.yml
vendored
@@ -4,19 +4,19 @@
|
|||||||
only: issues
|
only: issues
|
||||||
|
|
||||||
# Number of days of inactivity before an issue becomes stale
|
# Number of days of inactivity before an issue becomes stale
|
||||||
daysUntilStale: 14
|
daysUntilStale: 45
|
||||||
|
|
||||||
# Number of days of inactivity before a stale issue is closed
|
# Number of days of inactivity before a stale issue is closed
|
||||||
daysUntilClose: 7
|
daysUntilClose: 15
|
||||||
|
|
||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- "status: accepted"
|
- "status: accepted"
|
||||||
- "status: gathering feedback"
|
|
||||||
- "status: blocked"
|
- "status: blocked"
|
||||||
|
- "status: needs milestone"
|
||||||
|
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: "pending closure"
|
||||||
|
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ help prevent wasting time on something that might we might not be able to
|
|||||||
implement. When suggesting a new feature, also make sure it won't conflict with
|
implement. When suggesting a new feature, also make sure it won't conflict with
|
||||||
any work that's already in progress.
|
any work that's already in progress.
|
||||||
|
|
||||||
|
* Once you've opened or identified an issue you'd like to work on, ask that it
|
||||||
|
be assigned to you so that others are aware it's being worked on. A maintainer
|
||||||
|
will then mark the issue as "accepted."
|
||||||
|
|
||||||
* Any pull request which does _not_ relate to an accepted issue will be closed.
|
* Any pull request which does _not_ relate to an accepted issue will be closed.
|
||||||
|
|
||||||
* All major new functionality must include relevant tests where applicable.
|
* All major new functionality must include relevant tests where applicable.
|
||||||
@@ -132,18 +136,17 @@ accumulating a large backlog of work.
|
|||||||
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
||||||
to aid in issue management.
|
to aid in issue management.
|
||||||
|
|
||||||
* Issues will be marked as stale after 14 days of no activity.
|
* Issues will be marked as stale after 45 days of no activity.
|
||||||
* Then after 7 more days of inactivity, the issue will be closed.
|
* Then after 15 more days of inactivity, the issue will be closed.
|
||||||
* Any issue bearing one of the following labels will be exempt from all Stale
|
* Any issue bearing one of the following labels will be exempt from all Stale
|
||||||
bot actions:
|
bot actions:
|
||||||
* `status: accepted`
|
* `status: accepted`
|
||||||
* `status: gathering feedback`
|
|
||||||
* `status: blocked`
|
* `status: blocked`
|
||||||
|
* `status: needs milestone`
|
||||||
|
|
||||||
It is natural that some new issues get more attention than others. Often this
|
It is natural that some new issues get more attention than others. Stale bot
|
||||||
is a metric of an issues's overall value to the project. In other cases in
|
helps bring renewed attention to potentially valuable issues that may have been
|
||||||
which issues merely get lost in the shuffle, notifications from Stale bot can
|
overlooked.
|
||||||
bring renewed attention to potentially meaningful issues.
|
|
||||||
|
|
||||||
## Maintainer Guidance
|
## Maintainer Guidance
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,10 @@ http://localhost:8000/api/ipam/prefixes/ | jq ".actions.POST.status.choices"
|
|||||||
|
|
||||||
For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar".
|
For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar".
|
||||||
|
|
||||||
|
### Excluding Config Contexts
|
||||||
|
|
||||||
|
The rendered config context for devices and VMs is included by default in all API results (list and detail views). Users with large amounts of context data will most likely observe a performance drop when returning multiple objects, particularly with page sizes in the high hundreds or more. To combat this, in cases where the rendered config context is not needed, the query parameter `?exclude=config_context` may be appended to the request URL to exclude the config context data from the API response.
|
||||||
|
|
||||||
### Custom Fields
|
### Custom Fields
|
||||||
|
|
||||||
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
|
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for
|
|||||||
|
|
||||||
### Manually Perform a New Install
|
### Manually Perform a New Install
|
||||||
|
|
||||||
Create a new installation of NetBox by following [the current documentation](http://netbox.readthedocs.io/en/latest/). This should be a manual process, so that issues with the documentation can be identified and corrected.
|
Install `mkdocs` in your local environment, then start the documentation server:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
$ pip install -r docs/requirements.txt
|
||||||
|
$ mkdocs serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow these instructions to perform a new installation of NetBox. This process must _not_ be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||||
|
|
||||||
### Close the Release Milestone
|
### Close the Release Milestone
|
||||||
|
|
||||||
|
|||||||
@@ -74,12 +74,18 @@ Checking connectivity... done.
|
|||||||
|
|
||||||
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
|
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
|
||||||
|
|
||||||
!!! note
|
#### Ubuntu
|
||||||
CentOS users may need to create the `netbox` group first.
|
|
||||||
|
```
|
||||||
|
# adduser --system --group netbox
|
||||||
|
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CentOS
|
||||||
|
|
||||||
```
|
```
|
||||||
# groupadd --system netbox
|
# groupadd --system netbox
|
||||||
# adduser --system --gid netbox netbox
|
# adduser --system -g netbox netbox
|
||||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ Copy the 'configuration.py' you created when first installing to the new version
|
|||||||
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Copy your local requirements file if used:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
# cp netbox-X.Y.Z/local_requirements.txt netbox/local_requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
Also copy the LDAP configuration if using LDAP:
|
Also copy the LDAP configuration if using LDAP:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A power feed identifies the power outlet/drop that goes to a rack and is terminated to a power panel. Power feeds have a supply type (AC/DC), voltage, amperage, and phase type (single/three).
|
A power feed identifies the power outlet/drop that goes to a rack and is terminated to a power panel. Power feeds have a supply type (AC/DC), voltage, amperage, and phase type (single/three).
|
||||||
|
|
||||||
Power feeds are optionally assigned to a rack. In addition, a power port – and only one – can connect to a power feed; in the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to.
|
Power feeds are optionally assigned to a rack. In addition, a power port may be connected to a power feed. In the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
The power usage of a rack is calculated when a power feed (or multiple) is assigned to that rack and connected to a power port.
|
The power usage of a rack is calculated when a power feed (or multiple) is assigned to that rack and connected to a power port.
|
||||||
|
|||||||
3
docs/models/extras/imageattachment.md
Normal file
3
docs/models/extras/imageattachment.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Image Attachments
|
||||||
|
|
||||||
|
Certain objects in NetBox support the attachment of uploaded images. These will be saved to the NetBox server and made available whenever the object is viewed.
|
||||||
@@ -110,6 +110,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
|||||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||||
|
|
||||||
|
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||||
|
|
||||||
### Install the Plugin for Development
|
### Install the Plugin for Development
|
||||||
|
|
||||||
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
|
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
# NetBox v2.8
|
# NetBox v2.8
|
||||||
|
|
||||||
|
## v2.8.9 (2020-08-04)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#4898](https://github.com/netbox-community/netbox/issues/4898) - Add MAC address search field to interfaces list
|
||||||
|
* [#4899](https://github.com/netbox-community/netbox/issues/4899) - Add MAC address column to interfaces table
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#4455](https://github.com/netbox-community/netbox/issues/4455) - Fix ordering of prefixes beneath aggregate when available space is hidden
|
||||||
|
* [#4875](https://github.com/netbox-community/netbox/issues/4875) - Fix documentation for image attachments
|
||||||
|
* [#4876](https://github.com/netbox-community/netbox/issues/4876) - Fix labels for sites in staging or decommissioning status
|
||||||
|
* [#4880](https://github.com/netbox-community/netbox/issues/4880) - Fix removal of tagged VLANs if not assigned in bulk interface editing
|
||||||
|
* [#4887](https://github.com/netbox-community/netbox/issues/4887) - Don't disable NAPALM tabs when device has no primary IP
|
||||||
|
* [#4894](https://github.com/netbox-community/netbox/issues/4894) - Fix display of device/VM counts on platforms list
|
||||||
|
* [#4895](https://github.com/netbox-community/netbox/issues/4895) - Force UTF-8 encoding when embedding model documentation
|
||||||
|
* [#4910](https://github.com/netbox-community/netbox/issues/4910) - Unpin redis dependency to fix exception in RQ worker
|
||||||
|
* [#4926](https://github.com/netbox-community/netbox/issues/4926) - Fix ordering of VM interfaces in REST API endpoint
|
||||||
|
* [#4927](https://github.com/netbox-community/netbox/issues/4927) - Fix validation error when updating an existing secret
|
||||||
|
* [#4929](https://github.com/netbox-community/netbox/issues/4929) - Correct log message when creating a new object
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.8.8 (2020-07-21)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors
|
||||||
|
* [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types
|
||||||
|
* [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set
|
||||||
|
* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint
|
||||||
|
* [#4595](https://github.com/netbox-community/netbox/issues/4595) - Ensure consistent display of non-racked and child devices on rack view
|
||||||
|
* [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string
|
||||||
|
* [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs
|
||||||
|
* [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields
|
||||||
|
* [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices
|
||||||
|
* [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations
|
||||||
|
* [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination
|
||||||
|
* [#4872](https://github.com/netbox-community/netbox/issues/4872) - Enable filtering virtual machine interfaces by tag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.8.7 (2020-07-02)
|
## v2.8.7 (2020-07-02)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import socket
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -371,15 +372,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
Execute a NAPALM method on a Device
|
Execute a NAPALM method on a Device
|
||||||
"""
|
"""
|
||||||
device = get_object_or_404(Device, pk=pk)
|
device = get_object_or_404(Device, pk=pk)
|
||||||
if not device.primary_ip:
|
|
||||||
raise ServiceUnavailable("This device does not have a primary IP address configured.")
|
|
||||||
if device.platform is None:
|
if device.platform is None:
|
||||||
raise ServiceUnavailable("No platform is configured for this device.")
|
raise ServiceUnavailable("No platform is configured for this device.")
|
||||||
if not device.platform.napalm_driver:
|
if not device.platform.napalm_driver:
|
||||||
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format(
|
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
|
||||||
device.platform
|
device.platform
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Check for primary IP address from NetBox object
|
||||||
|
if device.primary_ip:
|
||||||
|
host = str(device.primary_ip.address.ip)
|
||||||
|
else:
|
||||||
|
# Raise exception for no IP address and no Name if device.name does not exist
|
||||||
|
if not device.name:
|
||||||
|
raise ServiceUnavailable(
|
||||||
|
"This device does not have a primary IP address or device name to lookup configured.")
|
||||||
|
try:
|
||||||
|
# Attempt to complete a DNS name resolution if no primary_ip is set
|
||||||
|
host = socket.gethostbyname(device.name)
|
||||||
|
except socket.gaierror:
|
||||||
|
# Name lookup failure
|
||||||
|
raise ServiceUnavailable(
|
||||||
|
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
|
||||||
|
|
||||||
# Check that NAPALM is installed
|
# Check that NAPALM is installed
|
||||||
try:
|
try:
|
||||||
import napalm
|
import napalm
|
||||||
@@ -399,10 +414,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
if not request.user.has_perm('dcim.napalm_read'):
|
if not request.user.has_perm('dcim.napalm_read'):
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
# Connect to the device
|
|
||||||
napalm_methods = request.GET.getlist('method')
|
napalm_methods = request.GET.getlist('method')
|
||||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||||
ip_address = str(device.primary_ip.address.ip)
|
|
||||||
username = settings.NAPALM_USERNAME
|
username = settings.NAPALM_USERNAME
|
||||||
password = settings.NAPALM_PASSWORD
|
password = settings.NAPALM_PASSWORD
|
||||||
optional_args = settings.NAPALM_ARGS.copy()
|
optional_args = settings.NAPALM_ARGS.copy()
|
||||||
@@ -422,8 +435,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
elif key:
|
elif key:
|
||||||
optional_args[key.lower()] = request.headers[header]
|
optional_args[key.lower()] = request.headers[header]
|
||||||
|
|
||||||
|
# Connect to the device
|
||||||
d = driver(
|
d = driver(
|
||||||
hostname=ip_address,
|
hostname=host,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
timeout=settings.NAPALM_TIMEOUT,
|
timeout=settings.NAPALM_TIMEOUT,
|
||||||
@@ -432,7 +446,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
try:
|
try:
|
||||||
d.open()
|
d.open()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e))
|
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e))
|
||||||
|
|
||||||
# Validate and execute each specified NAPALM method
|
# Validate and execute each specified NAPALM method
|
||||||
for method in napalm_methods:
|
for method in napalm_methods:
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet
|
|||||||
|
|
||||||
class SiteStatusChoices(ChoiceSet):
|
class SiteStatusChoices(ChoiceSet):
|
||||||
|
|
||||||
STATUS_ACTIVE = 'active'
|
|
||||||
STATUS_PLANNED = 'planned'
|
STATUS_PLANNED = 'planned'
|
||||||
|
STATUS_STAGING = 'staging'
|
||||||
|
STATUS_ACTIVE = 'active'
|
||||||
|
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||||
STATUS_RETIRED = 'retired'
|
STATUS_RETIRED = 'retired'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(STATUS_ACTIVE, 'Active'),
|
|
||||||
(STATUS_PLANNED, 'Planned'),
|
(STATUS_PLANNED, 'Planned'),
|
||||||
|
(STATUS_STAGING, 'Staging'),
|
||||||
|
(STATUS_ACTIVE, 'Active'),
|
||||||
|
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||||
(STATUS_RETIRED, 'Retired'),
|
(STATUS_RETIRED, 'Retired'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -275,6 +279,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_1430P = 'nema-14-30p'
|
TYPE_NEMA_1430P = 'nema-14-30p'
|
||||||
TYPE_NEMA_1450P = 'nema-14-50p'
|
TYPE_NEMA_1450P = 'nema-14-50p'
|
||||||
TYPE_NEMA_1460P = 'nema-14-60p'
|
TYPE_NEMA_1460P = 'nema-14-60p'
|
||||||
|
TYPE_NEMA_1515P = 'nema-15-15p'
|
||||||
|
TYPE_NEMA_1520P = 'nema-15-20p'
|
||||||
|
TYPE_NEMA_1530P = 'nema-15-30p'
|
||||||
|
TYPE_NEMA_1550P = 'nema-15-50p'
|
||||||
|
TYPE_NEMA_1560P = 'nema-15-60p'
|
||||||
# NEMA locking
|
# NEMA locking
|
||||||
TYPE_NEMA_L115P = 'nema-l1-15p'
|
TYPE_NEMA_L115P = 'nema-l1-15p'
|
||||||
TYPE_NEMA_L515P = 'nema-l5-15p'
|
TYPE_NEMA_L515P = 'nema-l5-15p'
|
||||||
@@ -290,6 +299,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_L1430P = 'nema-l14-30p'
|
TYPE_NEMA_L1430P = 'nema-l14-30p'
|
||||||
TYPE_NEMA_L1450P = 'nema-l14-50p'
|
TYPE_NEMA_L1450P = 'nema-l14-50p'
|
||||||
TYPE_NEMA_L1460P = 'nema-l14-60p'
|
TYPE_NEMA_L1460P = 'nema-l14-60p'
|
||||||
|
TYPE_NEMA_L1520P = 'nema-l15-20p'
|
||||||
|
TYPE_NEMA_L1530P = 'nema-l15-30p'
|
||||||
|
TYPE_NEMA_L1550P = 'nema-l15-50p'
|
||||||
|
TYPE_NEMA_L1560P = 'nema-l15-60p'
|
||||||
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
||||||
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
||||||
# California style
|
# California style
|
||||||
@@ -351,6 +364,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
|
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
|
||||||
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
|
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
|
||||||
(TYPE_NEMA_1460P, 'NEMA 14-60P'),
|
(TYPE_NEMA_1460P, 'NEMA 14-60P'),
|
||||||
|
(TYPE_NEMA_1515P, 'NEMA 15-15P'),
|
||||||
|
(TYPE_NEMA_1520P, 'NEMA 15-20P'),
|
||||||
|
(TYPE_NEMA_1530P, 'NEMA 15-30P'),
|
||||||
|
(TYPE_NEMA_1550P, 'NEMA 15-50P'),
|
||||||
|
(TYPE_NEMA_1560P, 'NEMA 15-60P'),
|
||||||
)),
|
)),
|
||||||
('NEMA (Locking)', (
|
('NEMA (Locking)', (
|
||||||
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
|
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
|
||||||
@@ -367,6 +385,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
|
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
|
||||||
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
|
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
|
||||||
(TYPE_NEMA_L1460P, 'NEMA L14-60P'),
|
(TYPE_NEMA_L1460P, 'NEMA L14-60P'),
|
||||||
|
(TYPE_NEMA_L1520P, 'NEMA L15-20P'),
|
||||||
|
(TYPE_NEMA_L1530P, 'NEMA L15-30P'),
|
||||||
|
(TYPE_NEMA_L1550P, 'NEMA L15-50P'),
|
||||||
|
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
|
||||||
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
||||||
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
||||||
)),
|
)),
|
||||||
@@ -436,6 +458,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_1430R = 'nema-14-30r'
|
TYPE_NEMA_1430R = 'nema-14-30r'
|
||||||
TYPE_NEMA_1450R = 'nema-14-50r'
|
TYPE_NEMA_1450R = 'nema-14-50r'
|
||||||
TYPE_NEMA_1460R = 'nema-14-60r'
|
TYPE_NEMA_1460R = 'nema-14-60r'
|
||||||
|
TYPE_NEMA_1515R = 'nema-15-15r'
|
||||||
|
TYPE_NEMA_1520R = 'nema-15-20r'
|
||||||
|
TYPE_NEMA_1530R = 'nema-15-30r'
|
||||||
|
TYPE_NEMA_1550R = 'nema-15-50r'
|
||||||
|
TYPE_NEMA_1560R = 'nema-15-60r'
|
||||||
# NEMA locking
|
# NEMA locking
|
||||||
TYPE_NEMA_L115R = 'nema-l1-15r'
|
TYPE_NEMA_L115R = 'nema-l1-15r'
|
||||||
TYPE_NEMA_L515R = 'nema-l5-15r'
|
TYPE_NEMA_L515R = 'nema-l5-15r'
|
||||||
@@ -451,6 +478,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_L1430R = 'nema-l14-30r'
|
TYPE_NEMA_L1430R = 'nema-l14-30r'
|
||||||
TYPE_NEMA_L1450R = 'nema-l14-50r'
|
TYPE_NEMA_L1450R = 'nema-l14-50r'
|
||||||
TYPE_NEMA_L1460R = 'nema-l14-60r'
|
TYPE_NEMA_L1460R = 'nema-l14-60r'
|
||||||
|
TYPE_NEMA_L1520R = 'nema-l15-20r'
|
||||||
|
TYPE_NEMA_L1530R = 'nema-l15-30r'
|
||||||
|
TYPE_NEMA_L1550R = 'nema-l15-50r'
|
||||||
|
TYPE_NEMA_L1560R = 'nema-l15-60r'
|
||||||
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
||||||
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
||||||
# California style
|
# California style
|
||||||
@@ -513,6 +544,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
|
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
|
||||||
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
|
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
|
||||||
(TYPE_NEMA_1460R, 'NEMA 14-60R'),
|
(TYPE_NEMA_1460R, 'NEMA 14-60R'),
|
||||||
|
(TYPE_NEMA_1515R, 'NEMA 15-15R'),
|
||||||
|
(TYPE_NEMA_1520R, 'NEMA 15-20R'),
|
||||||
|
(TYPE_NEMA_1530R, 'NEMA 15-30R'),
|
||||||
|
(TYPE_NEMA_1550R, 'NEMA 15-50R'),
|
||||||
|
(TYPE_NEMA_1560R, 'NEMA 15-60R'),
|
||||||
)),
|
)),
|
||||||
('NEMA (Locking)', (
|
('NEMA (Locking)', (
|
||||||
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
|
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
|
||||||
@@ -529,6 +565,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
|
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
|
||||||
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
|
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
|
||||||
(TYPE_NEMA_L1460R, 'NEMA L14-60R'),
|
(TYPE_NEMA_L1460R, 'NEMA L14-60R'),
|
||||||
|
(TYPE_NEMA_L1520R, 'NEMA L15-20R'),
|
||||||
|
(TYPE_NEMA_L1530R, 'NEMA L15-30R'),
|
||||||
|
(TYPE_NEMA_L1550R, 'NEMA L15-50R'),
|
||||||
|
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
|
||||||
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
||||||
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -2671,6 +2671,10 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
mac_address = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label='MAC address'
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -254,8 +254,10 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
STATUS_CLASS_MAP = {
|
STATUS_CLASS_MAP = {
|
||||||
SiteStatusChoices.STATUS_ACTIVE: 'success',
|
|
||||||
SiteStatusChoices.STATUS_PLANNED: 'info',
|
SiteStatusChoices.STATUS_PLANNED: 'info',
|
||||||
|
SiteStatusChoices.STATUS_STAGING: 'primary',
|
||||||
|
SiteStatusChoices.STATUS_ACTIVE: 'success',
|
||||||
|
SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||||
SiteStatusChoices.STATUS_RETIRED: 'danger',
|
SiteStatusChoices.STATUS_RETIRED: 'danger',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +789,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if power_stats:
|
if power_stats:
|
||||||
allocated_draw_total = sum(x['allocated_draw_total'] for x in power_stats)
|
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
|
||||||
available_power_total = sum(x['available_power'] for x in power_stats)
|
available_power_total = sum(x['available_power'] for x in power_stats)
|
||||||
return int(allocated_draw_total / available_power_total * 100) or 0
|
return int(allocated_draw_total / available_power_total * 100) or 0
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ MANUFACTURER_ACTIONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
DEVICEROLE_DEVICE_COUNT = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEVICEROLE_VM_COUNT = """
|
||||||
|
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
DEVICEROLE_ACTIONS = """
|
DEVICEROLE_ACTIONS = """
|
||||||
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||||
<i class="fa fa-history"></i>
|
<i class="fa fa-history"></i>
|
||||||
@@ -103,20 +111,12 @@ DEVICEROLE_ACTIONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEVICEROLE_DEVICE_COUNT = """
|
|
||||||
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEVICEROLE_VM_COUNT = """
|
|
||||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLATFORM_DEVICE_COUNT = """
|
PLATFORM_DEVICE_COUNT = """
|
||||||
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLATFORM_VM_COUNT = """
|
PLATFORM_VM_COUNT = """
|
||||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLATFORM_ACTIONS = """
|
PLATFORM_ACTIONS = """
|
||||||
@@ -278,6 +278,7 @@ class RackGroupTable(BaseTable):
|
|||||||
|
|
||||||
class RackRoleTable(BaseTable):
|
class RackRoleTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
|
name = tables.Column(linkify=True)
|
||||||
rack_count = tables.Column(verbose_name='Racks')
|
rack_count = tables.Column(verbose_name='Racks')
|
||||||
color = tables.TemplateColumn(COLOR_LABEL)
|
color = tables.TemplateColumn(COLOR_LABEL)
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@@ -705,20 +706,17 @@ class DeviceRoleTable(BaseTable):
|
|||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
device_count = tables.TemplateColumn(
|
device_count = tables.TemplateColumn(
|
||||||
template_code=DEVICEROLE_DEVICE_COUNT,
|
template_code=DEVICEROLE_DEVICE_COUNT,
|
||||||
accessor=Accessor('devices.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
vm_count = tables.TemplateColumn(
|
vm_count = tables.TemplateColumn(
|
||||||
template_code=DEVICEROLE_VM_COUNT,
|
template_code=DEVICEROLE_VM_COUNT,
|
||||||
accessor=Accessor('virtual_machines.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='VMs'
|
verbose_name='VMs'
|
||||||
)
|
)
|
||||||
color = tables.TemplateColumn(
|
color = tables.TemplateColumn(
|
||||||
template_code=COLOR_LABEL,
|
template_code=COLOR_LABEL,
|
||||||
verbose_name='Label'
|
verbose_name='Label'
|
||||||
)
|
)
|
||||||
|
vm_role = BooleanColumn()
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
template_code=DEVICEROLE_ACTIONS,
|
template_code=DEVICEROLE_ACTIONS,
|
||||||
attrs={'td': {'class': 'text-right noprint'}},
|
attrs={'td': {'class': 'text-right noprint'}},
|
||||||
@@ -739,14 +737,10 @@ class PlatformTable(BaseTable):
|
|||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
device_count = tables.TemplateColumn(
|
device_count = tables.TemplateColumn(
|
||||||
template_code=PLATFORM_DEVICE_COUNT,
|
template_code=PLATFORM_DEVICE_COUNT,
|
||||||
accessor=Accessor('devices.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
vm_count = tables.TemplateColumn(
|
vm_count = tables.TemplateColumn(
|
||||||
template_code=PLATFORM_VM_COUNT,
|
template_code=PLATFORM_VM_COUNT,
|
||||||
accessor=Accessor('virtual_machines.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='VMs'
|
verbose_name='VMs'
|
||||||
)
|
)
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@@ -964,8 +958,8 @@ class InterfaceDetailTable(DeviceComponentDetailTable):
|
|||||||
|
|
||||||
class Meta(InterfaceTable.Meta):
|
class Meta(InterfaceTable.Meta):
|
||||||
order_by = ('parent', 'name')
|
order_by = ('parent', 'name')
|
||||||
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'mac_address', 'description', 'cable')
|
||||||
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
default_columns = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTable(BaseTable):
|
class FrontPortTable(BaseTable):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from ipam.models import Prefix, VLAN
|
|||||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.paginator import EnhancedPaginator
|
from utilities.paginator import EnhancedPaginator
|
||||||
from utilities.utils import csv_format
|
from utilities.utils import csv_format, get_subquery
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
|
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
|
||||||
ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
@@ -399,11 +399,12 @@ class RackView(PermissionRequiredMixin, View):
|
|||||||
|
|
||||||
rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
|
rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
|
||||||
|
|
||||||
|
# Get 0U and child devices located within the rack
|
||||||
nonracked_devices = Device.objects.filter(
|
nonracked_devices = Device.objects.filter(
|
||||||
rack=rack,
|
rack=rack,
|
||||||
position__isnull=True,
|
position__isnull=True
|
||||||
parent_bay__isnull=True
|
|
||||||
).prefetch_related('device_type__manufacturer')
|
).prefetch_related('device_type__manufacturer')
|
||||||
|
|
||||||
if rack.group:
|
if rack.group:
|
||||||
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
||||||
else:
|
else:
|
||||||
@@ -557,9 +558,9 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
|
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'dcim.view_manufacturer'
|
permission_required = 'dcim.view_manufacturer'
|
||||||
queryset = Manufacturer.objects.annotate(
|
queryset = Manufacturer.objects.annotate(
|
||||||
devicetype_count=Count('device_types', distinct=True),
|
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||||
inventoryitem_count=Count('inventory_items', distinct=True),
|
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||||
platform_count=Count('platforms', distinct=True),
|
platform_count=get_subquery(Platform, 'manufacturer')
|
||||||
)
|
)
|
||||||
table = tables.ManufacturerTable
|
table = tables.ManufacturerTable
|
||||||
|
|
||||||
@@ -1020,7 +1021,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
|
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'dcim.view_devicerole'
|
permission_required = 'dcim.view_devicerole'
|
||||||
queryset = DeviceRole.objects.all()
|
queryset = DeviceRole.objects.annotate(
|
||||||
|
device_count=get_subquery(Device, 'device_role'),
|
||||||
|
vm_count=get_subquery(VirtualMachine, 'role')
|
||||||
|
)
|
||||||
table = tables.DeviceRoleTable
|
table = tables.DeviceRoleTable
|
||||||
|
|
||||||
|
|
||||||
@@ -1055,7 +1059,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class PlatformListView(PermissionRequiredMixin, ObjectListView):
|
class PlatformListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'dcim.view_platform'
|
permission_required = 'dcim.view_platform'
|
||||||
queryset = Platform.objects.all()
|
queryset = Platform.objects.annotate(
|
||||||
|
device_count=get_subquery(Device, 'platform'),
|
||||||
|
vm_count=get_subquery(VirtualMachine, 'platform')
|
||||||
|
)
|
||||||
table = tables.PlatformTable
|
table = tables.PlatformTable
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from extras.models import (
|
|||||||
from extras.reports import get_report, get_reports
|
from extras.reports import get_report, get_reports
|
||||||
from extras.scripts import get_script, get_scripts, run_script
|
from extras.scripts import get_script, get_scripts, run_script
|
||||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||||
|
from utilities.metadata import ContentTypeMetadata
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ class CustomFieldModelViewSet(ModelViewSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class GraphViewSet(ModelViewSet):
|
class GraphViewSet(ModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = Graph.objects.all()
|
queryset = Graph.objects.all()
|
||||||
serializer_class = serializers.GraphSerializer
|
serializer_class = serializers.GraphSerializer
|
||||||
filterset_class = filters.GraphFilterSet
|
filterset_class = filters.GraphFilterSet
|
||||||
@@ -98,6 +100,7 @@ class GraphViewSet(ModelViewSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ExportTemplateViewSet(ModelViewSet):
|
class ExportTemplateViewSet(ModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.all()
|
||||||
serializer_class = serializers.ExportTemplateSerializer
|
serializer_class = serializers.ExportTemplateSerializer
|
||||||
filterset_class = filters.ExportTemplateFilterSet
|
filterset_class = filters.ExportTemplateFilterSet
|
||||||
@@ -120,6 +123,7 @@ class TagViewSet(ModelViewSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ImageAttachmentViewSet(ModelViewSet):
|
class ImageAttachmentViewSet(ModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ImageAttachment.objects.all()
|
queryset = ImageAttachment.objects.all()
|
||||||
serializer_class = serializers.ImageAttachmentSerializer
|
serializer_class = serializers.ImageAttachmentSerializer
|
||||||
|
|
||||||
@@ -271,6 +275,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Retrieve a list of recent changes.
|
Retrieve a list of recent changes.
|
||||||
"""
|
"""
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ObjectChange.objects.prefetch_related('user')
|
queryset = ObjectChange.objects.prefetch_related('user')
|
||||||
serializer_class = serializers.ObjectChangeSerializer
|
serializer_class = serializers.ObjectChangeSerializer
|
||||||
filterset_class = filters.ObjectChangeFilterSet
|
filterset_class = filters.ObjectChangeFilterSet
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ from django.apps import AppConfig
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
|
||||||
from extras.registry import registry
|
from extras.registry import registry
|
||||||
from utilities.choices import ButtonColorChoices
|
from utilities.choices import ButtonColorChoices
|
||||||
|
|
||||||
|
from extras.plugins.utils import import_object
|
||||||
|
|
||||||
|
|
||||||
# Initialize plugin registry stores
|
# Initialize plugin registry stores
|
||||||
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
||||||
@@ -60,18 +61,14 @@ class PluginConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
|
|
||||||
# Register template content
|
# Register template content
|
||||||
try:
|
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
|
||||||
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
|
if template_extensions is not None:
|
||||||
register_template_extensions(template_extensions)
|
register_template_extensions(template_extensions)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register navigation menu items (if defined)
|
# Register navigation menu items (if defined)
|
||||||
try:
|
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
|
||||||
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
|
if menu_items is not None:
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
register_menu_items(self.verbose_name, menu_items)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, user_config):
|
def validate(cls, user_config):
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from django.conf import settings
|
|||||||
from django.conf.urls import include
|
from django.conf.urls import include
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
from extras.plugins.utils import import_object
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
@@ -24,19 +25,15 @@ for plugin_path in settings.PLUGINS:
|
|||||||
base_url = getattr(app, 'base_url') or app.label
|
base_url = getattr(app, 'base_url') or app.label
|
||||||
|
|
||||||
# Check if the plugin specifies any base URLs
|
# Check if the plugin specifies any base URLs
|
||||||
try:
|
urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns")
|
||||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
if urlpatterns is not None:
|
||||||
plugin_patterns.append(
|
plugin_patterns.append(
|
||||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
path(f"{base_url}/", include((urlpatterns, app.label)))
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if the plugin specifies any API URLs
|
# Check if the plugin specifies any API URLs
|
||||||
try:
|
urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns")
|
||||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
if urlpatterns is not None:
|
||||||
plugin_api_patterns.append(
|
plugin_api_patterns.append(
|
||||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|||||||
33
netbox/extras/plugins/utils.py
Normal file
33
netbox/extras/plugins/utils.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def import_object(module_and_object):
|
||||||
|
"""
|
||||||
|
Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object".
|
||||||
|
|
||||||
|
Returns the imported object, or None if it doesn't exist.
|
||||||
|
"""
|
||||||
|
target_module_name, object_name = module_and_object.rsplit('.', 1)
|
||||||
|
module_hierarchy = target_module_name.split('.')
|
||||||
|
|
||||||
|
# Iterate through the module hierarchy, checking for the existence of each successive submodule.
|
||||||
|
# We have to do this rather than jumping directly to calling find_spec(target_module_name)
|
||||||
|
# because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist.
|
||||||
|
module_name = ""
|
||||||
|
for module_component in module_hierarchy:
|
||||||
|
module_name = f"{module_name}.{module_component}" if module_name else module_component
|
||||||
|
spec = importlib.util.find_spec(module_name)
|
||||||
|
if spec is None:
|
||||||
|
# No such module
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Okay, target_module_name exists. Load it if not already loaded
|
||||||
|
if target_module_name in sys.modules:
|
||||||
|
module = sys.modules[target_module_name]
|
||||||
|
else:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[target_module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
return getattr(module, object_name, None)
|
||||||
@@ -4,13 +4,14 @@ from django.apps import apps
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls.exceptions import NoReverseMatch
|
from django.urls.exceptions import NoReverseMatch
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from extras.plugins.utils import import_object
|
||||||
|
|
||||||
|
|
||||||
class InstalledPluginsAdminView(View):
|
class InstalledPluginsAdminView(View):
|
||||||
"""
|
"""
|
||||||
@@ -60,9 +61,9 @@ class PluginsAPIRootView(APIView):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_plugin_entry(plugin, app_config, request, format):
|
def _get_plugin_entry(plugin, app_config, request, format):
|
||||||
try:
|
# Check if the plugin specifies any API URLs
|
||||||
api_app_name = import_string(f"{plugin}.api.urls.app_name")
|
api_app_name = import_object(f"{plugin}.api.urls.app_name")
|
||||||
except (ImportError, ModuleNotFoundError):
|
if api_app_name is None:
|
||||||
# Plugin does not expose an API
|
# Plugin does not expose an API
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ class PluginsAPIRootView(APIView):
|
|||||||
format=format
|
format=format
|
||||||
))
|
))
|
||||||
except NoReverseMatch:
|
except NoReverseMatch:
|
||||||
# The plugin does not include an api-root
|
# The plugin does not include an api-root url
|
||||||
entry = None
|
entry = None
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class NestedAggregateSerializer(WritableNestedSerializer):
|
class NestedAggregateSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||||
|
family = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Aggregate
|
model = models.Aggregate
|
||||||
@@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class NestedPrefixSerializer(WritableNestedSerializer):
|
class NestedPrefixSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||||
|
family = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Prefix
|
model = models.Prefix
|
||||||
@@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class NestedIPAddressSerializer(WritableNestedSerializer):
|
class NestedIPAddressSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||||
|
family = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.IPAddress
|
model = models.IPAddress
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
|||||||
serializer_class = serializers.PrefixSerializer
|
serializer_class = serializers.PrefixSerializer
|
||||||
filterset_class = filters.PrefixFilterSet
|
filterset_class = filters.PrefixFilterSet
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "available_prefixes" and self.request.method == "POST":
|
||||||
|
return serializers.PrefixLengthSerializer
|
||||||
|
return super().get_serializer_class()
|
||||||
|
|
||||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
|
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
|
||||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||||
|
|||||||
@@ -1068,7 +1068,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
|||||||
)
|
)
|
||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
widget=APISelect(
|
||||||
|
filter_for={
|
||||||
|
'group': 'site_id'
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ UTILIZATION_GRAPH = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ROLE_PREFIX_COUNT = """
|
ROLE_PREFIX_COUNT = """
|
||||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ROLE_VLAN_COUNT = """
|
ROLE_VLAN_COUNT = """
|
||||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ROLE_ACTIONS = """
|
ROLE_ACTIONS = """
|
||||||
@@ -319,15 +319,11 @@ class AggregateDetailTable(AggregateTable):
|
|||||||
class RoleTable(BaseTable):
|
class RoleTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
prefix_count = tables.TemplateColumn(
|
prefix_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('prefixes.count'),
|
|
||||||
template_code=ROLE_PREFIX_COUNT,
|
template_code=ROLE_PREFIX_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='Prefixes'
|
verbose_name='Prefixes'
|
||||||
)
|
)
|
||||||
vlan_count = tables.TemplateColumn(
|
vlan_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('vlans.count'),
|
|
||||||
template_code=ROLE_VLAN_COUNT,
|
template_code=ROLE_VLAN_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='VLANs'
|
verbose_name='VLANs'
|
||||||
)
|
)
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@@ -524,7 +520,7 @@ class InterfaceIPAddressTable(BaseTable):
|
|||||||
|
|
||||||
class VLANGroupTable(BaseTable):
|
class VLANGroupTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn()
|
name = tables.Column(linkify=True)
|
||||||
site = tables.LinkColumn(
|
site = tables.LinkColumn(
|
||||||
viewname='dcim:site',
|
viewname='dcim:site',
|
||||||
args=[Accessor('site.slug')]
|
args=[Accessor('site.slug')]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django_tables2 import RequestConfig
|
|||||||
|
|
||||||
from dcim.models import Device, Interface
|
from dcim.models import Device, Interface
|
||||||
from utilities.paginator import EnhancedPaginator
|
from utilities.paginator import EnhancedPaginator
|
||||||
|
from utilities.utils import get_subquery
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
)
|
)
|
||||||
@@ -326,6 +327,8 @@ class AggregateView(PermissionRequiredMixin, View):
|
|||||||
prefix__net_contained_or_equal=str(aggregate.prefix)
|
prefix__net_contained_or_equal=str(aggregate.prefix)
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'site', 'role'
|
'site', 'role'
|
||||||
|
).order_by(
|
||||||
|
'prefix'
|
||||||
).annotate_depth(
|
).annotate_depth(
|
||||||
limit=0
|
limit=0
|
||||||
)
|
)
|
||||||
@@ -407,7 +410,10 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class RoleListView(PermissionRequiredMixin, ObjectListView):
|
class RoleListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'ipam.view_role'
|
permission_required = 'ipam.view_role'
|
||||||
queryset = Role.objects.all()
|
queryset = Role.objects.annotate(
|
||||||
|
prefix_count=get_subquery(Prefix, 'role'),
|
||||||
|
vlan_count=get_subquery(VLAN, 'role')
|
||||||
|
)
|
||||||
table = tables.RoleTable
|
table = tables.RoleTable
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '2.8.7'
|
VERSION = '2.8.9'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
netbox/project-static/jquery/jquery-3.5.1.min.js
vendored
Normal file
2
netbox/project-static/jquery/jquery-3.5.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -120,7 +120,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
|||||||
device=self.cleaned_data['device'],
|
device=self.cleaned_data['device'],
|
||||||
role=self.cleaned_data['role'],
|
role=self.cleaned_data['role'],
|
||||||
name=self.cleaned_data['name']
|
name=self.cleaned_data['name']
|
||||||
).exists():
|
).exclude(pk=self.instance.pk).exists():
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
"Each secret assigned to a device must have a unique combination of role and name"
|
"Each secret assigned to a device must have a unique combination of role and name"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,8 +80,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="{% static 'jquery/jquery-3.4.1.min.js' %}"
|
<script src="{% static 'jquery/jquery-3.5.1.min.js' %}"
|
||||||
onerror="window.location='{% url 'media_failure' %}?filename=jquery/jquery-3.4.1.min.js'"></script>
|
onerror="window.location='{% url 'media_failure' %}?filename=jquery/jquery-3.5.1.min.js'"></script>
|
||||||
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"
|
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"
|
||||||
onerror="window.location='{% url 'media_failure' %}?filename=jquery-ui-1.12.1/jquery-ui.min.js'"></script>
|
onerror="window.location='{% url 'media_failure' %}?filename=jquery-ui-1.12.1/jquery-ui.min.js'"></script>
|
||||||
<script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"
|
<script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"
|
||||||
|
|||||||
@@ -51,10 +51,15 @@
|
|||||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if termination.connected_endpoint %}
|
{% with peer=termination.get_cable_peer %}
|
||||||
to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
|
to
|
||||||
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
|
{% if peer.device %}
|
||||||
|
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
|
||||||
|
{% elif peer.circuit %}
|
||||||
|
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
({{ peer }})
|
||||||
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if perms.dcim.add_cable %}
|
{% if perms.dcim.add_cable %}
|
||||||
<div class="pull-right">
|
<div class="pull-right">
|
||||||
@@ -63,10 +68,10 @@
|
|||||||
<span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
|
<span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-right">
|
<ul class="dropdown-menu dropdown-menu-right">
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Interface</a></li>
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Front Port</a></li>
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Rear Port</a></li>
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Circuit Termination</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,8 +108,6 @@
|
|||||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
|
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
|
||||||
{% elif not device.platform.napalm_driver %}
|
{% elif not device.platform.napalm_driver %}
|
||||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
|
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
|
||||||
{% elif not device.primary_ip %}
|
|
||||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include 'dcim/inc/device_napalm_tabs.html' %}
|
{% include 'dcim/inc/device_napalm_tabs.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -337,7 +337,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Parent</th>
|
<th colspan="2">Parent Device</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for device in nonracked_devices %}
|
{% for device in nonracked_devices %}
|
||||||
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
||||||
@@ -346,13 +346,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ device.device_role }}</td>
|
<td>{{ device.device_role }}</td>
|
||||||
<td>{{ device.device_type.display_name }}</td>
|
<td>{{ device.device_type.display_name }}</td>
|
||||||
<td>
|
|
||||||
{% if device.parent_bay %}
|
{% if device.parent_bay %}
|
||||||
<a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
|
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
|
||||||
|
<td>{{ device.parent_bay }}</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
<td colspan="2" class="text-muted">—</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -594,21 +594,20 @@ class DynamicModelChoiceMixin:
|
|||||||
filter = django_filters.ModelChoiceFilter
|
filter = django_filters.ModelChoiceFilter
|
||||||
widget = APISelect
|
widget = APISelect
|
||||||
|
|
||||||
def _get_initial_value(self, initial_data, field_name):
|
|
||||||
return initial_data.get(field_name)
|
|
||||||
|
|
||||||
def get_bound_field(self, form, field_name):
|
def get_bound_field(self, form, field_name):
|
||||||
bound_field = BoundField(form, self, field_name)
|
bound_field = BoundField(form, self, field_name)
|
||||||
|
|
||||||
# Override initial() to allow passing multiple values
|
|
||||||
bound_field.initial = self._get_initial_value(form.initial, field_name)
|
|
||||||
|
|
||||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||||
# will be populated on-demand via the APISelect widget.
|
# will be populated on-demand via the APISelect widget.
|
||||||
data = bound_field.value()
|
data = bound_field.value()
|
||||||
if data:
|
if data:
|
||||||
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
|
field_name = getattr(self, 'to_field_name') or 'pk'
|
||||||
|
filter = self.filter(field_name=field_name)
|
||||||
|
try:
|
||||||
self.queryset = filter.filter(self.queryset, data)
|
self.queryset = filter.filter(self.queryset, data)
|
||||||
|
except TypeError:
|
||||||
|
# Catch any error caused by invalid initial data passed from the user
|
||||||
|
self.queryset = self.queryset.none()
|
||||||
else:
|
else:
|
||||||
self.queryset = self.queryset.none()
|
self.queryset = self.queryset.none()
|
||||||
|
|
||||||
@@ -638,12 +637,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
|||||||
filter = django_filters.ModelMultipleChoiceFilter
|
filter = django_filters.ModelMultipleChoiceFilter
|
||||||
widget = APISelectMultiple
|
widget = APISelectMultiple
|
||||||
|
|
||||||
def _get_initial_value(self, initial_data, field_name):
|
|
||||||
# If a QueryDict has been passed as initial form data, get *all* listed values
|
|
||||||
if hasattr(initial_data, 'getlist'):
|
|
||||||
return initial_data.getlist(field_name)
|
|
||||||
return initial_data.get(field_name)
|
|
||||||
|
|
||||||
|
|
||||||
class LaxURLField(forms.URLField):
|
class LaxURLField(forms.URLField):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ def _get_viewname(instance, action):
|
|||||||
|
|
||||||
@register.inclusion_tag('buttons/clone.html')
|
@register.inclusion_tag('buttons/clone.html')
|
||||||
def clone_button(instance):
|
def clone_button(instance):
|
||||||
viewname = _get_viewname(instance, 'add')
|
url = reverse(_get_viewname(instance, 'add'))
|
||||||
|
|
||||||
# Populate cloned field values
|
# Populate cloned field values
|
||||||
param_string = prepare_cloned_fields(instance)
|
param_string = prepare_cloned_fields(instance)
|
||||||
if param_string:
|
if param_string:
|
||||||
url = '{}?{}'.format(reverse(viewname), param_string)
|
url = f'{url}?{param_string}'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'url': url,
|
'url': url,
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ def get_docs(model):
|
|||||||
model._meta.model_name
|
model._meta.model_name
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with open(path) as docfile:
|
with open(path, encoding='utf-8') as docfile:
|
||||||
content = docfile.read()
|
content = docfile.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return "Unable to load documentation, file not found: {}".format(path)
|
return "Unable to load documentation, file not found: {}".format(path)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
|
from django.http import QueryDict
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from utilities.utils import deepmerge, dict_to_filter_params
|
from utilities.utils import deepmerge, dict_to_filter_params, normalize_querydict
|
||||||
|
|
||||||
|
|
||||||
class DictToFilterParamsTest(TestCase):
|
class DictToFilterParamsTest(TestCase):
|
||||||
"""
|
"""
|
||||||
Validate the operation of dict_to_filter_params().
|
Validate the operation of dict_to_filter_params().
|
||||||
"""
|
"""
|
||||||
def setUp(self):
|
|
||||||
return
|
|
||||||
|
|
||||||
def test_dict_to_filter_params(self):
|
def test_dict_to_filter_params(self):
|
||||||
|
|
||||||
input = {
|
input = {
|
||||||
@@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase):
|
|||||||
self.assertNotEqual(dict_to_filter_params(input), output)
|
self.assertNotEqual(dict_to_filter_params(input), output)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizeQueryDictTest(TestCase):
|
||||||
|
"""
|
||||||
|
Validate normalize_querydict() utility function.
|
||||||
|
"""
|
||||||
|
def test_normalize_querydict(self):
|
||||||
|
self.assertDictEqual(
|
||||||
|
normalize_querydict(QueryDict('foo=1&bar=2&bar=3&baz=')),
|
||||||
|
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeepMergeTest(TestCase):
|
class DeepMergeTest(TestCase):
|
||||||
"""
|
"""
|
||||||
Validate the behavior of the deepmerge() utility.
|
Validate the behavior of the deepmerge() utility.
|
||||||
"""
|
"""
|
||||||
def setUp(self):
|
|
||||||
return
|
|
||||||
|
|
||||||
def test_deepmerge(self):
|
def test_deepmerge(self):
|
||||||
|
|
||||||
dict1 = {
|
dict1 = {
|
||||||
|
|||||||
@@ -150,6 +150,24 @@ def dict_to_filter_params(d, prefix=''):
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_querydict(querydict):
|
||||||
|
"""
|
||||||
|
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
|
||||||
|
|
||||||
|
QueryDict('foo=1&bar=2&bar=3&baz=')
|
||||||
|
|
||||||
|
becomes:
|
||||||
|
|
||||||
|
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
|
||||||
|
|
||||||
|
This function is necessary because QueryDict does not provide any built-in mechanism which preserves multiple
|
||||||
|
values.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
k: v if len(v) > 1 else v[0] for k, v in querydict.lists()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def deepmerge(original, new):
|
def deepmerge(original, new):
|
||||||
"""
|
"""
|
||||||
Deep merge two dictionaries (new into original) and return a new dict
|
Deep merge two dictionaries (new into original) and return a new dict
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
|
|||||||
from extras.querysets import CustomFieldQueryset
|
from extras.querysets import CustomFieldQueryset
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
|
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
|
||||||
from utilities.utils import csv_format, prepare_cloned_fields
|
from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
|
||||||
from .error_handlers import handle_protectederror
|
from .error_handlers import handle_protectederror
|
||||||
from .forms import ConfirmationForm, ImportForm
|
from .forms import ConfirmationForm, ImportForm
|
||||||
from .paginator import EnhancedPaginator, get_paginate_count
|
from .paginator import EnhancedPaginator, get_paginate_count
|
||||||
@@ -250,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View):
|
|||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
# Parse initial data manually to avoid setting field values as lists
|
# Parse initial data manually to avoid setting field values as lists
|
||||||
initial_data = {k: request.GET[k] for k in request.GET}
|
initial_data = normalize_querydict(request.GET)
|
||||||
form = self.model_form(instance=self.obj, initial=initial_data)
|
form = self.model_form(instance=self.obj, initial=initial_data)
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
@@ -267,9 +267,10 @@ class ObjectEditView(GetReturnURLMixin, View):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
logger.debug("Form validation was successful")
|
logger.debug("Form validation was successful")
|
||||||
|
|
||||||
|
object_created = form.instance.pk is None
|
||||||
obj = form.save()
|
obj = form.save()
|
||||||
msg = '{} {}'.format(
|
msg = '{} {}'.format(
|
||||||
'Created' if not form.instance.pk else 'Modified',
|
'Created' if object_created else 'Modified',
|
||||||
self.model._meta.verbose_name
|
self.model._meta.verbose_name
|
||||||
)
|
)
|
||||||
logger.info(f"{msg} {obj} (PK: {obj.pk})")
|
logger.info(f"{msg} {obj} (PK: {obj.pk})")
|
||||||
@@ -721,8 +722,8 @@ class BulkEditView(GetReturnURLMixin, View):
|
|||||||
|
|
||||||
# ManyToManyFields
|
# ManyToManyFields
|
||||||
elif isinstance(model_field, ManyToManyField):
|
elif isinstance(model_field, ManyToManyField):
|
||||||
|
if form.cleaned_data[name].count() > 0:
|
||||||
getattr(obj, name).set(form.cleaned_data[name])
|
getattr(obj, name).set(form.cleaned_data[name])
|
||||||
|
|
||||||
# Normal fields
|
# 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])
|
setattr(obj, name, form.cleaned_data[name])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from django.db.models import Count
|
|||||||
from dcim.models import Device, Interface
|
from dcim.models import Device, Interface
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from utilities.api import ModelViewSet
|
from utilities.api import ModelViewSet
|
||||||
|
from utilities.query_functions import CollateAsChar
|
||||||
from utilities.utils import get_subquery
|
from utilities.utils import get_subquery
|
||||||
from virtualization import filters
|
from virtualization import filters
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
@@ -74,7 +75,7 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
|
|||||||
class InterfaceViewSet(ModelViewSet):
|
class InterfaceViewSet(ModelViewSet):
|
||||||
queryset = Interface.objects.filter(
|
queryset = Interface.objects.filter(
|
||||||
virtual_machine__isnull=False
|
virtual_machine__isnull=False
|
||||||
).prefetch_related(
|
).order_by('virtual_machine', CollateAsChar('_name')).prefetch_related(
|
||||||
'virtual_machine', 'tags'
|
'virtual_machine', 'tags'
|
||||||
)
|
)
|
||||||
serializer_class = serializers.InterfaceSerializer
|
serializer_class = serializers.InterfaceSerializer
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet):
|
|||||||
mac_address = MultiValueMACAddressFilter(
|
mac_address = MultiValueMACAddressFilter(
|
||||||
label='MAC address',
|
label='MAC address',
|
||||||
)
|
)
|
||||||
|
tag = TagFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
|
|||||||
{{ record.primary_ip4.address.ip|default:"" }}
|
{{ record.primary_ip4.address.ip|default:"" }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CLUSTER_DEVICE_COUNT = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
CLUSTER_VM_COUNT = """
|
||||||
|
<a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Cluster types
|
# Cluster types
|
||||||
@@ -94,14 +102,12 @@ class ClusterTable(BaseTable):
|
|||||||
viewname='dcim:site',
|
viewname='dcim:site',
|
||||||
args=[Accessor('site.slug')]
|
args=[Accessor('site.slug')]
|
||||||
)
|
)
|
||||||
device_count = tables.Column(
|
device_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('devices.count'),
|
template_code=CLUSTER_DEVICE_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
vm_count = tables.Column(
|
vm_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('virtual_machines.count'),
|
template_code=CLUSTER_VM_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='VMs'
|
verbose_name='VMs'
|
||||||
)
|
)
|
||||||
tags = TagColumn(
|
tags = TagColumn(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from dcim.models import Device, Interface
|
|||||||
from dcim.tables import DeviceTable
|
from dcim.tables import DeviceTable
|
||||||
from extras.views import ObjectConfigContextView
|
from extras.views import ObjectConfigContextView
|
||||||
from ipam.models import Service
|
from ipam.models import Service
|
||||||
|
from utilities.utils import get_subquery
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
||||||
ObjectEditView, ObjectListView,
|
ObjectEditView, ObjectListView,
|
||||||
@@ -94,7 +95,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class ClusterListView(PermissionRequiredMixin, ObjectListView):
|
class ClusterListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'virtualization.view_cluster'
|
permission_required = 'virtualization.view_cluster'
|
||||||
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
|
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant').annotate(
|
||||||
|
device_count=get_subquery(Device, 'cluster'),
|
||||||
|
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||||
|
)
|
||||||
table = tables.ClusterTable
|
table = tables.ClusterTable
|
||||||
filterset = filters.ClusterFilterSet
|
filterset = filters.ClusterFilterSet
|
||||||
filterset_form = forms.ClusterFilterForm
|
filterset_form = forms.ClusterFilterForm
|
||||||
|
|||||||
@@ -21,5 +21,4 @@ Pillow==7.1.1
|
|||||||
psycopg2-binary==2.8.5
|
psycopg2-binary==2.8.5
|
||||||
pycryptodome==3.9.7
|
pycryptodome==3.9.7
|
||||||
PyYAML==5.3.1
|
PyYAML==5.3.1
|
||||||
redis==3.4.1
|
|
||||||
svgwrite==1.4
|
svgwrite==1.4
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ echo "Installing core dependencies ($COMMAND)..."
|
|||||||
eval $COMMAND || exit 1
|
eval $COMMAND || exit 1
|
||||||
|
|
||||||
# Install optional packages (if any)
|
# Install optional packages (if any)
|
||||||
if [ -f "local_requirements.txt" ]
|
if [ -s "local_requirements.txt" ]; then
|
||||||
then
|
|
||||||
COMMAND="pip3 install -r local_requirements.txt"
|
COMMAND="pip3 install -r local_requirements.txt"
|
||||||
echo "Installing local dependencies ($COMMAND)..."
|
echo "Installing local dependencies ($COMMAND)..."
|
||||||
eval $COMMAND || exit 1
|
eval $COMMAND || exit 1
|
||||||
|
elif [ -f "local_requirements.txt" ]; then
|
||||||
|
echo "Skipping local dependencies (local_requirements.txt is empty)"
|
||||||
else
|
else
|
||||||
echo "Skipping local dependencies (local_requirements.txt not found)"
|
echo "Skipping local dependencies (local_requirements.txt not found)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user