Compare commits

..

111 Commits

Author SHA1 Message Date
Jeremy Stretch
77e2b0e4ba Merge pull request #5512 from netbox-community/develop
Release v2.10.2
2020-12-21 16:17:49 -05:00
Jeremy Stretch
e196d82e76 Release v2.10.2 2020-12-21 16:03:43 -05:00
Jeremy Stretch
23f334e5df Fixes #5488: Fix caching error when viewing cable trace after toggling cable status 2020-12-21 15:30:34 -05:00
Robin Schneider
c5a265e828 Use HTTPS URL schema everywhere (#5505)
* Use HTTPS everywhere (mechanical edit using util from https-everywhere)

```Shell
node ~/src/EFForg/https-everywhere/utils/rewriter/rewriter.js .
git checkout netbox/project-static/
```

A few additional changes where reset manually before the commit.

* Use HTTPS everywhere (mechanical edit using util from opening_hours.js)

```Shell
make -f ~/src/opening-hours/opening_hours.js/Makefile qa-https-everywhere
git checkout netbox/project-static/
git checkout netbox/*/tests
```
2020-12-21 12:02:30 -05:00
Jeremy Stretch
278c881059 as_range: Catch TypeErrors 2020-12-21 11:54:35 -05:00
Jeremy Stretch
b09112941a Fixes #5510: Fix filtering by boolean custom fields 2020-12-21 11:47:11 -05:00
Jeremy Stretch
158ed5765b Fixes #5507: Fix custom field data assignment via UI for IP addresses, secrets 2020-12-21 09:43:15 -05:00
Jeremy Stretch
1e5778e6db Clean up prefix hierarchy annotation 2020-12-18 15:43:15 -05:00
Jeremy Stretch
442dee72b5 Fixes #5499: Fix filtering of displayed device/VM interfaces by regex 2020-12-18 15:02:52 -05:00
Daniel Sheppard
f2d028ba29 Update version-2.10.md 2020-12-18 11:33:28 -06:00
Jeremy Stretch
b1402f3bae Fixes #5498: Fix filtering rack reservations by username 2020-12-18 12:18:59 -05:00
Jeremy Stretch
6ca9e2a777 Closes #5496: Add form field to filter rack reservation by user 2020-12-18 12:09:41 -05:00
Daniel Sheppard
dac93b9840 Fixes #5254 - Require plugin authors to set zip_safe=False 2020-12-17 16:18:10 -06:00
Jeremy Stretch
34a0e2e72d Closes #5489: Add filters for type and width to racks list 2020-12-17 15:55:22 -05:00
Jeremy Stretch
af2777c580 Fixes #5487: Support filtering rack type/width with multiple values 2020-12-17 15:50:47 -05:00
Jeremy Stretch
4a066a1913 Rename get_subquery() to count_related() 2020-12-17 14:47:49 -05:00
Jeremy Stretch
91083fd6c5 Call Coalesce() inside get_queryset() 2020-12-17 14:45:50 -05:00
Jeremy Stretch
202e7d0ebf Fixes #5486: Optimize retrieval of config context data for device/VM REST API views 2020-12-17 14:03:10 -05:00
Jeremy Stretch
7449f36f22 Merge pull request #5485 from glennmatthews/gfm-doc-fix
Docs fix: Django templating language is no longer supported for export templates
2020-12-17 10:06:03 -05:00
Glenn Matthews
dc6e7cd56c Django templating language is no longer supported for export templates 2020-12-17 09:48:22 -05:00
Jeremy Stretch
26426c1431 Fixes #5484: Fix "tagged" indication in VLAN members list 2020-12-17 09:46:50 -05:00
Jeremy Stretch
2fba20acda Merge pull request #5481 from cloos/5123-cf-select-changelog
Fixes #5123: Add tests for custom field select changelog
2020-12-16 14:29:27 -05:00
Jeremy Stretch
3c887b0dd9 Fixes #5468: Fix unlocking secrets from device/VM view 2020-12-16 13:48:44 -05:00
Jeremy Stretch
52c9e3ecef Run CI for pull requests 2020-12-16 13:23:45 -05:00
Christian Loos
2ffdc46f81 Fixes #5123: Add tests for custom field select changelog 2020-12-16 18:50:55 +01:00
Jeremy Stretch
577c86fb80 Fixes #5478: Fix display of route target description 2020-12-16 10:38:09 -05:00
Jeremy Stretch
4ce7dfa55e Fixes #5473: Fix alignment of rack names in elevations list 2020-12-16 09:26:22 -05:00
Jeremy Stretch
bd144c996a PRVB 2020-12-15 22:16:26 -05:00
Jeremy Stretch
2c1a60b965 Merge pull request #5472 from netbox-community/develop
Release v2.10.1
2020-12-15 22:14:19 -05:00
Jeremy Stretch
2dad44c93f Release v2.10.1 2020-12-15 22:03:54 -05:00
Jeremy Stretch
c835ec5102 Fixes #5470: Fix exception when making OPTIONS request for a REST API list endpoint 2020-12-15 21:04:47 -05:00
Jeremy Stretch
e2d17b1999 #5444: Also fix the enabled field' 2020-12-15 16:52:58 -05:00
Jeremy Stretch
3c2ddf1f69 Fixes #5444: Don't force overwriting of mgmt_only when bulk editing interfaces 2020-12-15 16:17:52 -05:00
Jeremy Stretch
d64fc261e2 Tweak validation for custom date fields 2020-12-15 16:00:18 -05:00
Jeremy Stretch
34643f536e Fixes #5466: Fix validation for required custom fields 2020-12-15 15:56:42 -05:00
Jeremy Stretch
6a3dab0142 Fixes #5465: Correct return URL when disconnecting a cable from a device 2020-12-15 13:52:35 -05:00
Daniel Sheppard
3c2b2f26b9 Fixes: #5450 - Added Coalesce to viewsets for the _count functions 2020-12-15 12:50:28 -06:00
Jeremy Stretch
67f9e16905 Fixes #5453: Correct change log representation when creating a cable 2020-12-15 13:37:02 -05:00
Daniel Sheppard
14a46f82ee Fixes: #5463 - Add check for ip_addresses attribute on circuittermination connected_endpoint 2020-12-15 12:30:42 -06:00
Jeremy Stretch
974500c281 Add test for #5461 2020-12-15 12:01:53 -05:00
Daniel Sheppard
1f4954bf0e Fixes: #5461 - Assign object=rack in netbox/templates/dcim/rack_elevation_list.html 2020-12-15 08:39:27 -06:00
Daniel Sheppard
9615dc0638 Fixes: #5458 - Fix devicetype_component_table.html 2020-12-14 16:30:10 -06:00
Jeremy Stretch
c1e56a3717 Post-release version bump 2020-12-14 14:41:01 -05:00
Jeremy Stretch
91ea12344c Merge pull request #5455 from netbox-community/develop
Release v2.10.0
2020-12-14 14:38:23 -05:00
Jeremy Stretch
f572f66426 Release v2.10.0 2020-12-14 14:27:14 -05:00
Jeremy Stretch
872ba89cad Merge pull request #5454 from netbox-community/develop-2.10
Stage v2.10 release
2020-12-14 14:19:14 -05:00
Jeremy Stretch
1f2daaad59 Merge branch 'develop' into develop-2.10 2020-12-14 14:08:21 -05:00
esljaz
230b503b7a Clarify REMOTE_AUTH_HEADER documentation (#5430) 2020-12-14 14:03:13 -05:00
Jeremy Stretch
a371c08c86 Merge branch 'develop' into develop-2.10 2020-12-11 16:57:04 -05:00
Jeremy Stretch
55e8d37fb0 Post-release version bump 2020-12-11 16:49:13 -05:00
Jeremy Stretch
3f5a2b9c7a Merge pull request #5448 from netbox-community/develop
Release v2.9.11
2020-12-11 16:47:30 -05:00
Jeremy Stretch
a589022fcc Merge branch 'master' into develop 2020-12-11 16:46:41 -05:00
Jeremy Stretch
31fc8076e3 Release v2.9.11 2020-12-11 16:33:08 -05:00
John Anderson
53f330dbe8 fixes #5387 - Fix error when rendering config contexts when objects have multiple tags assigned (#5447) 2020-12-11 16:20:46 -05:00
Jeremy Stretch
13a13f3943 Fixes #5446: Fix validation for plugin version and required settings 2020-12-11 16:05:50 -05:00
Jeremy Stretch
00ca227292 Fixes #5433: Exclude SVG files from front/rear image upload for device types (currently unsupported) 2020-12-11 09:27:00 -05:00
Jeremy Stretch
5c8ed73608 Closes #5439: Add CS and SN fiber port types 2020-12-11 08:56:02 -05:00
Jeremy Stretch
5df01ed3e6 Encourage people to use GitHub discussions in place of the mailing list 2020-12-10 15:59:42 -05:00
Jeremy Stretch
43d7d5570e Merge branch 'develop' into develop-2.10 2020-12-10 10:26:15 -05:00
Jeremy Stretch
e062cbb79f Fixes #5436: Show assigned IP addresses in interfaces list 2020-12-10 08:52:55 -05:00
Jeremy Stretch
27e27788cd Closes #5424: Allow passing Python code to nbshell using --command 2020-12-08 16:14:52 -05:00
Jeremy Stretch
cc5c000a6d Move CI to GitHub Actions (#5431)
Replaces Travis CI with GitHub Actions
2020-12-08 14:55:06 -05:00
Jeremy Stretch
2c7fc5df75 Closes #5411: Include cable tags in trace view 2020-12-04 15:56:33 -05:00
Jeremy Stretch
f7e61cc153 Fixes #5417: Fix exception when viewing a device installed within a device bay 2020-12-04 15:21:33 -05:00
Jeremy Stretch
e7f64334c0 Fixes #5410: Restore tags field on cable connection forms 2020-12-04 08:57:19 -05:00
Jeremy Stretch
584b8109a0 Fixes #5408: Fix updating secrets without setting new plaintext 2020-12-03 15:23:39 -05:00
Jeremy Stretch
d75696b30a Fixes #5407: Add direct link to secret on secrets list 2020-12-03 14:15:48 -05:00
Jeremy Stretch
22bb700f94 Fixes #5396: Fix uniqueness constraint for virtual machine names 2020-12-03 13:40:42 -05:00
Jeremy Stretch
a4b2e1f2e2 Fix typo (from PR #5393) 2020-12-03 12:13:08 -05:00
Jeremy Stretch
6ee161bdc6 Add references to GitHub discussions 2020-12-03 12:11:44 -05:00
Hans Erasmus
13349dab6d Fixed small typo (#5393)
Very small typo fixed
2020-12-03 11:48:34 -05:00
Jeremy Stretch
6a90295505 Merge pull request #5377 from netbox-community/develop
Release v2.9.10
2020-11-24 16:47:21 -05:00
Jeremy Stretch
8ba50d0cf2 Merge pull request #5332 from netbox-community/develop
Release v2.9.9
2020-11-09 20:55:28 -05:00
Jeremy Stretch
6dcb2de28f Merge pull request #5293 from netbox-community/develop
Release v2.9.8
2020-10-30 10:38:17 -04:00
Jeremy Stretch
1bbfc6da25 Merge pull request #5236 from netbox-community/develop
Release v2.9.7
2020-10-12 09:49:51 -04:00
Jeremy Stretch
eecd51e08b Merge pull request #5230 from netbox-community/develop
Release v2.9.6
2020-10-09 13:27:42 -04:00
Jeremy Stretch
26abd1f4e2 Merge pull request #5228 from netbox-community/develop
Release v2.9.5
2020-10-09 13:01:24 -04:00
Jeremy Stretch
28925c12eb Merge pull request #5172 from netbox-community/develop
Release v2.9.4
2020-09-23 15:38:09 -04:00
Jeremy Stretch
d0ac4332ab Merge pull request #5100 from netbox-community/develop
Release v2.9.3
2020-09-04 15:55:47 -04:00
Jeremy Stretch
8e5aa69321 Merge pull request #5062 from netbox-community/develop
Release v2.9.2
2020-08-27 14:13:58 -04:00
Jeremy Stretch
848cfeb353 Merge pull request #5034 from netbox-community/develop
Release v2.9.1 - 2020-08-22
2020-08-22 21:05:08 -04:00
Jeremy Stretch
7b05a18173 Merge pull request #5026 from netbox-community/develop
Release v2.9.0
2020-08-21 09:52:01 -04:00
Jeremy Stretch
2ce99929e2 Merge pull request #4947 from netbox-community/develop
v2.8.9 - 2020-08-04
2020-08-04 12:39:55 -04:00
Jeremy Stretch
f1e82a3647 Merge pull request #4873 from netbox-community/develop
Release v2.8.8
2020-07-21 12:21:04 -04:00
Jeremy Stretch
1c5af01a82 Merge pull request #4816 from netbox-community/develop
Release v2.8.7
2020-07-02 09:42:41 -04:00
Jeremy Stretch
bac3ace8fc Merge pull request #4762 from netbox-community/develop
Release v2.8.6
2020-06-15 14:45:01 -04:00
Jeremy Stretch
68599351aa Merge pull request #4693 from netbox-community/develop
Release v2.8.5
2020-05-26 16:27:36 -04:00
Jeremy Stretch
86755029ef Merge pull request #4642 from netbox-community/develop
Release v2.8.4
2020-05-13 17:31:12 -04:00
Jeremy Stretch
c507ab30e9 Merge pull request #4594 from netbox-community/develop
Release v2.8.3
2020-05-06 23:49:27 -04:00
Jeremy Stretch
7d1614b933 Merge pull request #4589 from netbox-community/develop
Release v2.8.2
2020-05-06 15:14:45 -04:00
Jeremy Stretch
a77d1e502c Merge pull request #4528 from netbox-community/develop
Release v2.8.1
2020-04-23 10:24:08 -04:00
Jeremy Stretch
d79ed76d80 Merge pull request #4479 from netbox-community/develop
Release v2.8.0
2020-04-13 11:29:33 -04:00
Jeremy Stretch
ccf8059452 Merge pull request #4467 from netbox-community/develop
Release v2.7.12
2020-04-08 13:31:49 -04:00
Jeremy Stretch
3d3d1bc623 Merge pull request #4417 from netbox-community/develop
Release v2.7.11
2020-03-27 12:47:28 -04:00
Jeremy Stretch
e66d065b6d Merge pull request #4339 from netbox-community/develop
Release v2.7.10
2020-03-10 13:59:50 -04:00
Jeremy Stretch
c1ef87e009 Merge pull request #4321 from netbox-community/develop
Release v2.7.9
2020-03-06 11:23:42 -05:00
Jeremy Stretch
3c249a40a0 Merge pull request #4275 from netbox-community/develop
Release v2.7.8
2020-02-25 15:09:41 -05:00
Jeremy Stretch
5092641157 Merge pull request #4216 from netbox-community/develop
Release v2.7.7
2020-02-20 14:56:27 -05:00
Jeremy Stretch
472a45ddec Merge pull request #4167 from netbox-community/develop
Release v2.7.6
2020-02-13 21:50:44 -05:00
Jeremy Stretch
120cbb0159 Merge pull request #4165 from netbox-community/develop
Release v2.7.5
2020-02-13 15:36:50 -05:00
Jeremy Stretch
68fbd9b017 Merge pull request #4088 from netbox-community/develop
Release v2.7.4
2020-02-04 15:04:34 -05:00
Jeremy Stretch
3143f75a38 Merge pull request #4035 from netbox-community/develop
Release v2.7.3
2020-01-28 16:39:09 -05:00
Jeremy Stretch
e13d4ffe60 Merge pull request #3980 from netbox-community/develop
Release v2.7.2
2020-01-21 15:12:00 -05:00
Jeremy Stretch
295d4f0394 Merge pull request #3946 from netbox-community/develop
Release v2.7.1
2020-01-16 23:46:40 -05:00
Jeremy Stretch
ea91e09a1b Merge pull request #3938 from netbox-community/develop
Release v2.7.0
2020-01-16 13:03:42 -05:00
Jeremy Stretch
946779000f Merge pull request #3908 from netbox-community/develop
Release v2.6.12
2020-01-13 13:25:21 -05:00
Jeremy Stretch
5c07b6dc1d Merge pull request #3837 from netbox-community/develop
Release v2.6.11
2020-01-03 14:00:10 -05:00
Jeremy Stretch
25c3c1b431 Merge pull request #3829 from netbox-community/develop
Fix v2.6.10 release date
2020-01-02 17:05:21 -05:00
Jeremy Stretch
a0ae7a227d Merge pull request #3828 from netbox-community/develop
Release v2.6.10
2020-01-02 17:02:52 -05:00
Jeremy Stretch
50df3acd26 Merge pull request #3774 from netbox-community/develop
Release v2.6.9
2019-12-16 16:32:00 -05:00
Jeremy Stretch
425670f52a Merge pull request #3745 from netbox-community/develop
Release v2.6.8
2019-12-10 10:47:48 -05:00
75 changed files with 716 additions and 356 deletions

View File

@@ -7,11 +7,9 @@ about: Report a reproducible bug in the current release of NetBox
<!--
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for reproducible bugs. If you need assistance with
NetBox installation, or if you have a general question, DO NOT open an
issue. Instead, post to our mailing list:
https://groups.google.com/g/netbox-discuss
This form is only for reporting reproducible bugs. If you need assistance
with NetBox installation, or if you have a general question, please start a
discussion instead: https://github.com/netbox-community/netbox/discussions
Please describe the environment in which you are running NetBox. Be sure
that you are running an unmodified instance of the latest stable release

View File

@@ -8,10 +8,8 @@ about: Propose a new NetBox feature or enhancement
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for proposing specific new features or enhancements.
If you have a general idea or question, please post to our mailing list
instead of opening an issue:
https://groups.google.com/g/netbox-discuss
If you have a general idea or question, please start a discussion instead:
https://github.com/netbox-community/netbox/discussions
NOTE: Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which significantly extend NetBox's feature scope.

50
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
services:
redis:
image: redis
ports:
- 6379:6379
postgres:
image: postgres
env:
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies & set up configuration
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py
- name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 netbox/
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/
- name: Show coverage report
run: coverage report --skip-covered --omit *migrations*

View File

@@ -1,20 +0,0 @@
sudo: required
services:
- postgresql
- redis-server
addons:
postgresql: "9.6"
language: python
python:
- "3.6"
- "3.7"
- "3.8"
install:
- pip install -r requirements.txt
- pip install pycodestyle
- pip install coverage
before_script:
- psql --version
- psql -U postgres -c 'SELECT version();'
script:
- ./scripts/cibuild.sh

View File

@@ -4,15 +4,31 @@ If you encounter any issues installing or using NetBox, try one of the
following resources to get assistance. Please **do not** open a GitHub issue
except to report bugs or request features.
### GitHub Discussions
GitHub's discussions are the best place to get help or propose rough ideas for
new functionality. Their integration with GitHub allows for easily cross-
referencing and converting posts to issues as needed. There are several
categories for discussions:
* **General** - General community discussion
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
feature request
* **Q&A** - Request help with installing or using NetBox
### Mailing List
We have established a Google Groups Mailing List for issues and general
discussion. This is the best forum for obtaining assistance with NetBox
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
for general discussion, however we're encouraging people to use GitHub
discussions where possible, as it's much easier for newcomers to review past
discussions.
### Slack
For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.
## Reporting Bugs
@@ -171,7 +187,7 @@ overlooked.
* Official channels for communication include:
* GitHub issues/pull requests
* GitHub issues, pull requests, and discussions
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)

View File

@@ -7,20 +7,20 @@ 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
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status
| | status |
|-------------|------------|
| **master** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=master)](https://travis-ci.com/netbox-community/netbox/) |
| **develop** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=develop)](https://travis-ci.com/netbox-community/netbox/) |
| **master** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) |
| **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) |
### Screenshots
@@ -36,19 +36,22 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
## Installation
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`.
Please see [the documentation](https://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`.
## Providing Feedback
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/g/netbox-discuss).
The best platform for general feedback, assistance, and other discussion is our
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
To report a bug or request a specific feature, please open a GitHub issue using
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
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
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

