mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-26 11:48:16 +01:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
425670f52a | ||
|
|
6a2651991a | ||
|
|
f25e2a1922 | ||
|
|
95edec5448 | ||
|
|
f2076c9572 | ||
|
|
d71e6698f4 | ||
|
|
9b26225fdd | ||
|
|
f8c5ca942a | ||
|
|
16353ce63c | ||
|
|
7a4b202064 | ||
|
|
a97ebc6d4c | ||
|
|
9e765b1704 | ||
|
|
1048d3909b | ||
|
|
b2caaa6733 | ||
|
|
15722c1871 | ||
|
|
1d18948307 | ||
|
|
6a451e0c0e | ||
|
|
b46e52eccc | ||
|
|
de986ec392 | ||
|
|
4b415caad2 | ||
|
|
bfede60f3d | ||
|
|
03b8759597 | ||
|
|
bdd623f82c | ||
|
|
3c8083ed7f | ||
|
|
5904f1f8ee | ||
|
|
cc848a3f01 | ||
|
|
1aaa101fb5 | ||
|
|
0319450643 | ||
|
|
099774d667 | ||
|
|
b1761f7856 | ||
|
|
1bc38f66ae | ||
|
|
b4433a8471 | ||
|
|
053f49c76a | ||
|
|
fbde6187ea | ||
|
|
5eb5c4bac5 | ||
|
|
fb4283ed53 | ||
|
|
bbd65988f9 | ||
|
|
a11fa44170 | ||
|
|
99a542e4e4 | ||
|
|
7f779e3942 | ||
|
|
a2a83a4a4c | ||
|
|
9f7313e492 | ||
|
|
f6fbf66c8c | ||
|
|
3deedfd177 | ||
|
|
e9d5ff095c | ||
|
|
43d6bfa15f | ||
|
|
875e09013c | ||
|
|
ee854eff2c | ||
|
|
b176e0fafd | ||
|
|
fb24a4d420 | ||
|
|
391c42300e | ||
|
|
2a8fa01a57 | ||
|
|
bd4b496d14 | ||
|
|
66f4aac0e8 | ||
|
|
267caa348e | ||
|
|
7bf09a3085 | ||
|
|
a0de0696c5 | ||
|
|
aa6b2b8407 | ||
|
|
3e6726ff97 | ||
|
|
60ec3fde9d | ||
|
|
846acf7e9d | ||
|
|
fb9279cc74 | ||
|
|
d2aa9b8e79 | ||
|
|
eaeb52de20 | ||
|
|
fdbf41e9fd | ||
|
|
092346c819 | ||
|
|
5a5f51fe48 | ||
|
|
800df1ebbe | ||
|
|
8eec67e73a | ||
|
|
33b420652d | ||
|
|
42b679d9a3 | ||
|
|
ae4f17264f | ||
|
|
c7d8083ac6 | ||
|
|
95fec1a87c | ||
|
|
7467347af1 | ||
|
|
0ebc2e4ac0 | ||
|
|
ccb9f7bfe2 | ||
|
|
766b5dff24 | ||
|
|
68e0329c1b | ||
|
|
fd99994fdb | ||
|
|
f21a63382c | ||
|
|
6f0581598b | ||
|
|
244e85e836 | ||
|
|
1df6713ad5 | ||
|
|
af73ce75ce | ||
|
|
f08968da49 | ||
|
|
24344ccfaf | ||
|
|
91f045a2e4 | ||
|
|
050dfb279d | ||
|
|
f9b7f18be6 | ||
|
|
a7380ba353 | ||
|
|
b8feba1070 | ||
|
|
64575fec42 | ||
|
|
c7d9bf839e | ||
|
|
8480c0da53 | ||
|
|
5e88313276 | ||
|
|
09d7d38b04 | ||
|
|
4cc29729f9 | ||
|
|
b25faec159 |
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,8 +9,7 @@
|
||||
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
|
||||
FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
|
||||
-->
|
||||
### Fixes:
|
||||
|
||||
### Fixes: <ISSUE NUMBER GOES HERE>
|
||||
<!--
|
||||
Please include a summary of the proposed changes below.
|
||||
-->
|
||||
|
||||
23
.github/stale.yml
vendored
Normal file
23
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- "status: accepted"
|
||||
- "status: gathering feedback"
|
||||
- "status: blocked"
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. NetBox
|
||||
is governed by a small group of core maintainers which means not all opened
|
||||
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
effort to reduce noise, please do not comment any further. Note that the
|
||||
core maintainers may elect to reopen this issue at a later date if deemed
|
||||
necessary.
|
||||
@@ -24,7 +24,7 @@ already been fixed.
|
||||
to see if the bug you've found has already been reported. If you think you may
|
||||
be experiencing a reported issue that hasn't already been resolved, please
|
||||
click "add a reaction" in the top right corner of the issue and add a thumbs
|
||||
up (+1). You mightalso want to add a comment describing how it's affecting your
|
||||
up (+1). You might also want to add a comment describing how it's affecting your
|
||||
installation. This will allow us to prioritize bugs based on how many users are
|
||||
affected.
|
||||
|
||||
@@ -99,6 +99,8 @@ any work that's already in progress.
|
||||
|
||||
* Any pull request which does _not_ relate to an accepted issue will be closed.
|
||||
|
||||
* All major new functionality must include relevant tests where applicable.
|
||||
|
||||
* When submitting a pull request, please be sure to work off of the `develop`
|
||||
branch, rather than `master`. The `develop` branch is used for ongoing
|
||||
development, while `master` is used for tagging new stable releases.
|
||||
@@ -118,6 +120,30 @@ feedback. **Do not** comment on an issue just to show your support (give the
|
||||
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
|
||||
reduce noise in the discussion.
|
||||
|
||||
## Issue Lifecycle
|
||||
|
||||
When a correctly formatted issue is submitted it is evaluated by a moderator
|
||||
who may elect to immediately label the issue as accepted in addition to another
|
||||
issue type label. In other cases, the issue may be labeled as "status: gathering feedback"
|
||||
which will often be accompanied by a comment from a moderator asking for further dialog from the community.
|
||||
If an issue is labeled as "status: revisions needed" a moderator has identified a problem with
|
||||
the issue itself and is asking for the submitter himself to update the original post with
|
||||
the requested information. If the original post is not updated in a reasonable amount of time,
|
||||
the issue will be closed as invalid.
|
||||
|
||||
The core maintainers group has chosen to make use of the GitHub Stale bot to aid in issue management.
|
||||
|
||||
* Issues will be marked as stale after 14 days of no activity.
|
||||
* Then after 7 more days of inactivity, the issue will be closed.
|
||||
* Any issue bearing one of the following labels will be exempt from all Stale bot actions:
|
||||
* `status: accepted`
|
||||
* `status: gathering feedback`
|
||||
* `status: blocked`
|
||||
|
||||
It is natural that some new issues get more attention than others. Often this is a metric of an issues's
|
||||
overall usefulness to the project. In other cases in which issues merely get lost in the shuffle,
|
||||
notifications from Stale bot can bring renewed attention to potentially meaningful issues.
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
* Maintainers are expected to contribute at least four hours per week to the
|
||||
|
||||
15
README.md
15
README.md
@@ -3,7 +3,8 @@
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure
|
||||
management (DCIM) tool. Initially conceived by the network engineering team at
|
||||
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
|
||||
to address the needs of network and infrastructure engineers.
|
||||
to address the needs of network and infrastructure engineers. It is intended to
|
||||
function as a domain-specific source of truth for network operations.
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
|
||||
@@ -35,12 +36,14 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
|
||||
and run `upgrade.sh`.
|
||||
|
||||
## Alternative Installations
|
||||
# Providing Feedback
|
||||
|
||||
* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||
* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
|
||||
If you are interested in contributing to the development of NetBox, please read
|
||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
# Related projects
|
||||
|
||||
|
||||
@@ -119,6 +119,23 @@ Stored a numeric integer. Options include:
|
||||
|
||||
A true/false flag. This field has no options beyond the defaults.
|
||||
|
||||
### ChoiceVar
|
||||
|
||||
A set of choices from which the user can select one.
|
||||
|
||||
* `choices` - A list of `(value, label)` tuples representing the available choices. For example:
|
||||
|
||||
```python
|
||||
CHOICES = (
|
||||
('n', 'North'),
|
||||
('s', 'South'),
|
||||
('e', 'East'),
|
||||
('w', 'West')
|
||||
)
|
||||
|
||||
direction = ChoiceVar(choices=CHOICES)
|
||||
```
|
||||
|
||||
### ObjectVar
|
||||
|
||||
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
|
||||
@@ -165,7 +182,7 @@ class NewBranchScript(Script):
|
||||
class Meta:
|
||||
name = "New Branch"
|
||||
description = "Provision a new branch site"
|
||||
fields = ['site_name', 'switch_count', 'switch_model']
|
||||
field_order = ['site_name', 'switch_count', 'switch_model']
|
||||
|
||||
site_name = StringVar(
|
||||
description="Name of the new site"
|
||||
|
||||
@@ -4,7 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
|
||||
|
||||
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
|
||||
|
||||
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
|
||||
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
|
||||
|
||||
```
|
||||
{% for rack in queryset %}
|
||||
|
||||
@@ -11,8 +11,10 @@ The webhook POST request is structured as so (assuming `application/json` as the
|
||||
```no-highlight
|
||||
{
|
||||
"event": "created",
|
||||
"signal_received_timestamp": 1508769597,
|
||||
"model": "Site"
|
||||
"timestamp": "2019-10-12 12:51:29.746944",
|
||||
"username": "admin",
|
||||
"model": "site",
|
||||
"request_id": "43d8e212-94c7-4f67-b544-0dcde4fc0f43",
|
||||
"data": {
|
||||
...
|
||||
}
|
||||
@@ -24,8 +26,10 @@ The webhook POST request is structured as so (assuming `application/json` as the
|
||||
```no-highlight
|
||||
{
|
||||
"event": "deleted",
|
||||
"signal_received_timestamp": 1508781858.544069,
|
||||
"model": "Site",
|
||||
"timestamp": "2019-10-12 12:55:44.030750",
|
||||
"username": "johnsmith",
|
||||
"model": "site",
|
||||
"request_id": "e9bb83b2-ebe4-4346-b13f-07144b1a00b4",
|
||||
"data": {
|
||||
"asn": None,
|
||||
"comments": "",
|
||||
|
||||
@@ -4,7 +4,7 @@ NetBox includes a Python shell within which objects can be directly queried, cre
|
||||
./manage.py nbshell
|
||||
```
|
||||
|
||||
This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/dev/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
|
||||
```
|
||||
$ ./manage.py nbshell
|
||||
@@ -28,7 +28,7 @@ DCIM:
|
||||
|
||||
## Querying Objects
|
||||
|
||||
Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `<model>.objects.all()`, which will return a (truncated) list of all objects of that type.
|
||||
Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `<model>.objects.all()`, which will return a (truncated) list of all objects of that type.
|
||||
|
||||
```
|
||||
>>> Device.objects.all()
|
||||
@@ -99,7 +99,7 @@ This approach can span multiple levels of relations. For example, the following
|
||||
```
|
||||
|
||||
!!! note
|
||||
While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/dev/ref/models/querysets/) documentation.
|
||||
While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/stable/ref/models/querysets/) documentation.
|
||||
|
||||
Reverse relationships can be traversed as well. For example, the following will find all devices with an interface named "em0":
|
||||
|
||||
@@ -137,7 +137,7 @@ To return the inverse of a filtered queryset, use `exclude()` instead of `filter
|
||||
```
|
||||
|
||||
!!! info
|
||||
The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/dev/ref/models/querysets/).
|
||||
The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/stable/ref/models/querysets/).
|
||||
|
||||
## Creating and Updating Objects
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ If you want to export only the database schema, and not the data itself (e.g. fo
|
||||
```no-highlight
|
||||
pg_dump -s netbox > netbox_schema.sql
|
||||
```
|
||||
If you are migrating your instance of NetBox to a different machine, please make sure you invalidate the cache by performing this command:
|
||||
|
||||
```no-highlight
|
||||
python3 manage.py invalidate all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni
|
||||
|
||||
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
|
||||
|
||||
The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/1.11/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
|
||||
The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
|
||||
|
||||
```
|
||||
LOGGING = {
|
||||
@@ -311,7 +311,7 @@ Enable this option to run the webhook backend. See the docs section on the webho
|
||||
|
||||
## Date and Time Formatting
|
||||
|
||||
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date).
|
||||
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date).
|
||||
|
||||
Defaults:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## ALLOWED_HOSTS
|
||||
|
||||
This is a list of valid fully-qualified domain names (FQDNs) that is used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different (e.g. when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server). NetBox will not permit access to the server via any other hostnames (or IPs). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts `HTTP POST` to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/1.9/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, has `USE_X_FORWARDED_HOST = True` (in `netbox/netbox/settings.py`) 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/1.9/ref/settings/#allowed-hosts)).
|
||||
This is a list of valid fully-qualified domain names (FQDNs) that is used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different (e.g. when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server). NetBox will not permit access to the server via any other hostnames (or IPs). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts `HTTP POST` 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, has `USE_X_FORWARDED_HOST = True` (in `netbox/netbox/settings.py`) 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)).
|
||||
|
||||
Example:
|
||||
|
||||
@@ -21,6 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
|
||||
* `PASSWORD` - PostgreSQL password
|
||||
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
|
||||
* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)).
|
||||
|
||||
Example:
|
||||
|
||||
@@ -31,6 +32,7 @@ DATABASE = {
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
'CONN_MAX_AGE': 300, # Max database connection age
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,7 +71,7 @@ REDIS = {
|
||||
!!! note:
|
||||
If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
|
||||
an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
|
||||
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
|
||||
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
|
||||
|
||||
!!! warning:
|
||||
It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Style Guide
|
||||
|
||||
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
|
||||
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
|
||||
|
||||
## PEP 8 Exceptions
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ DATABASE = {
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
'CONN_MAX_AGE': 300, # Max database connection age
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -108,7 +107,7 @@ Install gunicorn:
|
||||
# pip3 install gunicorn
|
||||
```
|
||||
|
||||
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
|
||||
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`. More info on `max_requests` can be found in the [gunicorn docs](https://docs.gunicorn.org/en/stable/settings.html#max-requests).
|
||||
|
||||
```no-highlight
|
||||
command = '/usr/bin/gunicorn'
|
||||
@@ -116,6 +115,8 @@ pythonpath = '/opt/netbox/netbox'
|
||||
bind = '127.0.0.1:8001'
|
||||
workers = 3
|
||||
user = 'www-data'
|
||||
max_requests = 5000
|
||||
max_requests_jitter = 500
|
||||
```
|
||||
|
||||
# supervisord Installation
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
# v2.6.8 (2019-12-10)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3139](https://github.com/netbox-community/netbox/issues/3139) - Disable password change form for LDAP-authenticated users
|
||||
* [#3457](https://github.com/netbox-community/netbox/issues/3457) - Display cable colors on device view
|
||||
* [#3329](https://github.com/netbox-community/netbox/issues/3329) - Remove obsolete P3P policy header
|
||||
* [#3663](https://github.com/netbox-community/netbox/issues/3663) - Add query filters for `created` and `last_updated` fields
|
||||
* [#3722](https://github.com/netbox-community/netbox/issues/3722) - Allow the underscore character in IPAddress DNS names
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3312](https://github.com/netbox-community/netbox/issues/3312) - Fix validation error when editing power cables in bulk
|
||||
* [#3644](https://github.com/netbox-community/netbox/issues/3644) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
|
||||
* [#3669](https://github.com/netbox-community/netbox/issues/3669) - Include `weight` field in prefix/VLAN role form
|
||||
* [#3674](https://github.com/netbox-community/netbox/issues/3674) - Include comments on PowerFeed view
|
||||
* [#3679](https://github.com/netbox-community/netbox/issues/3679) - Fix link for assigned ipaddress in interface page
|
||||
* [#3709](https://github.com/netbox-community/netbox/issues/3709) - Prevent exception when importing an invalid cable definition
|
||||
* [#3720](https://github.com/netbox-community/netbox/issues/3720) - Correctly indicate power feed terminations on cable list
|
||||
* [#3724](https://github.com/netbox-community/netbox/issues/3724) - Fix API filtering of interfaces by more than one device name
|
||||
* [#3725](https://github.com/netbox-community/netbox/issues/3725) - Enforce client validation for minimum service port number
|
||||
|
||||
# v2.6.7 (2019-11-01)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3445](https://github.com/netbox-community/netbox/issues/3445) - Add support for additional user defined headers to be added to webhook requests
|
||||
* [#3499](https://github.com/netbox-community/netbox/issues/3499) - Add `ca_file_path` to Webhook model to support user supplied CA certificate verification of webhook requests
|
||||
* [#3594](https://github.com/netbox-community/netbox/issues/3594) - Add ChoiceVar for custom scripts
|
||||
* [#3619](https://github.com/netbox-community/netbox/issues/3619) - Add 400GE OSFP interface type
|
||||
* [#3659](https://github.com/netbox-community/netbox/issues/3659) - Add filtering for objects in admin UI
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3309](https://github.com/netbox-community/netbox/issues/3309) - Rewrite change logging middleware to resolve sporadic testing failures
|
||||
* [#3340](https://github.com/netbox-community/netbox/issues/3340) - Add missing options to connect front ports to console ports
|
||||
* [#3357](https://github.com/netbox-community/netbox/issues/3357) - Enable filter sites/devices/VMs by null region
|
||||
* [#3460](https://github.com/netbox-community/netbox/issues/3460) - Extend upgrade script to validate Python dependencies
|
||||
* [#3596](https://github.com/netbox-community/netbox/issues/3596) - Prevent server error when reassigning a device to a new device bay
|
||||
* [#3629](https://github.com/netbox-community/netbox/issues/3629) - Use `get_lldp_neighors_detail` to validation LLDP neighbors
|
||||
* [#3635](https://github.com/netbox-community/netbox/issues/3635) - Add missing cache support for the circuits app
|
||||
* [#3636](https://github.com/netbox-community/netbox/issues/3636) - Add missing `rack_group` field to PowerFeed CSV export
|
||||
* [#3652](https://github.com/netbox-community/netbox/issues/3652) - Limit next/previous rack by assigned rack group
|
||||
|
||||
---
|
||||
|
||||
# v2.6.6 (2019-10-10)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -31,6 +31,7 @@ pages:
|
||||
- Change Logging: 'additional-features/change-logging.md'
|
||||
- Context Data: 'additional-features/context-data.md'
|
||||
- Custom Fields: 'additional-features/custom-fields.md'
|
||||
- Custom Links: 'additional-features/custom-links.md'
|
||||
- Custom Scripts: 'additional-features/custom-scripts.md'
|
||||
- Export Templates: 'additional-features/export-templates.md'
|
||||
- Graphs: 'additional-features/graphs.md'
|
||||
|
||||
@@ -2,14 +2,14 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
|
||||
from .constants import *
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
|
||||
class ProviderFilter(CustomFieldFilterSet):
|
||||
class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -54,7 +54,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
|
||||
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
@@ -86,6 +86,7 @@ IFACE_TYPE_100GE_QSFP28 = 1600
|
||||
IFACE_TYPE_200GE_CFP2 = 1650
|
||||
IFACE_TYPE_200GE_QSFP56 = 1700
|
||||
IFACE_TYPE_400GE_QSFP_DD = 1750
|
||||
IFACE_TYPE_400GE_OSFP = 1800
|
||||
# Wireless
|
||||
IFACE_TYPE_80211A = 2600
|
||||
IFACE_TYPE_80211G = 2610
|
||||
@@ -180,6 +181,7 @@ IFACE_TYPE_CHOICES = [
|
||||
[IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'],
|
||||
[IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'],
|
||||
[IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
|
||||
[IFACE_TYPE_400GE_OSFP, 'OSFP (400GE)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -382,7 +384,8 @@ CONNECTION_STATUS_CHOICES = [
|
||||
|
||||
# Cable endpoint types
|
||||
CABLE_TERMINATION_TYPES = [
|
||||
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
|
||||
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
|
||||
'circuittermination', 'powerfeed',
|
||||
]
|
||||
|
||||
# Cable types
|
||||
|
||||
@@ -2,13 +2,13 @@ import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.constants import COLOR_CHOICES
|
||||
from utilities.filters import (
|
||||
MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter,
|
||||
TreeNodeMultipleChoiceFilter,
|
||||
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter,
|
||||
TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import *
|
||||
@@ -38,7 +38,7 @@ class RegionFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -116,7 +116,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -251,7 +251,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class DeviceTypeFilter(CustomFieldFilterSet):
|
||||
class DeviceTypeFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -423,7 +423,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver']
|
||||
|
||||
|
||||
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
|
||||
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -696,7 +696,7 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
label='Device',
|
||||
@@ -749,8 +749,10 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
device = Device.objects.get(**{name: value})
|
||||
vc_interface_ids = device.vc_interfaces.values_list('id', flat=True)
|
||||
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||
vc_interface_ids = []
|
||||
for device in devices:
|
||||
vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
@@ -1096,7 +1098,7 @@ class PowerPanelFilter(django_filters.FilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class PowerFeedFilter(CustomFieldFilterSet):
|
||||
class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
@@ -174,8 +174,8 @@ class Migration(migrations.Migration):
|
||||
('length', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
|
||||
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'powerfeed']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'powerfeed']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
@@ -5,14 +7,15 @@ import django.db.models.deletion
|
||||
def cache_cable_devices(apps, schema_editor):
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
|
||||
print("\nUpdatng cable device terminations...")
|
||||
if 'test' not in sys.argv:
|
||||
print("\nUpdating cable device terminations...")
|
||||
cable_count = Cable.objects.count()
|
||||
|
||||
# Cache A/B termination devices on all existing Cables. Note that the custom save() method on Cable is not
|
||||
# available during a migration, so we replicate its logic here.
|
||||
for i, cable in enumerate(Cable.objects.all(), start=1):
|
||||
|
||||
if not i % 1000:
|
||||
if not i % 1000 and 'test' not in sys.argv:
|
||||
print("[{}/{}]".format(i, cable_count))
|
||||
|
||||
termination_a_model = apps.get_model(cable.termination_a_type.app_label, cable.termination_a_type.model)
|
||||
|
||||
@@ -98,6 +98,8 @@ class CableTermination(models.Model):
|
||||
object_id_field='termination_b_id'
|
||||
)
|
||||
|
||||
is_path_endpoint = True
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -2444,6 +2446,8 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
is_path_endpoint = False
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -2506,6 +2510,8 @@ class RearPort(CableTermination, ComponentModel):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
is_path_endpoint = False
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -2588,6 +2594,16 @@ class DeviceBay(ComponentModel):
|
||||
if self.device == self.installed_device:
|
||||
raise ValidationError("Cannot install a device into itself.")
|
||||
|
||||
# Check that the installed device is not already installed elsewhere
|
||||
if self.installed_device:
|
||||
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
|
||||
if current_bay:
|
||||
raise ValidationError({
|
||||
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
|
||||
current_bay
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
@@ -2828,6 +2844,8 @@ class Cable(ChangeLoggedModel):
|
||||
def clean(self):
|
||||
|
||||
# Validate that termination A exists
|
||||
if not hasattr(self, 'termination_a_type'):
|
||||
raise ValidationError('Termination A type has not been specified')
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
@@ -2836,6 +2854,8 @@ class Cable(ChangeLoggedModel):
|
||||
})
|
||||
|
||||
# Validate that termination B exists
|
||||
if not hasattr(self, 'termination_b_type'):
|
||||
raise ValidationError('Termination B type has not been specified')
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
@@ -3112,6 +3132,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
return (
|
||||
self.power_panel.site.name,
|
||||
self.power_panel.name,
|
||||
self.rack.group.name if self.rack and self.rack.group else None,
|
||||
self.rack.name if self.rack else None,
|
||||
self.name,
|
||||
self.get_status_display(),
|
||||
|
||||
@@ -45,7 +45,7 @@ def update_connected_endpoints(instance, **kwargs):
|
||||
|
||||
# Check if this Cable has formed a complete path. If so, update both endpoints.
|
||||
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
|
||||
if endpoint_a is not None and endpoint_b is not None:
|
||||
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
|
||||
endpoint_a.connected_endpoint = endpoint_b
|
||||
endpoint_a.connection_status = path_status
|
||||
endpoint_a.save()
|
||||
|
||||
@@ -181,8 +181,10 @@ VIRTUALCHASSIS_ACTIONS = """
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
{% if value.device %}
|
||||
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
|
||||
{% else %}
|
||||
{% elif value.circuit %}
|
||||
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
|
||||
{% elif value.power_panel %}
|
||||
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -718,7 +720,7 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Termination A'
|
||||
)
|
||||
termination_a = tables.Column(
|
||||
termination_a = tables.LinkColumn(
|
||||
accessor=Accessor('termination_a'),
|
||||
orderable=False,
|
||||
verbose_name=''
|
||||
@@ -729,7 +731,7 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Termination B'
|
||||
)
|
||||
termination_b = tables.Column(
|
||||
termination_b = tables.LinkColumn(
|
||||
accessor=Accessor('termination_b'),
|
||||
orderable=False,
|
||||
verbose_name=''
|
||||
|
||||
@@ -404,8 +404,12 @@ class RackView(PermissionRequiredMixin, View):
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
|
||||
if rack.group:
|
||||
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
||||
else:
|
||||
peer_racks = Rack.objects.filter(site=rack.site, group__isnull=True)
|
||||
next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first()
|
||||
|
||||
reservations = RackReservation.objects.filter(rack=rack)
|
||||
power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
|
||||
|
||||
@@ -40,6 +40,9 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
|
||||
'type_delete', 'ssl_verification',
|
||||
]
|
||||
list_filter = [
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
|
||||
]
|
||||
form = WebhookForm
|
||||
|
||||
def models(self, obj):
|
||||
@@ -70,7 +73,12 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
|
||||
@admin.register(CustomField, site=admin_site)
|
||||
class CustomFieldAdmin(admin.ModelAdmin):
|
||||
inlines = [CustomFieldChoiceAdmin]
|
||||
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
|
||||
list_display = [
|
||||
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
|
||||
]
|
||||
list_filter = [
|
||||
'type', 'required', 'obj_type',
|
||||
]
|
||||
form = CustomFieldForm
|
||||
|
||||
def models(self, obj):
|
||||
@@ -106,7 +114,12 @@ class CustomLinkForm(forms.ModelForm):
|
||||
|
||||
@admin.register(CustomLink, site=admin_site)
|
||||
class CustomLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'content_type', 'group_name', 'weight']
|
||||
list_display = [
|
||||
'name', 'content_type', 'group_name', 'weight',
|
||||
]
|
||||
list_filter = [
|
||||
'content_type',
|
||||
]
|
||||
form = CustomLinkForm
|
||||
|
||||
|
||||
@@ -116,7 +129,12 @@ class CustomLinkAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Graph, site=admin_site)
|
||||
class GraphAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'type', 'weight', 'source']
|
||||
list_display = [
|
||||
'name', 'type', 'weight', 'source',
|
||||
]
|
||||
list_filter = [
|
||||
'type',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -139,7 +157,12 @@ class ExportTemplateForm(forms.ModelForm):
|
||||
|
||||
@admin.register(ExportTemplate, site=admin_site)
|
||||
class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
|
||||
list_display = [
|
||||
'name', 'content_type', 'description', 'mime_type', 'file_extension',
|
||||
]
|
||||
list_filter = [
|
||||
'content_type',
|
||||
]
|
||||
form = ExportTemplateForm
|
||||
|
||||
|
||||
|
||||
@@ -97,13 +97,13 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
def _populate_custom_fields(instance, fields):
|
||||
custom_fields = {f.name: None for f in fields}
|
||||
for cfv in instance.custom_field_values.all():
|
||||
if cfv.field.type == CF_TYPE_SELECT:
|
||||
custom_fields[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
|
||||
instance.custom_fields = {}
|
||||
for field in fields:
|
||||
value = instance.cf.get(field.name)
|
||||
if field.type == CF_TYPE_SELECT and value is not None:
|
||||
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
|
||||
else:
|
||||
custom_fields[cfv.field.name] = cfv.value
|
||||
instance.custom_fields = custom_fields
|
||||
instance.custom_fields[field.name] = value
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ router.APIRootView = ExtrasRootView
|
||||
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
|
||||
|
||||
# Custom field choices
|
||||
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
|
||||
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
|
||||
|
||||
# Graphs
|
||||
router.register(r'graphs', views.GraphViewSet)
|
||||
|
||||
@@ -241,3 +241,24 @@ class ObjectChangeFilter(django_filters.FilterSet):
|
||||
Q(user_name__icontains=value) |
|
||||
Q(object_repr__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CreatedUpdatedFilterSet(django_filters.FilterSet):
|
||||
created = django_filters.DateFilter()
|
||||
created__gte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
created__lte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
last_updated = django_filters.DateTimeFilter()
|
||||
last_updated__gte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
last_updated__lte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import random
|
||||
import threading
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import curry
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from utilities.querysets import DummyQuerySet
|
||||
from .constants import *
|
||||
from .models import ObjectChange
|
||||
from .signals import purge_changelog
|
||||
@@ -19,33 +20,34 @@ _thread_locals = threading.local()
|
||||
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated
|
||||
Fires when an object is created or updated.
|
||||
"""
|
||||
# Queue the object and a new ObjectChange for processing once the request completes
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
|
||||
objectchange = instance.to_objectchange(action)
|
||||
_thread_locals.changed_objects.append(
|
||||
(instance, objectchange)
|
||||
)
|
||||
# Queue the object for processing once the request completes
|
||||
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
|
||||
_thread_locals.changed_objects.append(
|
||||
(instance, action)
|
||||
)
|
||||
|
||||
|
||||
def _handle_deleted_object(request, sender, instance, **kwargs):
|
||||
def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Record an Object Change
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
# Cache custom fields prior to copying the instance
|
||||
if hasattr(instance, 'cache_custom_fields'):
|
||||
instance.cache_custom_fields()
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
|
||||
# Create a copy of the object being deleted
|
||||
copy = deepcopy(instance)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
# Preserve tags
|
||||
if hasattr(instance, 'tags'):
|
||||
copy.tags = DummyQuerySet(instance.tags.all())
|
||||
|
||||
# Queue the copy of the object for processing once the request completes
|
||||
_thread_locals.changed_objects.append(
|
||||
(copy, OBJECTCHANGE_ACTION_DELETE)
|
||||
)
|
||||
|
||||
|
||||
def purge_objectchange_cache(sender, **kwargs):
|
||||
@@ -81,12 +83,9 @@ class ObjectChangeMiddleware(object):
|
||||
# the same request.
|
||||
request.id = uuid.uuid4()
|
||||
|
||||
# Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
|
||||
handle_deleted_object = curry(_handle_deleted_object, request)
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='cache_changed_object')
|
||||
post_delete.connect(handle_deleted_object, dispatch_uid='cache_deleted_object')
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
|
||||
# Provide a hook for purging the change cache
|
||||
purge_changelog.connect(purge_objectchange_cache)
|
||||
@@ -98,22 +97,31 @@ class ObjectChangeMiddleware(object):
|
||||
if not _thread_locals.changed_objects:
|
||||
return response
|
||||
|
||||
# Create records for any cached objects that were created/updated.
|
||||
for obj, objectchange in _thread_locals.changed_objects:
|
||||
# Create records for any cached objects that were changed.
|
||||
for instance, action in _thread_locals.changed_objects:
|
||||
|
||||
# Record the change
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
# Refresh cached custom field values
|
||||
if action in [OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE]:
|
||||
if hasattr(instance, 'cache_custom_fields'):
|
||||
instance.cache_custom_fields()
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(obj, request.user, request.id, objectchange.action)
|
||||
enqueue_webhooks(instance, request.user, request.id, action)
|
||||
|
||||
# Increment metric counters
|
||||
if objectchange.action == OBJECTCHANGE_ACTION_CREATE:
|
||||
model_inserts.labels(obj._meta.model_name).inc()
|
||||
elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
|
||||
model_updates.labels(obj._meta.model_name).inc()
|
||||
if action == OBJECTCHANGE_ACTION_CREATE:
|
||||
model_inserts.labels(instance._meta.model_name).inc()
|
||||
elif action == OBJECTCHANGE_ACTION_UPDATE:
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
elif action == OBJECTCHANGE_ACTION_DELETE:
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
# Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
|
||||
# one or more changes being logged.
|
||||
|
||||
18
netbox/extras/migrations/0026_webhook_ca_file_path.py
Normal file
18
netbox/extras/migrations/0026_webhook_ca_file_path.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-10-13 05:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0025_objectchange_time_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='ca_file_path',
|
||||
field=models.CharField(blank=True, max_length=4096, null=True),
|
||||
),
|
||||
]
|
||||
19
netbox/extras/migrations/0027_webhook_additional_headers.py
Normal file
19
netbox/extras/migrations/0027_webhook_additional_headers.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2 on 2019-10-13 07:06
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0026_webhook_ca_file_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='additional_headers',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -70,6 +70,12 @@ class Webhook(models.Model):
|
||||
default=WEBHOOK_CT_JSON,
|
||||
verbose_name='HTTP content type'
|
||||
)
|
||||
additional_headers = JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User supplied headers which should be added to the request in addition to the HTTP content type. "
|
||||
"Headers are supplied as key/value pairs in a JSON object."
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
@@ -86,6 +92,14 @@ class Webhook(models.Model):
|
||||
verbose_name='SSL verification',
|
||||
help_text="Enable SSL certificate verification. Disable with caution!"
|
||||
)
|
||||
ca_file_path = models.CharField(
|
||||
max_length=4096,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='CA File Path',
|
||||
help_text='The specific CA certificate file to use for SSL verification. '
|
||||
'Leave blank to use the system defaults.'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
|
||||
@@ -102,6 +116,17 @@ class Webhook(models.Model):
|
||||
"You must select at least one type: create, update, and/or delete."
|
||||
)
|
||||
|
||||
if not self.ssl_verification and self.ca_file_path:
|
||||
raise ValidationError({
|
||||
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is dissabled.'
|
||||
})
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.additional_headers and type(self.additional_headers) is not dict:
|
||||
raise ValidationError({
|
||||
'additional_headers': 'Header JSON data must be in object form. Example: {"X-API-KEY": "abc123"}'
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
@@ -113,16 +138,21 @@ class CustomFieldModel(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def cache_custom_fields(self):
|
||||
"""
|
||||
Cache all custom field values for this instance
|
||||
"""
|
||||
self._cf = {
|
||||
field.name: value for field, value in self.get_custom_fields().items()
|
||||
}
|
||||
|
||||
@property
|
||||
def cf(self):
|
||||
"""
|
||||
Name-based CustomFieldValue accessor for use in templates
|
||||
"""
|
||||
if self._cf is None:
|
||||
# Cache all custom field values for this instance
|
||||
self._cf = {
|
||||
field.name: value for field, value in self.get_custom_fields().items()
|
||||
}
|
||||
self.cache_custom_fields()
|
||||
return self._cf
|
||||
|
||||
def get_custom_fields(self):
|
||||
|
||||
@@ -24,6 +24,7 @@ from .signals import purge_changelog
|
||||
__all__ = [
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'ChoiceVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPNetworkVar',
|
||||
@@ -133,6 +134,27 @@ class BooleanVar(ScriptVariable):
|
||||
self.field_attrs['required'] = False
|
||||
|
||||
|
||||
class ChoiceVar(ScriptVariable):
|
||||
"""
|
||||
Select one of several predefined static choices, passed as a list of two-tuples. Example:
|
||||
|
||||
color = ChoiceVar(
|
||||
choices=(
|
||||
('#ff0000', 'Red'),
|
||||
('#00ff00', 'Green'),
|
||||
('#0000ff', 'Blue')
|
||||
)
|
||||
)
|
||||
"""
|
||||
form_field = forms.ChoiceField
|
||||
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set field choices
|
||||
self.field_attrs['choices'] = choices
|
||||
|
||||
|
||||
class ObjectVar(ScriptVariable):
|
||||
"""
|
||||
NetBox object representation. The provided QuerySet will determine the choices available.
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_DELETE
|
||||
from extras.models import ObjectChange
|
||||
from extras.constants import *
|
||||
from extras.models import CustomField, CustomFieldValue, ObjectChange
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
|
||||
class ChangeLogTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Create a custom field on the Site model
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField(
|
||||
type=CF_TYPE_TEXT,
|
||||
name='my_field',
|
||||
required=False
|
||||
)
|
||||
cf.save()
|
||||
cf.obj_type.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
},
|
||||
'tags': [
|
||||
'bar', 'foo'
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ObjectChange.objects.count(), 1)
|
||||
|
||||
oc = ObjectChange.objects.first()
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
)
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
|
||||
|
||||
def test_update_object(self):
|
||||
|
||||
@@ -37,26 +61,43 @@ class ChangeLogTest(APITestCase):
|
||||
data = {
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
},
|
||||
'tags': [
|
||||
'abc', 'xyz'
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ObjectChange.objects.count(), 1)
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(site.name, data['name'])
|
||||
|
||||
oc = ObjectChange.objects.first()
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
)
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
|
||||
|
||||
def test_delete_object(self):
|
||||
|
||||
site = Site(name='Test Site 1', slug='test-site-1')
|
||||
site = Site(
|
||||
name='Test Site 1',
|
||||
slug='test-site-1'
|
||||
)
|
||||
site.save()
|
||||
site.tags.add('foo', 'bar')
|
||||
CustomFieldValue.objects.create(
|
||||
field=CustomField.objects.get(name='my_field'),
|
||||
obj=site,
|
||||
value='ABC'
|
||||
)
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
@@ -70,3 +111,5 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])
|
||||
|
||||
@@ -99,6 +99,31 @@ class ScriptVariablesTest(TestCase):
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], False)
|
||||
|
||||
def test_choicevar(self):
|
||||
|
||||
CHOICES = (
|
||||
('ff0000', 'Red'),
|
||||
('00ff00', 'Green'),
|
||||
('0000ff', 'Blue')
|
||||
)
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = ChoiceVar(
|
||||
choices=CHOICES
|
||||
)
|
||||
|
||||
# Validate valid choice
|
||||
data = {'var1': CHOICES[0][0]}
|
||||
form = TestScript().as_form(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], CHOICES[0][0])
|
||||
|
||||
# Validate invalid choices
|
||||
data = {'var1': 'taupe'}
|
||||
form = TestScript().as_form(data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
def test_objectvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
@@ -25,6 +25,9 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
|
||||
headers = {
|
||||
'Content-Type': webhook.get_http_content_type_display(),
|
||||
}
|
||||
if webhook.additional_headers:
|
||||
headers.update(webhook.additional_headers)
|
||||
|
||||
params = {
|
||||
'method': 'POST',
|
||||
'url': webhook.payload_url,
|
||||
@@ -49,6 +52,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
|
||||
|
||||
with requests.Session() as session:
|
||||
session.verify = webhook.ssl_verification
|
||||
if webhook.ca_file_path:
|
||||
session.verify = webhook.ca_file_path
|
||||
response = session.send(prepared_request)
|
||||
|
||||
if response.status_code >= 200 and response.status_code <= 299:
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.db.models import Q
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import Site, Device, Interface
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
|
||||
from virtualization.models import VirtualMachine
|
||||
@@ -13,7 +13,7 @@ from .constants import *
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -49,7 +49,7 @@ class RIRFilter(NameSlugSearchFilterSet):
|
||||
fields = ['name', 'slug', 'is_private']
|
||||
|
||||
|
||||
class AggregateFilter(CustomFieldFilterSet):
|
||||
class AggregateFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -110,7 +110,7 @@ class RoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -247,7 +247,7 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
return queryset.filter(prefix__net_mask_length=value)
|
||||
|
||||
|
||||
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -384,7 +384,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -444,7 +444,7 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ServiceFilter(django_filters.FilterSet):
|
||||
class ServiceFilter(CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -240,7 +240,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = [
|
||||
'name', 'slug',
|
||||
'name', 'slug', 'weight',
|
||||
]
|
||||
|
||||
|
||||
@@ -1250,6 +1250,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class ServiceForm(BootstrapMixin, CustomFieldForm):
|
||||
port = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=65535
|
||||
)
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -14,6 +14,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='ipaddress',
|
||||
name='dns_name',
|
||||
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]),
|
||||
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', regex='^[0-9A-Za-z._-]+$')]),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -85,7 +85,7 @@ IPADDRESS_LINK = """
|
||||
"""
|
||||
|
||||
IPADDRESS_ASSIGN_LINK = """
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
|
||||
"""
|
||||
|
||||
IPADDRESS_PARENT = """
|
||||
@@ -292,7 +292,7 @@ class RoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'weight', 'actions')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
DNSValidator = RegexValidator(
|
||||
regex='^[0-9A-Za-z.-]+$',
|
||||
message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names',
|
||||
regex='^[0-9A-Za-z._-]+$',
|
||||
message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names',
|
||||
code='invalid'
|
||||
)
|
||||
|
||||
@@ -17,12 +17,13 @@ DATABASE = {
|
||||
'PASSWORD': '', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
'CONN_MAX_AGE': 300, # Max database connection age
|
||||
}
|
||||
|
||||
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
|
||||
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
|
||||
# symbols. NetBox will not run without this defined. For more information, see
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY
|
||||
SECRET_KEY = ''
|
||||
|
||||
# Redis database settings. The Redis database is used for caching and background processing such as webhooks
|
||||
@@ -106,7 +107,7 @@ EXEMPT_VIEW_PERMISSIONS = [
|
||||
]
|
||||
|
||||
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
|
||||
# https://docs.djangoproject.com/en/1.11/topics/logging/
|
||||
# https://docs.djangoproject.com/en/stable/topics/logging/
|
||||
LOGGING = {}
|
||||
|
||||
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
|
||||
@@ -154,6 +155,10 @@ PREFER_IPV4 = False
|
||||
# this setting is derived from the installed location.
|
||||
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
|
||||
|
||||
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
||||
# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use
|
||||
# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only
|
||||
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||
@@ -167,7 +172,7 @@ TIME_ZONE = 'UTC'
|
||||
WEBHOOKS_ENABLED = False
|
||||
|
||||
# Date/time formatting. See the following link for supported formats:
|
||||
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
|
||||
# https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
|
||||
DATE_FORMAT = 'N j, Y'
|
||||
SHORT_DATE_FORMAT = 'Y-m-d'
|
||||
TIME_FORMAT = 'g:i a'
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.6.6'
|
||||
VERSION = '2.6.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -364,6 +364,7 @@ CACHEOPS = {
|
||||
'auth.user': {'ops': 'get', 'timeout': 60 * 15},
|
||||
'auth.*': {'ops': ('fetch', 'get')},
|
||||
'auth.permission': {'ops': 'all'},
|
||||
'circuits.*': {'ops': 'all'},
|
||||
'dcim.*': {'ops': 'all'},
|
||||
'ipam.*': {'ops': 'all'},
|
||||
'extras.*': {'ops': 'all'},
|
||||
|
||||
@@ -457,6 +457,14 @@ table.report th a {
|
||||
width: 80px;
|
||||
border: 1px solid grey;
|
||||
}
|
||||
.inline-color-block {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
border: 1px solid grey;
|
||||
border-radius: .25em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
|
||||
from .models import Secret, SecretRole
|
||||
|
||||
@@ -14,7 +14,7 @@ class SecretRoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SecretFilter(CustomFieldFilterSet):
|
||||
class SecretFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$.ajax({
|
||||
url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors",
|
||||
url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors_detail",
|
||||
dataType: 'json',
|
||||
success: function(json) {
|
||||
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
|
||||
$.each(json['get_lldp_neighbors_detail'], function(iface, neighbors) {
|
||||
var neighbor = neighbors[0];
|
||||
var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1"));
|
||||
|
||||
@@ -69,8 +69,8 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// Clean up hostnames/interfaces learned via LLDP
|
||||
var neighbor_host = neighbor['hostname'] || ""; // sanitize hostname if it's null to avoid breaking the split func
|
||||
var neighbor_port = neighbor['port'] || ""; // sanitize port if it's null to avoid breaking the split func
|
||||
var neighbor_host = neighbor['remote_system_name'] || ""; // sanitize hostname if it's null to avoid breaking the split func
|
||||
var neighbor_port = neighbor['remote_port'] || ""; // sanitize port if it's null to avoid breaking the split func
|
||||
var lldp_device = neighbor_host.split(".")[0]; // Strip off any trailing domain name
|
||||
var lldp_interface = neighbor_port.split(".")[0]; // Strip off any trailing subinterface ID
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='console-server-port' %}?return_url={{ device.get_absolute_url }}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='console-port' %}?return_url={{ device.get_absolute_url }}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
<td class="text-nowrap">
|
||||
{% if iface.cable %}
|
||||
<a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a>
|
||||
{% if iface.cable.color %}
|
||||
<span class="inline-color-block" style="background-color: #{{ iface.cable.color }}"> </span>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
@@ -121,6 +121,18 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body rendered-markdown">
|
||||
{% if powerfeed.comments %}
|
||||
{{ powerfeed.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
<li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:profile' %}">Profile</a>
|
||||
</li>
|
||||
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:change_password' %}">Change Password</a>
|
||||
</li>
|
||||
{% if not request.user.ldap_username %}
|
||||
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:change_password' %}">Change Password</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:token_list' %}">API Tokens</a>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
|
||||
from .models import Tenant, TenantGroup
|
||||
|
||||
@@ -13,7 +13,7 @@ class TenantGroupFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class TenantFilter(CustomFieldFilterSet):
|
||||
class TenantFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
@@ -95,6 +95,11 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
||||
template_name = 'users/change_password.html'
|
||||
|
||||
def get(self, request):
|
||||
# LDAP users cannot change their password here
|
||||
if getattr(request.user, 'ldap_username'):
|
||||
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
|
||||
return redirect('user:profile')
|
||||
|
||||
form = PasswordChangeForm(user=request.user)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import django_filters
|
||||
from dcim.forms import MACAddressField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from dcim.forms import MACAddressField
|
||||
from extras.models import Tag
|
||||
|
||||
|
||||
@@ -62,8 +61,15 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
|
||||
"""
|
||||
Filters for a set of Models, including all descendant models within a Tree. Example: [<Region: R1>,<Region: R2>]
|
||||
"""
|
||||
|
||||
def get_filter_predicate(self, v):
|
||||
# null value filtering
|
||||
if v is None:
|
||||
return {self.field_name.replace('in', 'isnull'): True}
|
||||
return super().get_filter_predicate(v)
|
||||
|
||||
def filter(self, qs, value):
|
||||
value = [node.get_descendants(include_self=True) for node in value]
|
||||
value = [node.get_descendants(include_self=True) if not isinstance(node, str) else node for node in value]
|
||||
return super().filter(qs, value)
|
||||
|
||||
|
||||
|
||||
9
netbox/utilities/querysets.py
Normal file
9
netbox/utilities/querysets.py
Normal file
@@ -0,0 +1,9 @@
|
||||
class DummyQuerySet:
|
||||
"""
|
||||
A fake QuerySet that can be used to cache relationships to objects that have been deleted.
|
||||
"""
|
||||
def __init__(self, queryset):
|
||||
self._cache = [obj for obj in queryset.all()]
|
||||
|
||||
def all(self):
|
||||
return self._cache
|
||||
62
netbox/utilities/tests/test_filters.py
Normal file
62
netbox/utilities/tests/test_filters.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
import django_filters
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
|
||||
|
||||
class TreeNodeMultipleChoiceFilterTest(TestCase):
|
||||
|
||||
class SiteFilterSet(django_filters.FilterSet):
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='region__in',
|
||||
to_field_name='slug',
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
|
||||
self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
|
||||
self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='test-site1')
|
||||
self.site2 = Site.objects.create(region=self.region2, name='Test Site 2', slug='test-site2')
|
||||
self.site3 = Site.objects.create(region=None, name='Test Site 3', slug='test-site3')
|
||||
|
||||
self.queryset = Site.objects.all()
|
||||
|
||||
def test_filter_single(self):
|
||||
|
||||
kwargs = {'region': ['test-region-1']}
|
||||
qs = self.SiteFilterSet(kwargs, self.queryset).qs
|
||||
|
||||
self.assertEqual(qs.count(), 1)
|
||||
self.assertEqual(qs[0], self.site1)
|
||||
|
||||
def test_filter_multiple(self):
|
||||
|
||||
kwargs = {'region': ['test-region-1', 'test-region-2']}
|
||||
qs = self.SiteFilterSet(kwargs, self.queryset).qs
|
||||
|
||||
self.assertEqual(qs.count(), 2)
|
||||
self.assertEqual(qs[0], self.site1)
|
||||
self.assertEqual(qs[1], self.site2)
|
||||
|
||||
def test_filter_null(self):
|
||||
|
||||
kwargs = {'region': [settings.FILTERS_NULL_CHOICE_VALUE]}
|
||||
qs = self.SiteFilterSet(kwargs, self.queryset).qs
|
||||
|
||||
self.assertEqual(qs.count(), 1)
|
||||
self.assertEqual(qs[0], self.site3)
|
||||
|
||||
def test_filter_combined(self):
|
||||
|
||||
kwargs = {'region': ['test-region-1', settings.FILTERS_NULL_CHOICE_VALUE]}
|
||||
qs = self.SiteFilterSet(kwargs, self.queryset).qs
|
||||
|
||||
self.assertEqual(qs.count(), 2)
|
||||
self.assertEqual(qs[0], self.site1)
|
||||
self.assertEqual(qs[1], self.site3)
|
||||
@@ -99,7 +99,7 @@ def serialize_object(obj, extra=None):
|
||||
# Include any custom fields
|
||||
if hasattr(obj, 'get_custom_fields'):
|
||||
data['custom_fields'] = {
|
||||
field.name: str(value) for field, value in obj.get_custom_fields().items()
|
||||
field: str(value) for field, value in obj.cf.items()
|
||||
}
|
||||
|
||||
# Include any tags
|
||||
|
||||
@@ -4,7 +4,7 @@ from netaddr import EUI
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import DeviceRole, Interface, Platform, Region, Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
@@ -27,7 +27,7 @@ class ClusterGroupFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class ClusterFilter(CustomFieldFilterSet):
|
||||
class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -81,7 +81,7 @@ class ClusterFilter(CustomFieldFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
@@ -16,7 +16,7 @@ graphviz==0.10.1
|
||||
Jinja2==2.10.1
|
||||
Markdown==2.6.11
|
||||
netaddr==0.7.19
|
||||
Pillow==6.0.0
|
||||
Pillow==6.2.0
|
||||
psycopg2-binary==2.8.3
|
||||
py-gfm==0.1.4
|
||||
pycryptodome==3.8.2
|
||||
|
||||
@@ -9,6 +9,24 @@
|
||||
|
||||
exec 1>&2
|
||||
|
||||
EXIT=0
|
||||
RED='\033[0;31m'
|
||||
NOCOLOR='\033[0m'
|
||||
|
||||
echo "Validating PEP8 compliance..."
|
||||
pycodestyle --ignore=W504,E501 netbox/
|
||||
if [ $? != 0 ]; then
|
||||
EXIT=1
|
||||
fi
|
||||
|
||||
echo "Checking for missing migrations..."
|
||||
python netbox/manage.py makemigrations --dry-run --check
|
||||
if [ $? != 0 ]; then
|
||||
EXIT=1
|
||||
fi
|
||||
|
||||
if [ $EXIT != 0 ]; then
|
||||
printf "${RED}COMMIT FAILED${NOCOLOR}\n"
|
||||
fi
|
||||
|
||||
exit $EXIT
|
||||
|
||||
11
upgrade.sh
11
upgrade.sh
@@ -20,6 +20,17 @@ COMMAND="${PIP} install -r requirements.txt --upgrade"
|
||||
echo "Updating required Python packages ($COMMAND)..."
|
||||
eval $COMMAND
|
||||
|
||||
# Validate Python dependencies
|
||||
COMMAND="${PIP} check"
|
||||
echo "Validating Python dependencies ($COMMAND)..."
|
||||
eval $COMMAND || (
|
||||
echo "******** PLEASE FIX THE DEPENDENCIES BEFORE CONTINUING ********"
|
||||
echo "* Manually install newer version(s) of the highlighted packages"
|
||||
echo "* so that 'pip3 check' passes. For more information see:"
|
||||
echo "* https://github.com/pypa/pip/issues/988"
|
||||
exit 1
|
||||
)
|
||||
|
||||
# Apply any database migrations
|
||||
COMMAND="${PYTHON} netbox/manage.py migrate"
|
||||
echo "Applying database migrations ($COMMAND)..."
|
||||
|
||||
Reference in New Issue
Block a user