@@ -4,10 +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 may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2.
!!! warning
Support for Django's native templating logic will be removed in NetBox v2.10.
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
The list of objects returned from the database when rendering an export template 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:

View File

@@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
Default: None
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
```python
BASE_PATH = 'netbox/'
@@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
## NAPALM_ARGS
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
```python
NAPALM_ARGS = {
@@ -460,7 +460,7 @@ NetBox can be configured to support remote user authentication by inferring user
Default: `'HTTP_REMOTE_USER'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.)
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
---

View File

@@ -7,7 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
Communication among developers should always occur via public channels:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred).
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
## Governance

View File

@@ -1,9 +1,9 @@
# HTTP Server Setup
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info
For the sake of brevity, only Ubuntu 20.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate

View File

@@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
```
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
### General Server Configuration

View File

@@ -63,11 +63,15 @@ setup(
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:

View File

@@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
#### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
### Enhancements

View File

@@ -1,27 +1,46 @@
# NetBox v2.10
## v2.10-beta2 (2020-12-03)
## v2.10.2 (2020-12-21)
### Enhancements
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields
* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import
* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list
* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user
### Bug Fixes
* [#5176](https://github.com/netbox-community/netbox/issues/5176) - Enforce content type restrictions when creating objects via the REST API
* [#5358](https://github.com/netbox-community/netbox/issues/5358) - Fix user table configuration for VM interfaces
* [#5374](https://github.com/netbox-community/netbox/issues/5374) - Fix exception thrown when tracing mid-point
* [#5376](https://github.com/netbox-community/netbox/issues/5376) - Correct invalid custom field filter logic values
* [#5395](https://github.com/netbox-community/netbox/issues/5395) - Fix cable tracing for rear ports with no corresponding front port
### Other Changes
* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types`
* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description
* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list
* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views
* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values
* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status
* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username
* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex
* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets
* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields
---
## v2.10-beta1 (2020-11-17)
## v2.10.1 (2020-12-15)
### Bug Fixes
* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces
* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value
* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable
* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception
* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception
* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception
* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device
* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields
* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint
---
## v2.10.0 (2020-12-14)
**NOTE:** This release completely removes support for embedded graphs.
@@ -95,6 +114,9 @@ All end-to-end cable paths are now cached using the new CablePath backend model.
* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis
* [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window
* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`)
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields
* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import
* [#5411](https://github.com/netbox-community/netbox/issues/5411) - Include cable tags in trace view
### Other Changes
@@ -102,6 +124,7 @@ All end-to-end cable paths are now cached using the new CablePath backend model.
* [#2755](https://github.com/netbox-community/netbox/issues/2755) - Switched from Font Awesome/Glyphicons to Material Design icons
* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs
* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates
* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types`
* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method
* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models
* [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list

View File

@@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
#### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
### Enhancements

View File

@@ -1,10 +1,23 @@
# NetBox v2.9
## v2.9.11 (FUTURE)
## v2.9.11 (2020-12-11)
### Enhancements
* [#5424](https://github.com/netbox-community/netbox/issues/5424) - Allow passing Python code to `nbshell` using `--command`
* [#5439](https://github.com/netbox-community/netbox/issues/5439) - Add CS and SN fiber port types
### Bug Fixes
* [#5383](https://github.com/netbox-community/netbox/issues/5383) - Fix setting user password via REST API
* [#5396](https://github.com/netbox-community/netbox/issues/5396) - Fix uniqueness constraint for virtual machine names
* [#5387](https://github.com/netbox-community/netbox/issues/5387) - Fix error when rendering config contexts when objects have multiple tags assigned
* [#5407](https://github.com/netbox-community/netbox/issues/5407) - Add direct link to secret on secrets list
* [#5408](https://github.com/netbox-community/netbox/issues/5408) - Fix updating secrets without setting new plaintext
* [#5410](https://github.com/netbox-community/netbox/issues/5410) - Restore tags field on cable connection forms
* [#5433](https://github.com/netbox-community/netbox/issues/5433) - Exclude SVG files from front/rear image upload for device types (currently unsupported)
* [#5436](https://github.com/netbox-community/netbox/issues/5436) - Show assigned IP addresses in interfaces list
* [#5446](https://github.com/netbox-community/netbox/issues/5446) - Fix validation for plugin version and required settings
---

View File

@@ -2,7 +2,7 @@
## What is a REST API?
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
* `GET`: Retrieve an object or list of objects
* `POST`: Create an object

View File

@@ -6,7 +6,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from dcim.api.views import PathEndpointMixin
from extras.api.views import CustomFieldModelViewSet
from netbox.api.views import ModelViewSet
from utilities.utils import get_subquery
from utilities.utils import count_related
from . import serializers
@@ -24,7 +24,7 @@ class CircuitsRootView(APIRootView):
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=get_subquery(Circuit, 'provider')
circuit_count=count_related(Circuit, 'provider')
)
serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet
@@ -36,7 +36,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type')
circuit_count=count_related(Circuit, 'type')
)
serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet

View File

@@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery
from utilities.utils import count_related
from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -18,7 +18,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
@@ -67,7 +67,7 @@ class ProviderBulkImportView(generic.BulkImportView):
class ProviderBulkEditView(generic.BulkEditView):
queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
@@ -76,7 +76,7 @@ class ProviderBulkEditView(generic.BulkEditView):
class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
@@ -88,7 +88,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type')
circuit_count=count_related(Circuit, 'type')
)
table = tables.CircuitTypeTable
@@ -110,7 +110,7 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type')
circuit_count=count_related(Circuit, 'type')
)
table = tables.CircuitTypeTable
@@ -139,7 +139,7 @@ class CircuitView(generic.ObjectView):
).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
if termination_a and termination_a.connected_endpoint:
if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination
@@ -148,7 +148,7 @@ class CircuitView(generic.ObjectView):
).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
if termination_z and termination_z.connected_endpoint:
if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return {

View File

@@ -30,7 +30,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from utilities.api import get_serializer_for_model
from utilities.utils import get_subquery
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@@ -119,12 +119,12 @@ class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.prefetch_related(
'region', 'tenant', 'tags'
).annotate(
device_count=get_subquery(Device, 'site'),
rack_count=get_subquery(Rack, 'site'),
prefix_count=get_subquery(Prefix, 'site'),
vlan_count=get_subquery(VLAN, 'site'),
circuit_count=get_subquery(Circuit, 'terminations__site'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
device_count=count_related(Device, 'site'),
rack_count=count_related(Rack, 'site'),
prefix_count=count_related(Prefix, 'site'),
vlan_count=count_related(VLAN, 'site'),
circuit_count=count_related(Circuit, 'terminations__site'),
virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
)
serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet
@@ -152,7 +152,7 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role')
rack_count=count_related(Rack, 'role')
)
serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet
@@ -166,8 +166,8 @@ class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant', 'tags'
).annotate(
device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack')
device_count=count_related(Device, 'rack'),
powerfeed_count=count_related(PowerFeed, 'rack')
)
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet
@@ -240,9 +240,9 @@ class RackReservationViewSet(ModelViewSet):
class ManufacturerViewSet(ModelViewSet):
queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet
@@ -254,7 +254,7 @@ class ManufacturerViewSet(ModelViewSet):
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=get_subquery(Device, 'device_type')
device_count=count_related(Device, 'device_type')
)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet
@@ -318,8 +318,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
virtualmachine_count=get_subquery(VirtualMachine, 'role')
device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role')
)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet
@@ -331,8 +331,8 @@ class DeviceRoleViewSet(ModelViewSet):
class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform')
)
serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet
@@ -342,7 +342,7 @@ class PlatformViewSet(ModelViewSet):
# Devices
#
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
@@ -596,7 +596,7 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=get_subquery(Device, 'virtual_chassis')
member_count=count_related(Device, 'virtual_chassis')
)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet
@@ -610,7 +610,7 @@ class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet

View File

@@ -887,6 +887,8 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH = 'lsh'
TYPE_LSH_APC = 'lsh-apc'
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
CHOICES = (
(
@@ -914,6 +916,8 @@ class PortTypeChoices(ChoiceSet):
(TYPE_SC, 'SC'),
(TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
(TYPE_SN, 'SN'),
(TYPE_SPLICE, 'Splice'),
)
)

View File

@@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
choices=RackStatusChoices,
null_value=None
)
type = django_filters.MultipleChoiceFilter(
choices=RackTypeChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackRole.objects.all(),
label='Role (ID)',
@@ -242,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit',
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit',
]
def search(self, queryset, name, value):
@@ -296,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
label='User (ID)',
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user',
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
label='User (name)',

View File

@@ -21,7 +21,7 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
@@ -690,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False,
widget=StaticSelect2Multiple()
)
type = forms.MultipleChoiceField(
choices=RackTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False,
widget=StaticSelect2Multiple()
)
role = DynamicModelMultipleChoiceField(
queryset=RackRole.objects.all(),
to_field_name='slug',
@@ -850,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
model = RackReservation
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
label='Search'
@@ -874,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
label='Rack group',
null_option='None'
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label='User',
widget=APISelectMultiple(
api_url='/api/users/users/',
)
)
tag = TagFilterField(model)
@@ -922,7 +941,14 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
'subdevice_role': StaticSelect2()
'subdevice_role': StaticSelect2(),
# Exclude SVG images (unsupported by PIL)
'front_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
}),
'rear_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
})
}
@@ -2832,7 +2858,7 @@ class InterfaceBulkCreateForm(
class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode'
'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
]),
BootstrapMixin,
AddRemoveTagsForm,
@@ -2848,6 +2874,15 @@ class InterfaceBulkEditForm(
disabled=True,
widget=forms.HiddenInput()
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label='Management only'
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,

View File

@@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
return instance
def __str__(self):
return self.label or '#{}'.format(self._pk)
pk = self.pk or self._pk
return self.label or f'#{pk}'
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])

View File

@@ -1,5 +1,6 @@
import logging
from cacheops import invalidate_obj
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
from django.db import transaction
@@ -30,6 +31,7 @@ def rebuild_paths(obj):
with transaction.atomic():
for cp in cable_paths:
invalidate_obj(cp.origin)
cp.delete()
create_cablepath(cp.origin)

View File

@@ -447,7 +447,8 @@ class DeviceInterfaceTable(InterfaceTable):
'connection', 'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
'class': lambda record: record.cable.get_status_class() if record.cable else '',
'data-name': lambda record: record.name,
}

View File

@@ -329,7 +329,7 @@ class RackTestCase(TestCase):
racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
)
Rack.objects.bulk_create(racks)
@@ -351,13 +351,11 @@ class RackTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
# TODO: Test for multiple values
params = {'type': RackTypeChoices.TYPE_2POST}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_width(self):
# TODO: Test for multiple values
params = {'width': RackWidthChoices.WIDTH_19IN}
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_u_height(self):
@@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
users = User.objects.all()[:2]
params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Filtering by username is broken
# params = {'user': [users[0].username, users[1].username]}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'user': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]

View File

@@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_rack_elevations(self):
"""
Test viewing the list of rack elevations.
"""
response = self.client.get(reverse('dcim:rack_elevation_list'))
self.assertHttpStatus(response, 200)
class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Manufacturer

View File

@@ -20,7 +20,7 @@ from secrets.models import Secret
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format, get_subquery
from utilities.utils import csv_format, count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@@ -254,7 +254,7 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
class RackRoleListView(generic.ObjectListView):
queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role')
rack_count=count_related(Rack, 'role')
)
table = tables.RackRoleTable
@@ -276,7 +276,7 @@ class RackRoleBulkImportView(generic.BulkImportView):
class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role')
rack_count=count_related(Rack, 'role')
)
table = tables.RackRoleTable
@@ -289,7 +289,7 @@ class RackListView(generic.ObjectListView):
queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type'
).annotate(
device_count=get_subquery(Device, 'rack')
device_count=count_related(Device, 'rack')
)
filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm
@@ -470,9 +470,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
table = tables.ManufacturerTable
@@ -494,7 +494,7 @@ class ManufacturerBulkImportView(generic.BulkImportView):
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer')
devicetype_count=count_related(DeviceType, 'manufacturer')
)
table = tables.ManufacturerTable
@@ -505,7 +505,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
class DeviceTypeListView(generic.ObjectListView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type')
instance_count=count_related(Device, 'device_type')
)
filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm
@@ -612,7 +612,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
class DeviceTypeBulkEditView(generic.BulkEditView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type')
instance_count=count_related(Device, 'device_type')
)
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
@@ -621,7 +621,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type')
instance_count=count_related(Device, 'device_type')
)
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
@@ -913,8 +913,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceRoleListView(generic.ObjectListView):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
vm_count=get_subquery(VirtualMachine, 'role')
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
table = tables.DeviceRoleTable
@@ -945,8 +945,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
class PlatformListView(generic.ObjectListView):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
vm_count=get_subquery(VirtualMachine, 'platform')
device_count=count_related(Device, 'platform'),
vm_count=count_related(VirtualMachine, 'platform')
)
table = tables.PlatformTable
@@ -2335,7 +2335,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
class VirtualChassisListView(generic.ObjectListView):
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
member_count=get_subquery(Device, 'virtual_chassis')
member_count=count_related(Device, 'virtual_chassis')
)
table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet
@@ -2565,7 +2565,7 @@ class PowerPanelListView(generic.ObjectListView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm
@@ -2615,7 +2615,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable

View File

@@ -21,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, get_subquery
from utilities.utils import copy_safe_request, count_related
from . import serializers
@@ -102,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(
tagged_items=get_subquery(TaggedItem, 'tag')
tagged_items=count_related(TaggedItem, 'tag')
)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet

View File

@@ -2,6 +2,7 @@ import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.forms import DateField, IntegerField, NullBooleanField
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
@@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter):
"""
def __init__(self, custom_field, *args, **kwargs):
self.custom_field = custom_field
if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
self.field_class = IntegerField
elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
self.field_class = NullBooleanField
elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
self.field_class = DateField
super().__init__(*args, **kwargs)
def filter(self, queryset, value):
self.field_name = f'custom_field_data__{self.field_name}'
# Skip filter on empty value
if value is None or not value.strip():
return queryset
# Apply the assigned filter logic (exact or loose)
if (
self.custom_field.type in EXACT_FILTER_TYPES or
self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
):
kwargs = {f'custom_field_data__{self.field_name}': value}
else:
kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
return queryset.filter(**kwargs)
if custom_field.type not in EXACT_FILTER_TYPES:
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
self.lookup_expr = 'icontains'
class CustomFieldModelFilterSet(django_filters.FilterSet):

View File

@@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm):
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
def save(self, commit=True):
def clean(self):
# Save custom field data on instance
for cf_name in self.custom_fields:
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
return super().save(commit)
return super().clean()
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):

View File

@@ -25,12 +25,18 @@ class Command(BaseCommand):
help = "Start the Django shell with all NetBox models already imported"
django_models = {}
def add_arguments(self, parser):
parser.add_argument(
'-c', '--command',
help='Python code to execute (instead of starting an interactive shell)',
)
def _lsmodels(self):
for app, models in self.django_models.items():
app_name = apps.get_app_config(app).verbose_name
print('{}:'.format(app_name))
print(f'{app_name}:')
for m in models:
print(' {}'.format(m))
print(f' {m}')
def get_namespace(self):
namespace = {}
@@ -46,7 +52,7 @@ class Command(BaseCommand):
# Constants
try:
app_constants = sys.modules['{}.constants'.format(app)]
app_constants = sys.modules[f'{app}.constants']
for name in dir(app_constants):
namespace[name] = getattr(app_constants, name)
except KeyError:
@@ -64,5 +70,10 @@ class Command(BaseCommand):
return namespace
def handle(self, **options):
# If Python code has been passed, execute it and exit.
if options['command']:
exec(options['command'], self.get_namespace())
return
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
return shell

View File

@@ -1,6 +1,6 @@
import re
from collections import OrderedDict
from datetime import datetime
from datetime import datetime, date
from django import forms
from django.contrib.contenttypes.models import ContentType
@@ -317,10 +317,11 @@ class CustomField(models.Model):
# Validate date
if self.type == CustomFieldTypeChoices.TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
if type(value) is not date:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
# Validate selected choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT:

View File

@@ -3,7 +3,6 @@ import inspect
from packaging import version
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template
@@ -71,10 +70,10 @@ class PluginConfig(AppConfig):
register_menu_items(self.verbose_name, menu_items)
@classmethod
def validate(cls, user_config):
def validate(cls, user_config, netbox_version):
# Enforce version constraints
current_version = version.parse(settings.VERSION)
current_version = version.parse(netbox_version)
if cls.min_version is not None:
min_version = version.parse(cls.min_version)
if current_version < min_version:

View File

@@ -1,5 +1,6 @@
from django.db.models import OuterRef, Subquery, Q
from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
from utilities.querysets import RestrictedQuerySet
@@ -81,11 +82,25 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
def _get_config_context_filters(self):
# Construct the set of Q objects for the specific object types
tag_query_filters = {
"object_id": OuterRef(OuterRef('pk')),
"content_type__app_label": self.model._meta.app_label,
"content_type__model": self.model._meta.model_name
}
base_query = Q(
Q(platforms=OuterRef('platform')) | Q(platforms=None),
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
Q(tags=OuterRef('tags')) | Q(tags=None),
Q(
tags__pk__in=Subquery(
TaggedItem.objects.filter(
**tag_query_filters
).values_list(
'tag_id',
flat=True
)
)
) | Q(tags=None),
is_active=True,
)

View File

@@ -27,6 +27,16 @@ class ChangeLogViewTest(ModelViewTestCase):
cf.save()
cf.content_types.set([ct])
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
required=False,
choices=['Bar', 'Foo']
)
cf_select.save()
cf_select.content_types.set([ct])
def test_create_object(self):
tags = self.create_tags('Tag 1', 'Tag 2')
form_data = {
@@ -34,6 +44,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'test-site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC',
'cf_my_field_select': 'Bar',
'tags': [tag.pk for tag in tags],
}
@@ -54,6 +65,7 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
@@ -68,6 +80,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'test-site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF',
'cf_my_field_select': 'Foo',
'tags': [tags[2].pk],
}
@@ -88,6 +101,7 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
def test_delete_object(self):
@@ -95,7 +109,8 @@ class ChangeLogViewTest(ModelViewTestCase):
name='Test Site 1',
slug='test-site-1',
custom_field_data={
'my_field': 'ABC'
'my_field': 'ABC',
'my_field_select': 'Bar'
}
)
site.save()
@@ -115,6 +130,7 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
@@ -133,6 +149,16 @@ class ChangeLogAPITest(APITestCase):
cf.save()
cf.content_types.set([ct])
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
required=False,
choices=['Bar', 'Foo']
)
cf_select.save()
cf_select.content_types.set([ct])
# Create some tags
tags = (
Tag(name='Tag 1', slug='tag-1'),
@@ -146,7 +172,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'my_field': 'ABC'
'my_field': 'ABC',
'my_field_select': 'Bar',
},
'tags': [
{'name': 'Tag 1'},
@@ -180,7 +207,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Test Site X',
'slug': 'test-site-x',
'custom_fields': {
'my_field': 'DEF'
'my_field': 'DEF',
'my_field_select': 'Foo',
},
'tags': [
{'name': 'Tag 3'}
@@ -209,7 +237,8 @@ class ChangeLogAPITest(APITestCase):
name='Test Site 1',
slug='test-site-1',
custom_field_data={
'my_field': 'ABC'
'my_field': 'ABC',
'my_field_select': 'Bar'
}
)
site.save()
@@ -226,5 +255,6 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])

View File

@@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from rest_framework import status
from dcim.filters import SiteFilterSet
from dcim.forms import SiteCSVForm
from dcim.models import Site, Rack
from extras.choices import *
@@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase):
site.cf['baz'] = 'def'
site.clean()
class CustomFieldFilterTest(TestCase):
queryset = Site.objects.all()
filterset = SiteFilterSet
@classmethod
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
# Integer filtering
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf.save()
cf.content_types.set([obj_type])
# Boolean filtering
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf.save()
cf.content_types.set([obj_type])
# Exact text filtering
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
cf.save()
cf.content_types.set([obj_type])
# Loose text filtering
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
cf.save()
cf.content_types.set([obj_type])
# Date filtering
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
cf.save()
cf.content_types.set([obj_type])
# Exact URL filtering
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
cf.save()
cf.content_types.set([obj_type])
# Loose URL filtering
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
cf.save()
cf.content_types.set([obj_type])
# Selection filtering
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
cf.save()
cf.content_types.set([obj_type])
Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', custom_field_data={
'cf1': 100,
'cf2': True,
'cf3': 'foo',
'cf4': 'foo',
'cf5': '2016-06-26',
'cf6': 'http://foo.example.com/',
'cf7': 'http://foo.example.com/',
'cf8': 'Foo',
}),
Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200,
'cf2': False,
'cf3': 'foobar',
'cf4': 'foobar',
'cf5': '2016-06-27',
'cf6': 'http://bar.example.com/',
'cf7': 'http://bar.example.com/',
'cf8': 'Bar',
}),
Site(name='Site 3', slug='site-3', custom_field_data={
}),
])
def test_filter_integer(self):
self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
def test_filter_boolean(self):
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
def test_filter_text(self):
self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
def test_filter_date(self):
self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
def test_filter_url(self):
self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)

View File

@@ -321,3 +321,46 @@ class ConfigContextTest(TestCase):
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
"""
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
This is combatted by by appending distinct() to the config context querysets. This test creates a config
context assigned to two tags and ensures objects related by those same two tags result in only a single
config context record being returned.
This test case is seperate from the above in that it deals with multiple config context objects in play.
See https://github.com/netbox-community/netbox/issues/5387
"""
tag_context_1 = ConfigContext.objects.create(
name="tag-1",
weight=100,
data={
"tag": 1
}
)
tag_context_1.tags.add(self.tag)
tag_context_2 = ConfigContext.objects.create(
name="tag-2",
weight=100,
data={
"tag": 1
}
)
tag_context_2.tags.add(self.tag2)
device = Device.objects.create(
name="Device 3",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
)
device.tags.add(self.tag)
device.tags.add(self.tag2)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())

View File

@@ -86,21 +86,19 @@ class PluginTest(TestCase):
"""
self.assertIn('extras.tests.dummy_plugin.*', settings.CACHEOPS)
@override_settings(VERSION='0.9')
def test_min_version(self):
"""
Check enforcement of minimum NetBox version.
"""
with self.assertRaises(ImproperlyConfigured):
dummy_config.validate({})
dummy_config.validate({}, '0.9')
@override_settings(VERSION='10.0')
def test_max_version(self):
"""
Check enforcement of maximum NetBox version.
"""
with self.assertRaises(ImproperlyConfigured):
dummy_config.validate({})
dummy_config.validate({}, '10.0')
def test_required_settings(self):
"""
@@ -110,11 +108,11 @@ class PluginTest(TestCase):
required_settings = ['foo']
# Validation should pass when all required settings are present
DummyConfigWithRequiredSettings.validate({'foo': True})
DummyConfigWithRequiredSettings.validate({'foo': True}, settings.VERSION)
# Validation should fail when a required setting is missing
with self.assertRaises(ImproperlyConfigured):
DummyConfigWithRequiredSettings.validate({})
DummyConfigWithRequiredSettings.validate({}, settings.VERSION)
def test_default_settings(self):
"""
@@ -127,10 +125,10 @@ class PluginTest(TestCase):
# Populate the default value if setting has not been specified
user_config = {}
DummyConfigWithDefaultSettings.validate(user_config)
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 123)
# Don't overwrite specified values
user_config = {'bar': 456}
DummyConfigWithDefaultSettings.validate(user_config)
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 456)

View File

@@ -12,7 +12,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import copy_safe_request, get_subquery, shallow_compare_dict
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables
from .choices import JobResultStatusChoices
@@ -27,7 +27,7 @@ from .scripts import get_scripts, run_script
class TagListView(generic.ObjectListView):
queryset = Tag.objects.annotate(
items=get_subquery(TaggedItem, 'tag')
items=count_related(TaggedItem, 'tag')
)
filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm
@@ -52,7 +52,7 @@ class TagBulkImportView(generic.BulkImportView):
class TagBulkEditView(generic.BulkEditView):
queryset = Tag.objects.annotate(
items=get_subquery(TaggedItem, 'tag')
items=count_related(TaggedItem, 'tag')
)
table = tables.TagTable
form = forms.TagBulkEditForm
@@ -60,7 +60,7 @@ class TagBulkEditView(generic.BulkEditView):
class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate(
items=get_subquery(TaggedItem, 'tag')
items=count_related(TaggedItem, 'tag')
)
table = tables.TagTable

View File

@@ -12,7 +12,7 @@ from ipam import filters
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from netbox.api.views import ModelViewSet
from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import get_subquery
from utilities.utils import count_related
from . import serializers
@@ -32,8 +32,8 @@ class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags'
).annotate(
ipaddress_count=get_subquery(IPAddress, 'vrf'),
prefix_count=get_subquery(Prefix, 'vrf')
ipaddress_count=count_related(IPAddress, 'vrf'),
prefix_count=count_related(Prefix, 'vrf')
)
serializer_class = serializers.VRFSerializer
filterset_class = filters.VRFFilterSet
@@ -55,7 +55,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
class RIRViewSet(ModelViewSet):
queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir')
aggregate_count=count_related(Aggregate, 'rir')
)
serializer_class = serializers.RIRSerializer
filterset_class = filters.RIRFilterSet
@@ -77,8 +77,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
class RoleViewSet(ModelViewSet):
queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'),
vlan_count=get_subquery(VLAN, 'role')
prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role')
)
serializer_class = serializers.RoleSerializer
filterset_class = filters.RoleFilterSet
@@ -272,7 +272,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=get_subquery(VLAN, 'group')
vlan_count=count_related(VLAN, 'group')
)
serializer_class = serializers.VLANGroupSerializer
filterset_class = filters.VLANGroupFilterSet
@@ -286,7 +286,7 @@ class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags'
).annotate(
prefix_count=get_subquery(Prefix, 'vlan')
prefix_count=count_related(Prefix, 'vlan')
)
serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet

View File

@@ -774,6 +774,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True
def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):

View File

@@ -18,13 +18,11 @@ UTILIZATION_GRAPH = """
"""
PREFIX_LINK = """
{% if record.children %}
<span class="text-nowrap" style="padding-left: {{ record.parents }}0px "><i class="mdi mdi-chevron-right"></i></a>
{% else %}
<span class="text-nowrap" style="padding-left: {{ record.parents }}9px">
{% endif %}
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
</span>
{% load helpers %}
{% for i in record.parents|as_range %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
"""
PREFIX_ROLE_LINK = """
@@ -104,7 +102,7 @@ VLANGROUP_ADD_VLAN = """
"""
VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == vlan.pk %}
{% if record.untagged_vlan_id == object.pk %}
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
{% else %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>

View File

@@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
from dcim.models import Device, Interface
from netbox.views import generic
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery
from utilities.utils import count_related
from virtualization.models import VirtualMachine, VMInterface
from . import filters, forms, tables
from .constants import *
@@ -140,7 +140,7 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
class RIRListView(generic.ObjectListView):
queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir')
aggregate_count=count_related(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm
@@ -165,7 +165,7 @@ class RIRBulkImportView(generic.BulkImportView):
class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir')
aggregate_count=count_related(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet
table = tables.RIRTable
@@ -277,8 +277,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
class RoleListView(generic.ObjectListView):
queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'),
vlan_count=get_subquery(VLAN, 'role')
prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role')
)
table = tables.RoleTable
@@ -633,7 +633,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=get_subquery(VLAN, 'group')
vlan_count=count_related(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
@@ -657,7 +657,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=get_subquery(VLAN, 'group')
vlan_count=count_related(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable

View File

@@ -1,10 +1,45 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.utils.encoding import force_str
from rest_framework import exceptions
from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from netbox.api import ContentTypeField
class ContentTypeMetadata(SimpleMetadata):
class BulkOperationMetadata(SimpleMetadata):
def determine_actions(self, request, view):
"""
Replace the stock determine_actions() method to assess object permissions only
when viewing a specific object. This is necessary to support OPTIONS requests
with bulk update in place (see #5470).
"""
actions = {}
for method in {'PUT', 'POST'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions (if viewing a specific object)
if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request
return actions
class ContentTypeMetadata(BulkOperationMetadata):
def get_field_info(self, field):
field_info = super().get_field_info(field)

View File

@@ -79,7 +79,7 @@ BANNER_BOTTOM = ''
# Text to include on the login page above the login form. HTML is allowed.
BANNER_LOGIN = ''
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = ''
@@ -183,7 +183,7 @@ NAPALM_PASSWORD = ''
# NAPALM timeout (in seconds). (Default: 30)
NAPALM_TIMEOUT = 30
# NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
# be provided as a dictionary.
NAPALM_ARGS = {}

View File

@@ -7,8 +7,8 @@ ALLOWED_HOSTS = ['*']
DATABASE = {
'NAME': 'netbox',
'USER': '',
'PASSWORD': '',
'USER': 'netbox',
'PASSWORD': 'netbox',
'HOST': 'localhost',
'PORT': '',
'CONN_MAX_AGE': 300,

View File

@@ -23,7 +23,7 @@ from secrets.tables import SecretTable
from tenancy.filters import TenantFilterSet
from tenancy.models import Tenant
from tenancy.tables import TenantTable
from utilities.utils import get_subquery
from utilities.utils import count_related
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
@@ -33,7 +33,7 @@ SEARCH_TYPES = OrderedDict((
# Circuits
('provider', {
'queryset': Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
count_circuits=count_related(Circuit, 'provider')
),
'filterset': ProviderFilterSet,
'table': ProviderTable,
@@ -74,7 +74,7 @@ SEARCH_TYPES = OrderedDict((
}),
('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type')
instance_count=count_related(Device, 'device_type')
),
'filterset': DeviceTypeFilterSet,
'table': DeviceTypeTable,
@@ -90,7 +90,7 @@ SEARCH_TYPES = OrderedDict((
}),
('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=get_subquery(Device, 'virtual_chassis')
member_count=count_related(Device, 'virtual_chassis')
),
'filterset': VirtualChassisFilterSet,
'table': VirtualChassisTable,
@@ -111,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
# Virtualization
('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=get_subquery(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster')
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
),
'filterset': ClusterFilterSet,
'table': ClusterTable,

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.10-beta2'
VERSION = '2.10.2'
# Hostname
HOSTNAME = platform.node()
@@ -467,6 +467,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend',
),
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
'netbox.api.authentication.TokenPermissions',
@@ -609,7 +610,7 @@ for plugin_name in PLUGINS:
# Validate user-provided configuration settings and assign defaults
if plugin_name not in PLUGINS_CONFIG:
PLUGINS_CONFIG[plugin_name] = {}
plugin_config.validate(PLUGINS_CONFIG[plugin_name])
plugin_config.validate(PLUGINS_CONFIG[plugin_name], VERSION)
# Add middleware
plugin_middleware = plugin_config.middleware

View File

@@ -1,11 +1,10 @@
// Inteface filtering
$('input.interface-filter').on('input', function() {
var filter = new RegExp(this.value);
var interface;
let filter = new RegExp(this.value);
let interface;
for (interface of $('table > tbody > tr')) {
// Slice off 'interface_' at the start of the ID
if (filter.test(interface.id.slice(10))) {
if (filter.test(interface.getAttribute('data-name'))) {
// Match the toggle in case the filter now matches the interface
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
$(interface).show();

View File

@@ -12,7 +12,7 @@ from netbox.api.views import ModelViewSet
from secrets import filters
from secrets.exceptions import InvalidKey
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.utils import get_subquery
from utilities.utils import count_related
from . import serializers
ERR_USERKEY_MISSING = "No UserKey found for the current user."
@@ -35,7 +35,7 @@ class SecretsRootView(APIRootView):
class SecretRoleViewSet(ModelViewSet):
queryset = SecretRole.objects.annotate(
secret_count=get_subquery(Secret, 'role')
secret_count=count_related(Secret, 'role')
)
serializer_class = serializers.SecretRoleSerializer
filterset_class = filters.SecretRoleFilterSet

View File

@@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
self.fields['plaintext'].required = True
def clean(self):
super().clean()
if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")

View File

@@ -30,15 +30,21 @@ class SecretRoleTable(BaseTable):
class SecretTable(BaseTable):
pk = ToggleColumn()
id = tables.Column( # Provides a link to the secret
linkify=True
)
assigned_object = tables.Column(
linkify=True,
verbose_name='Assigned object'
)
role = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='secrets:secret_list'
)
class Meta(BaseTable.Meta):
model = Secret
fields = ('pk', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
default_columns = ('pk', 'assigned_object', 'role', 'name', 'last_updated')
fields = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
default_columns = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated')

View File

@@ -7,7 +7,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from netbox.views import generic
from utilities.utils import get_subquery
from utilities.utils import count_related
from . import filters, forms, tables
from .models import SecretRole, Secret, SessionKey, UserKey
@@ -28,7 +28,7 @@ def get_session_key(request):
class SecretRoleListView(generic.ObjectListView):
queryset = SecretRole.objects.annotate(
secret_count=get_subquery(Secret, 'role')
secret_count=count_related(Secret, 'role')
)
table = tables.SecretRoleTable
@@ -50,7 +50,7 @@ class SecretRoleBulkImportView(generic.BulkImportView):
class SecretRoleBulkDeleteView(generic.BulkDeleteView):
queryset = SecretRole.objects.annotate(
secret_count=get_subquery(Secret, 'role')
secret_count=count_related(Secret, 'role')
)
table = tables.SecretRoleTable
@@ -98,13 +98,14 @@ class SecretEditView(generic.ObjectEditView):
if form.is_valid():
logger.debug("Form validation was successful")
secret = form.save(commit=False)
# We must have a session key in order to create a secret or update the plaintext of an existing secret
if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None:
# We must have a session key in order to set the plaintext of a Secret
if form.cleaned_data['plaintext'] and session_key is None:
logger.debug("Unable to proceed: No session key was provided with the request")
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
else:
elif form.cleaned_data['plaintext']:
master_key = None
try:
sk = SessionKey.objects.get(userkey__user=request.user)
@@ -115,19 +116,18 @@ class SecretEditView(generic.ObjectEditView):
if master_key is not None:
logger.debug("Successfully resolved master key for encryption")
secret = form.save(commit=False)
if form.cleaned_data['plaintext']:
secret.plaintext = str(form.cleaned_data['plaintext'])
secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key)
secret.save()
form.save_m2m()
msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
logger.info(f"{msg} {secret} (PK: {secret.pk})")
msg = '{} <a href="{}">{}</a>'.format(msg, secret.get_absolute_url(), escape(secret))
messages.success(request, mark_safe(msg))
secret.save()
form.save_m2m()
return redirect(self.get_return_url(request, secret))
msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
logger.info(f"{msg} {secret} (PK: {secret.pk})")
msg = f'{msg} <a href="{secret.get_absolute_url()}">{escape(secret)}</a>'
messages.success(request, mark_safe(msg))
return redirect(self.get_return_url(request, secret))
else:
logger.debug("Form validation failed")

View File

@@ -71,7 +71,7 @@
</div>
<div class="col-xs-4 text-right noprint">
<p class="text-muted">
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> &middot;
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> &middot;
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
<i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> &middot;
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a>

View File

@@ -14,7 +14,7 @@
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{% url 'dcim:device_list' %}?site={{ object.site.slug }}">{{ object.site }}</a></li>
{% if object.parent_bay %}
<li><a href="{% url 'dcim:device' pk=object.parent_bay.object.pk %}">{{ object.parent_bay.device }}</a></li>
<li><a href="{% url 'dcim:device' pk=object.parent_bay.device.pk %}">{{ object.parent_bay.device }}</a></li>
<li>{{ object.parent_bay }}</li>
{% endif %}
<li>{{ object }}</li>

View File

@@ -10,7 +10,7 @@
{% endif %}
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ device.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ object.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -9,18 +9,18 @@
{% include 'responsive_table.html' %}
<div class="panel-footer noprint">
{% if table.rows %}
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
</button>
{% endif %}
<div class="pull-right">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Add {{ title }}
</a>

View File

@@ -330,12 +330,16 @@
<div class="col-md-6">
<div class="row" style="margin-bottom: 20px">
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>Front</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
</div>
</div>
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>Rear</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
</div>
</div>
</div>
<div class="panel panel-default">

View File

@@ -25,7 +25,8 @@
{% if page %}
<div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %}
<div style="display: inline-block; width: 266px">
<div style="display: inline-block; margin-right: 12px; width: 254px">
<div style="margin-left: 30px">
<div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
{% if rack.role %}
@@ -35,7 +36,7 @@
<br /><small class="text-muted">{{ rack.facility_id }}</small>
{% endif %}
</div>
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
{% include 'dcim/inc/rack_elevation.html' with object=rack face=rack_face %}
<div class="clearfix"></div>
<div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
@@ -43,6 +44,7 @@
<small class="text-muted">({{ rack.facility_id }})</small>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>

View File

@@ -137,7 +137,7 @@
<td>
{% if object.physical_address %}
<div class="pull-right noprint">
<a href="http://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
<a href="https://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
<i class="mdi mdi-map-marker"></i> Map it
</a>
</div>
@@ -156,7 +156,7 @@
<td>
{% if object.latitude and object.longitude %}
<div class="pull-right noprint">
<a href="http://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
<a href="https://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
<i class="mdi mdi-map-marker"></i> Map it
</a>
</div>

View File

@@ -1,3 +1,5 @@
{% load helpers %}
<div class="cable" style="border-left-color: #{{ cable.color|default:'606060' }}; {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
<strong>
<a href="{% url 'dcim:cable' pk=cable.pk %}">
@@ -5,11 +7,13 @@
</a>
</strong><br />
{% if cable.type %}
{{ cable.get_type_display|default:"" }}
{% if cable.length %}
({{ cable.length }} {{ cable.get_length_unit_display }})
{% endif %}
<br />
{{ cable.get_type_display|default:"" }}<br />
{% endif %}
<span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span>
{% if cable.length %}
({{ cable.length }} {{ cable.get_length_unit_display }})<br />
{% endif %}
<span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span><br />
{% for tag in cable.tags.all %}
{% tag tag 'dcim:cable_list' %}
{% endfor %}
</div>

View File

@@ -78,7 +78,7 @@
</tr>
<tr>
<td>Description</td>
<td>{{ vrf.description|placeholder }}</td>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>

View File

@@ -1,4 +1,7 @@
{% if secrets %}
<form id="secret_form">
{% csrf_token %}
</form>
<table class="table table-hover panel-body">
{% for secret in secrets %}
<tr>

View File

@@ -317,5 +317,6 @@
{% block javascript %}
<script src="{% static 'js/interface_filtering.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@@ -7,7 +7,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
from netbox.api.views import ModelViewSet
from tenancy import filters
from tenancy.models import Tenant, TenantGroup
from utilities.utils import get_subquery
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
@@ -44,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.prefetch_related(
'group', 'tags'
).annotate(
circuit_count=get_subquery(Circuit, 'tenant'),
device_count=get_subquery(Device, 'tenant'),
ipaddress_count=get_subquery(IPAddress, 'tenant'),
prefix_count=get_subquery(Prefix, 'tenant'),
rack_count=get_subquery(Rack, 'tenant'),
site_count=get_subquery(Site, 'tenant'),
virtualmachine_count=get_subquery(VirtualMachine, 'tenant'),
vlan_count=get_subquery(VLAN, 'tenant'),
vrf_count=get_subquery(VRF, 'tenant')
circuit_count=count_related(Circuit, 'tenant'),
device_count=count_related(Device, 'tenant'),
ipaddress_count=count_related(IPAddress, 'tenant'),
prefix_count=count_related(Prefix, 'tenant'),
rack_count=count_related(Rack, 'tenant'),
site_count=count_related(Site, 'tenant'),
virtualmachine_count=count_related(VirtualMachine, 'tenant'),
vlan_count=count_related(VLAN, 'tenant'),
vrf_count=count_related(VRF, 'tenant')
)
serializer_class = serializers.TenantSerializer
filterset_class = filters.TenantFilterSet

View File

@@ -208,6 +208,18 @@ def split(string, sep=','):
return string.split(sep)
@register.filter()
def as_range(n):
"""
Return a range of n items.
"""
try:
int(n)
except TypeError:
return list()
return range(n)
#
# Tags
#

View File

@@ -109,6 +109,15 @@ class APIViewTestCases:
url = self._get_detail_url(instance2)
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_options_object(self):
"""
Make an OPTIONS request for a single object.
"""
url = self._get_detail_url(self._get_queryset().first())
response = self.client.options(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ListObjectsViewTestCase(APITestCase):
brief_fields = []
@@ -174,6 +183,14 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_options_objects(self):
"""
Make an OPTIONS request for a list endpoint.
"""
response = self.client.options(self._get_list_url(), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class CreateObjectViewTestCase(APITestCase):
create_data = []
validation_excluded_fields = []

View File

@@ -5,6 +5,7 @@ from itertools import count, groupby
from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from jinja2 import Environment
from dcim.choices import CableLengthUnitChoices
@@ -65,7 +66,7 @@ def dynamic_import(name):
return mod
def get_subquery(model, field):
def count_related(model, field):
"""
Return a Subquery suitable for annotating a child object count.
"""
@@ -79,7 +80,7 @@ def get_subquery(model, field):
).values('c')
)
return subquery
return Coalesce(subquery, 0)
def serialize_object(obj, extra=None, exclude=None):

View File

@@ -2,7 +2,7 @@ from rest_framework.routers import APIRootView
from dcim.models import Device
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
from utilities.utils import get_subquery
from utilities.utils import count_related
from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from . import serializers
@@ -22,7 +22,7 @@ class VirtualizationRootView(APIRootView):
class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.annotate(
cluster_count=get_subquery(Cluster, 'type')
cluster_count=count_related(Cluster, 'type')
)
serializer_class = serializers.ClusterTypeSerializer
filterset_class = filters.ClusterTypeFilterSet
@@ -30,7 +30,7 @@ class ClusterTypeViewSet(ModelViewSet):
class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.annotate(
cluster_count=get_subquery(Cluster, 'group')
cluster_count=count_related(Cluster, 'group')
)
serializer_class = serializers.ClusterGroupSerializer
filterset_class = filters.ClusterGroupFilterSet
@@ -40,8 +40,8 @@ class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.prefetch_related(
'type', 'group', 'tenant', 'site', 'tags'
).annotate(
device_count=get_subquery(Device, 'cluster'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster')
device_count=count_related(Device, 'cluster'),
virtualmachine_count=count_related(VirtualMachine, 'cluster')
)
serializer_class = serializers.ClusterSerializer
filterset_class = filters.ClusterFilterSet
@@ -51,7 +51,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
# Virtual machines
#
class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)

View File

@@ -308,10 +308,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter(
name=self.name, tenant__isnull=True
name=self.name, cluster=self.cluster, tenant__isnull=True
):
raise ValidationError({
'name': 'A virtual machine with this name already exists.'
'name': 'A virtual machine with this name already exists in the assigned cluster.'
})
super().validate_unique(exclude)

View File

@@ -183,3 +183,6 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
default_columns = (
'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
)
row_attrs = {
'data-name': lambda record: record.name,
}

View File

@@ -11,7 +11,7 @@ from ipam.models import IPAddress, Service
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic
from secrets.models import Secret
from utilities.utils import get_subquery
from utilities.utils import count_related
from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -22,7 +22,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
class ClusterTypeListView(generic.ObjectListView):
queryset = ClusterType.objects.annotate(
cluster_count=get_subquery(Cluster, 'type')
cluster_count=count_related(Cluster, 'type')
)
table = tables.ClusterTypeTable
@@ -44,7 +44,7 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate(
cluster_count=get_subquery(Cluster, 'type')
cluster_count=count_related(Cluster, 'type')
)
table = tables.ClusterTypeTable
@@ -55,7 +55,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
class ClusterGroupListView(generic.ObjectListView):
queryset = ClusterGroup.objects.annotate(
cluster_count=get_subquery(Cluster, 'group')
cluster_count=count_related(Cluster, 'group')
)
table = tables.ClusterGroupTable
@@ -77,7 +77,7 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate(
cluster_count=get_subquery(Cluster, 'group')
cluster_count=count_related(Cluster, 'group')
)
table = tables.ClusterGroupTable
@@ -89,8 +89,8 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
class ClusterListView(generic.ObjectListView):
permission_required = 'virtualization.view_cluster'
queryset = Cluster.objects.annotate(
device_count=get_subquery(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster')
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
table = tables.ClusterTable
filterset = filters.ClusterFilterSet

View File

@@ -1,60 +0,0 @@
#!/bin/bash
# Exit code starts at 0 but is modified if any checks fail
EXIT=0
# Output a line prefixed with a timestamp
info()
{
echo "$(date +'%F %T') |"
}
# Track number of seconds required to run script
START=$(date +%s)
echo "$(info) starting build checks."
# Syntax check all python source files
SYNTAX=$(find . -name "*.py" -type f -exec python -m py_compile {} \; 2>&1)
if [[ ! -z $SYNTAX ]]; then
echo -e "$SYNTAX"
echo -e "\n$(info) detected one or more syntax errors, failing build."
EXIT=1
fi
# Check all python source files for PEP 8 compliance, but explicitly
# ignore:
# - W504: line break after binary operator
# - E501: line greater than 80 characters in length
pycodestyle \
--ignore=W504,E501 \
netbox/
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) one or more PEP 8 errors detected, failing build."
EXIT=$RC
fi
# Point to the testing configuration file for use in CI
ln -s configuration.testing.py netbox/netbox/configuration.py
# Run NetBox tests
coverage run --source="netbox/" netbox/manage.py test netbox/
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) one or more tests failed, failing build."
EXIT=$RC
fi
# Show code coverage report
coverage report --skip-covered --omit *migrations*
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) failed to generate code coverage report."
EXIT=$RC
fi
# Show build duration
END=$(date +%s)
echo "$(info) exiting with code $EXIT after $(($END - $START)) seconds."
exit $EXIT