mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-29 13:18:19 +01:00
Compare commits
253 Commits
v3.4-beta1
...
v3.4.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6638fd88b4 | ||
|
|
c280ca35d6 | ||
|
|
3586cf79d4 | ||
|
|
972ba7bfdc | ||
|
|
5a4d8a7107 | ||
|
|
3e946c78d0 | ||
|
|
0855ff8b42 | ||
|
|
cd09501d4d | ||
|
|
e635e3e959 | ||
|
|
9efc4689cc | ||
|
|
25278becef | ||
|
|
fc7cb106c1 | ||
|
|
18ea7d1e13 | ||
|
|
eed1b8f412 | ||
|
|
a61e7e7c04 | ||
|
|
ce166b12ce | ||
|
|
315371bf7c | ||
|
|
afc752b4ce | ||
|
|
126f9ba05f | ||
|
|
c031951f4b | ||
|
|
c36e7a1d0b | ||
|
|
3a4fee4e6e | ||
|
|
2db181ea49 | ||
|
|
eee1a0e10a | ||
|
|
9594049804 | ||
|
|
c78022a74c | ||
|
|
3150c1f8b3 | ||
|
|
9f91b89467 | ||
|
|
d748851027 | ||
|
|
df499ea8ac | ||
|
|
b5da383a17 | ||
|
|
f9237285fd | ||
|
|
3c970c331c | ||
|
|
91705aa9fd | ||
|
|
56c7a238a4 | ||
|
|
3f28d6aef3 | ||
|
|
edbd597bf2 | ||
|
|
5e1bb20f32 | ||
|
|
7ebfa4c1d1 | ||
|
|
65417dbf9e | ||
|
|
37d0135cab | ||
|
|
699edd049c | ||
|
|
95b2acb603 | ||
|
|
98a2f3e497 | ||
|
|
fb2771370c | ||
|
|
a137cd6cbe | ||
|
|
10e27cfa00 | ||
|
|
46ede62f3f | ||
|
|
e7ad6eeb74 | ||
|
|
892fd95b5f | ||
|
|
0da518e83d | ||
|
|
fbc9fea0a5 | ||
|
|
ccc108a217 | ||
|
|
22a9df82e6 | ||
|
|
9cb75e9834 | ||
|
|
55b1549895 | ||
|
|
6f74c5ec03 | ||
|
|
b8de9c0875 | ||
|
|
d5ccda355f | ||
|
|
b79a2976f7 | ||
|
|
39087d10eb | ||
|
|
6a793087b4 | ||
|
|
0f9a303963 | ||
|
|
eca624b13d | ||
|
|
a4d8169df8 | ||
|
|
5f7e310305 | ||
|
|
d5e6829eff | ||
|
|
504800a7db | ||
|
|
97723b1f96 | ||
|
|
5911041777 | ||
|
|
fcd0481b09 | ||
|
|
cc350165dd | ||
|
|
db7e1b8a97 | ||
|
|
188f773081 | ||
|
|
6271f81cff | ||
|
|
4bfc3bf412 | ||
|
|
d5a92104d1 | ||
|
|
ddd4f805a5 | ||
|
|
a1c1b19482 | ||
|
|
426bc15065 | ||
|
|
df5febf6e7 | ||
|
|
9e09e46700 | ||
|
|
ba0e9bb1d2 | ||
|
|
19da92b510 | ||
|
|
beb1f4e172 | ||
|
|
fb3d1ef399 | ||
|
|
d7c37d9dd6 | ||
|
|
24de404fbc | ||
|
|
8565d175f9 | ||
|
|
8d9e151030 | ||
|
|
758c5347fb | ||
|
|
1e54eee631 | ||
|
|
448760a2fe | ||
|
|
e44b22f7d1 | ||
|
|
30379c3f52 | ||
|
|
8729d60c1c | ||
|
|
effcdb8723 | ||
|
|
1354947434 | ||
|
|
864ce0088e | ||
|
|
93ac0b77c9 | ||
|
|
ea327e6b37 | ||
|
|
f7b85ab941 | ||
|
|
ce9933da85 | ||
|
|
0662f0dab4 | ||
|
|
0669fda1fd | ||
|
|
b88fcb6625 | ||
|
|
69be24cd5f | ||
|
|
35273cc87f | ||
|
|
5af73e9bf7 | ||
|
|
128ccb4330 | ||
|
|
07df622b59 | ||
|
|
5d22260589 | ||
|
|
39985ebdd1 | ||
|
|
92ec06c694 | ||
|
|
04137e887e | ||
|
|
e940f00c01 | ||
|
|
1c72a80d9a | ||
|
|
b9f8370097 | ||
|
|
1c636ea127 | ||
|
|
e1169e7ea6 | ||
|
|
5975dbcb07 | ||
|
|
08a419ec7a | ||
|
|
d417168805 | ||
|
|
ccb2966c4c | ||
|
|
b7cdbd3d41 | ||
|
|
ae440c9edf | ||
|
|
b6cd099117 | ||
|
|
98f57f2dba | ||
|
|
735fa4aa31 | ||
|
|
c7108bb3f7 | ||
|
|
98b3fc03b8 | ||
|
|
92da2fe082 | ||
|
|
bfab3a26bc | ||
|
|
b35b33e798 | ||
|
|
db5c2a379e | ||
|
|
3675ad2539 | ||
|
|
27c71b8ec0 | ||
|
|
0058c7749c | ||
|
|
f882dcabf7 | ||
|
|
c8f4a7c742 | ||
|
|
ed366c5ab2 | ||
|
|
2738da2d39 | ||
|
|
9f15ca2d90 | ||
|
|
e4f5407c70 | ||
|
|
951f82b428 | ||
|
|
f8685ad7aa | ||
|
|
c59d527664 | ||
|
|
77423e7bb1 | ||
|
|
ba12675267 | ||
|
|
def3ccfaee | ||
|
|
bbc68f9484 | ||
|
|
93685d92a4 | ||
|
|
b2bf613895 | ||
|
|
80ced6b782 | ||
|
|
47dfb89c52 | ||
|
|
064e3ff605 | ||
|
|
fb27803ab0 | ||
|
|
5e32b39f25 | ||
|
|
b9888d6f86 | ||
|
|
96a796ebde | ||
|
|
996e73d5d8 | ||
|
|
5c969a8caf | ||
|
|
68faab8196 | ||
|
|
b3693099dc | ||
|
|
9bb9ac3dec | ||
|
|
a57378e780 | ||
|
|
41f631b65b | ||
|
|
2db668f5cc | ||
|
|
aacf606999 | ||
|
|
e338f7cfe3 | ||
|
|
758030733c | ||
|
|
ad78f9e075 | ||
|
|
3468e8c8ae | ||
|
|
13d39a28ce | ||
|
|
8809fc949b | ||
|
|
860805ba82 | ||
|
|
1e0b024609 | ||
|
|
8486d47d17 | ||
|
|
407365888a | ||
|
|
2ad1db0c64 | ||
|
|
83a0576ca4 | ||
|
|
0b100b8fc8 | ||
|
|
2b12138c41 | ||
|
|
97aa40f7a8 | ||
|
|
b2f34cec19 | ||
|
|
3bc9586b0c | ||
|
|
4297c65f87 | ||
|
|
62b0f034e7 | ||
|
|
6ffd8aa320 | ||
|
|
d53ddd611b | ||
|
|
080a001118 | ||
|
|
5a77791f9d | ||
|
|
ab9c253310 | ||
|
|
35596ddcbc | ||
|
|
0cacac82ee | ||
|
|
780997a568 | ||
|
|
d2d60c0607 | ||
|
|
d4d8d00d01 | ||
|
|
cb52d9c84e | ||
|
|
db61e57893 | ||
|
|
52cf9086a5 | ||
|
|
db7590df1a | ||
|
|
ee03f3d584 | ||
|
|
2577f3a786 | ||
|
|
826a1714c3 | ||
|
|
d0e0c2ff8b | ||
|
|
fb407e9076 | ||
|
|
85c60670dc | ||
|
|
f2f36c67f6 | ||
|
|
281934cf34 | ||
|
|
00d72f18cf | ||
|
|
b36afdc924 | ||
|
|
4ed45e4031 | ||
|
|
cf0258204f | ||
|
|
3bd560add8 | ||
|
|
9e51a8d9d2 | ||
|
|
f59c6699f6 | ||
|
|
39732fa861 | ||
|
|
80f5eeacdd | ||
|
|
f56e3eb784 | ||
|
|
c3dcd8937f | ||
|
|
b1da374df2 | ||
|
|
dc1da0a738 | ||
|
|
1946e8f053 | ||
|
|
4623858849 | ||
|
|
9c5891f1b6 | ||
|
|
d5538c1ca3 | ||
|
|
90f15b8d55 | ||
|
|
4e27e8d3dd | ||
|
|
150cb772fe | ||
|
|
e494d7bb22 | ||
|
|
9774bb46ce | ||
|
|
84c0c45da9 | ||
|
|
46e3883f19 | ||
|
|
3a89a676cd | ||
|
|
0885333b11 | ||
|
|
c287641363 | ||
|
|
de9646d096 | ||
|
|
dd2520d675 | ||
|
|
3a5914827b | ||
|
|
cf55e96241 | ||
|
|
bd29d15814 | ||
|
|
d3911e2a4c | ||
|
|
eb591731ef | ||
|
|
ae11419045 | ||
|
|
43bbd42d3c | ||
|
|
d4a231585a | ||
|
|
977b79ecee | ||
|
|
5202d0add9 | ||
|
|
ebf555e1fb | ||
|
|
f411c4f439 | ||
|
|
216d8d24b8 | ||
|
|
cb2b256934 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.8
|
||||
placeholder: v3.4.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.8
|
||||
placeholder: v3.4.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
necessary.
|
||||
close-pr-message: >
|
||||
This PR has been automatically closed due to lack of activity.
|
||||
days-before-stale: 60
|
||||
days-before-stale: 90
|
||||
days-before-close: 30
|
||||
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
||||
operations-per-run: 100
|
||||
|
||||
233
CONTRIBUTING.md
233
CONTRIBUTING.md
@@ -1,188 +1,115 @@
|
||||
## Getting Help
|
||||
**Looking for help?** NetBox has a vast, active community of fellow users that may be able to provide assistance. Just [start a discussion](https://github.com/netbox-community/netbox/discussions/new) right here on GitHub! Or if you'd prefer to chat, join us live in the `#netbox` channel on the [NetDev Community Slack](https://netdev.chat/)!
|
||||
|
||||
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.
|
||||
<div align="center">
|
||||
<h3>
|
||||
:bug: <a href="#bug-reporting-bugs">Report a bug</a> ·
|
||||
:bulb: <a href="#bulb-feature-requests">Suggest a feature</a> ·
|
||||
:arrow_heading_up: <a href="#arrow_heading_up-submitting-pull-requests">Submit a pull request</a>
|
||||
</h3>
|
||||
<h3>
|
||||
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> ·
|
||||
:rescue_worker_helmet: <a href="#rescue_worker_helmet-become-a-maintainer">Become a maintainer</a> ·
|
||||
:heart: <a href="#heart-other-ways-to-contribute">Other ideas</a>
|
||||
</h3>
|
||||
</div>
|
||||
<h3></h3>
|
||||
|
||||
### GitHub Discussions
|
||||
Some general tips for engaging here on GitHub:
|
||||
|
||||
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:
|
||||
* Register for a free [GitHub account](https://github.com/signup) if you haven't already.
|
||||
* You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
|
||||
* To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
|
||||
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
|
||||
|
||||
* **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
|
||||
## :bug: Reporting Bugs
|
||||
|
||||
### Slack
|
||||
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
|
||||
|
||||
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
|
||||
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.
|
||||
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
|
||||
|
||||
## Reporting Bugs
|
||||
* If you can't find any existing issues (open or closed) that seem to match yours, you're welcome to [submit a new bug report](https://github.com/netbox-community/netbox/issues/new?label=type%3A+bug&template=bug_report.yaml). Be sure to complete the entire report template, including detailed steps that someone triaging your issue can follow to confirm the reported behavior. (If we're not able to replicate the bug based on the information provided, we'll ask for additional detail.)
|
||||
|
||||
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases)
|
||||
of NetBox. If you're running an older version, it's possible that the bug has
|
||||
already been fixed.
|
||||
|
||||
* Next, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
|
||||
to see if the bug you've found has already been reported. If you think you may
|
||||
be experiencing a reported issue that hasn't already been resolved, please
|
||||
click "add a reaction" in the top right corner of the issue and add a thumbs
|
||||
up (+1). You might also want to add a comment describing how it's affecting your
|
||||
installation. This will allow us to prioritize bugs based on how many users are
|
||||
affected.
|
||||
|
||||
* When submitting an issue, please be as descriptive as possible. Be sure to
|
||||
provide all information request in the issue template, including:
|
||||
|
||||
* The environment in which NetBox is running
|
||||
* The exact steps that can be taken to reproduce the issue
|
||||
* Expected and observed behavior
|
||||
* Any error messages generated
|
||||
* Screenshots (if applicable)
|
||||
|
||||
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
|
||||
The issue will be reviewed by a maintainer after submission and the appropriate
|
||||
labels will be applied for categorization.
|
||||
|
||||
* Keep in mind that we prioritize bugs based on their severity and how much
|
||||
work is required to resolve them. It may take some time for someone to address
|
||||
your issue.
|
||||
* Some other tips to keep in mind:
|
||||
* Error messages and screenshots are especially helpful.
|
||||
* Don't prepend your issue title with a label like `[Bug]`; the proper label will be assigned automatically.
|
||||
* Ensure that your reproduction instructions don't reference data in our [demo instance](https://demo.netbox.dev/), which gets rebuilt nightly.
|
||||
* Verify that you have GitHub notifications enabled and are subscribed to your issue after submitting.
|
||||
* We appreciate your patience as bugs are prioritized by their severity, impact, and difficulty to resolve.
|
||||
|
||||
* For more information on how bug reports are handled, please see our [issue
|
||||
intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
|
||||
## Feature Requests
|
||||
## :bulb: Feature Requests
|
||||
|
||||
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
|
||||
to see if the feature you're requesting is already listed. (Be sure to search
|
||||
closed issues as well, since some feature requests have been rejected.) If the
|
||||
feature you'd like to see has already been requested and is open, click "add a
|
||||
reaction" in the top right corner of the issue and add a thumbs up (+1). This
|
||||
ensures that the issue has a better chance of receiving attention. Also feel
|
||||
free to add a comment with any additional justification for the feature.
|
||||
(However, note that comments with no substance other than a "+1" will be
|
||||
deleted. Please use GitHub's reactions feature to indicate your support.)
|
||||
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the feature you have in mind has already been proposed. If you happen to find an open feature request that matches your idea, click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This ensures that the issue has a better chance of receiving attention. Also feel free to add a comment with any additional justification for the feature.
|
||||
|
||||
* Before filing a new feature request, consider raising your idea in a
|
||||
[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
|
||||
first. Feedback you receive there will help validate and shape the proposed
|
||||
feature before filing a formal issue.
|
||||
* If you have a rough idea that's not quite ready for formal submission yet, start a [GitHub discussion](https://github.com/netbox-community/netbox/discussions) instead. This is a great way to test the viability and narrow down the scope of a new feature prior to submitting a formal proposal, and can serve to generate interest in your idea from other community members.
|
||||
|
||||
* Good feature requests are very narrowly defined. Be sure to thoroughly
|
||||
describe the functionality and data model(s) being proposed. The more effort
|
||||
you put into writing a feature request, the better its chance is of being
|
||||
implemented. Overly broad feature requests will be closed.
|
||||
* Once you're ready, submit a feature request [using this template](https://github.com/netbox-community/netbox/issues/new?label=type%3A+feature&template=feature_request.yaml). Be sure to provide sufficient context and detail to convey exactly what you're proposing and why. The stronger your use case, the better chance your proposal has of being accepted.
|
||||
|
||||
* When submitting a feature request on GitHub, be sure to include all
|
||||
information requested by the issue template, including:
|
||||
* Some other tips to keep in mind:
|
||||
* Don't prepend your issue title with a label like `[Feature]`; the proper label will be assigned automatically.
|
||||
* Try to anticipate any likely questions about your proposal and provide that information proactively.
|
||||
* Verify that you have GitHub notifications enabled and are subscribed to your issue after submitting.
|
||||
* You're welcome to volunteer to implement your FR, but don't submit a pull request until it has been approved.
|
||||
|
||||
* A detailed description of the proposed functionality
|
||||
* A use case for the feature; who would use it and what value it would add
|
||||
to NetBox
|
||||
* A rough description of changes necessary to the database schema (if
|
||||
applicable)
|
||||
* Any third-party libraries or other resources which would be involved
|
||||
* For more information on how feature requests are handled, please see our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
|
||||
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
|
||||
title. The issue will be reviewed by a moderator after submission and the
|
||||
appropriate labels will be applied for categorization.
|
||||
## :arrow_heading_up: Submitting Pull Requests
|
||||
|
||||
* For more information on how feature requests are handled, please see our
|
||||
[issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
* [Pull requests](https://docs.github.com/en/pull-requests) (a feature of GitHub) are used to propose changes to NetBox's code base. Our process generally goes like this:
|
||||
* A user opens a new issue (bug report or feature request)
|
||||
* A maintainer triages the issue and may mark it as needing an owner
|
||||
* The issue's author can volunteer to own it, or someone else can
|
||||
* A maintainer assigns the issue to whomever volunteers
|
||||
* The issue owner submits a pull request that will resolve the issue
|
||||
* A maintainer reviews and merges the pull request, closing the issue
|
||||
|
||||
## Submitting Pull Requests
|
||||
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
|
||||
|
||||
* If you're interested in contributing to NetBox, be sure to check out our
|
||||
[getting started](https://docs.netbox.dev/en/stable/development/getting-started/)
|
||||
documentation for tips on setting up your development environment.
|
||||
* New pull requests should generally be based off of the `develop` branch, rather than `master`. The `develop` branch is used for ongoing development, while `master` is used for tracking stable releases. (If you're developing for an upcoming minor release, use `feature` instead.)
|
||||
|
||||
* Be sure to open an issue and wait for it to be assigned to you **before**
|
||||
starting work on a pull request, and discuss your idea with the NetBox
|
||||
maintainers before beginning work. This will help prevent wasting time on
|
||||
proposed changes that we might not be able to accept. When suggesting a new
|
||||
feature, also make sure it won't conflict with any work that's already in
|
||||
progress.
|
||||
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
|
||||
|
||||
* Once you've opened or identified an issue you'd like to work on, ask that it
|
||||
be assigned to you so that others are aware it's being worked on. If it meets
|
||||
the acceptance criteria, a maintainer will then mark the issue as "accepted"
|
||||
and assign it to you. (Note that GitHub requires that a user first comment on
|
||||
an issue before it can be assigned to that user.)
|
||||
|
||||
* Any pull request which does not relate to an **assigned** issue will be
|
||||
closed.
|
||||
|
||||
* All new functionality must include relevant tests where applicable.
|
||||
|
||||
* When submitting a pull request, please be sure to work off of the `develop`
|
||||
branch, rather than `master`. The `develop` branch is used for ongoing
|
||||
development, while `master` is used for tagging stable releases. (If you're
|
||||
developing for the next minor release, use `feature` instead.)
|
||||
|
||||
* In most cases, it is not necessary to add a changelog entry: A maintainer will
|
||||
take care of this when the PR is merged. (This helps avoid merge conflicts
|
||||
resulting from multiple PRs being submitted simultaneously.)
|
||||
|
||||
* All code submissions should meet the following criteria (CI will enforce
|
||||
these checks):
|
||||
|
||||
* Python syntax is valid
|
||||
* All tests pass when run with `./manage.py test`
|
||||
* PEP 8 compliance is enforced, with the exception that lines may be
|
||||
* All code submissions should meet the following criteria (CI will enforce these checks):
|
||||
* Python syntax is valid
|
||||
* All tests pass when run with `./manage.py test`
|
||||
* PEP 8 compliance is enforced, with the exception that lines may be
|
||||
greater than 80 characters in length
|
||||
|
||||
## Commenting
|
||||
* Some other tips to keep in mind:
|
||||
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
|
||||
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
|
||||
* All new functionality must include relevant tests where applicable.
|
||||
|
||||
Only comment on an issue if you are sharing a relevant idea or constructive
|
||||
feedback. **Do not** comment on an issue just to show your support (give the
|
||||
top post a :+1: instead) or to ask for an update. Doing so generates
|
||||
unnecessary noise in the discussion, and is especially annoying for people who
|
||||
have subscribed to updates for the issue. Any comments without substance
|
||||
relevant to the discussion will be deleted.
|
||||
## :jigsaw: Creating Plugins
|
||||
|
||||
## Issue Lifecycle
|
||||
Do you have an idea for something you'd like to build in NetBox, but might not be right for the core project? NetBox includes a powerful and extensive [plugins framework](https://docs.netbox.dev/en/stable/plugins/) that enables users to develop their own custom data models and integrations.
|
||||
|
||||
New issues are handled according to our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
Maintainers will assign label(s) and/or close new issues as the policy
|
||||
dictates. This helps ensure a productive development environment and avoid
|
||||
accumulating a large backlog of work.
|
||||
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
|
||||
|
||||
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
||||
to aid in issue management.
|
||||
## :rescue_worker_helmet: Become a Maintainer
|
||||
|
||||
* Issues will be marked as stale after 60 days of no activity.
|
||||
* If the stable label is not removed in the following 30 days, the issue will
|
||||
be closed automatically.
|
||||
* Any issue bearing one of the following labels will be exempt from all Stale
|
||||
bot actions:
|
||||
* `status: accepted`
|
||||
* `status: blocked`
|
||||
* `status: needs milestone`
|
||||
We're always looking for motivated individuals to join the maintainers team and help drive NetBox's long-term development. Some of our most sought-after skills include:
|
||||
|
||||
It is natural that some new issues get more attention than others. The stale
|
||||
bot helps bring renewed attention to potentially valuable issues that may have
|
||||
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
|
||||
effort to circumvent the bot: This will result in the immediate closure of the
|
||||
issue, and you may be barred from participating in future discussions.
|
||||
* Python development with a strong focus on the [Django](https://www.djangoproject.com/) framework
|
||||
* Expertise working with PostgreSQL databases
|
||||
* Javascript & TypeScript proficiency
|
||||
* A knack for web application design (HTML & CSS)
|
||||
* Familiarity with git and software development best practices
|
||||
* Excellent attention to detail
|
||||
* Working experience in the field of network operations & engineering
|
||||
|
||||
## Maintainer Guidance
|
||||
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
|
||||
|
||||
* Maintainers are expected to contribute at least four hours per week to the
|
||||
project on average. This can be employer-sponsored or individual time, with
|
||||
the understanding that all contributions are submitted under the Apache 2.0
|
||||
license and that your employer may not make claim to any contributions.
|
||||
Contributions include code work, issue management, and community support. All
|
||||
development must be in accordance with our [development guidance](https://docs.netbox.dev/en/stable/development/).
|
||||
Many maintainers petition their employer to grant some of their paid time to work on NetBox. In doing so, your employer becomes eligible to be featured as a [NetBox sponsor](https://github.com/netbox-community/netbox/wiki/Sponsorship).
|
||||
|
||||
* Maintainers are expected to attend (where feasible) our biweekly ~30-minute
|
||||
sync to review agenda items. This meeting provides opportunity to present and
|
||||
discuss pressing topics. Meetings are held as virtual audio/video conferences.
|
||||
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
|
||||
|
||||
* Maintainers with no substantial recorded activity in a 60-day period will be
|
||||
removed from the project.
|
||||
## :heart: Other Ways to Contribute
|
||||
|
||||
You don't have to be a developer to contribute to NetBox: There are plenty of other ways you can add value to the community! Below are just a few examples:
|
||||
|
||||
* Help answer questions and provide feedback in our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) and on [Slack](https://netdev.chat/).
|
||||
* Write a blog article or record a YouTube video demonstrating how NetBox is used at your organization.
|
||||
* Help grow our [library of device & module type definitions](https://github.com/netbox-community/devicetype-library).
|
||||
|
||||
129
README.md
129
README.md
@@ -1,107 +1,73 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
|
||||
The premiere source of truth powering network automation
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By
|
||||
combining the traditional disciplines of IP address management (IPAM) and
|
||||
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
|
||||
NetBox provides the ideal "source of truth" to power network automation.
|
||||
Available as open source software under the Apache 2.0 license, NetBox is
|
||||
employed by thousands of organizations around the world.
|
||||
Available as open source software under the Apache 2.0 license, NetBox serves
|
||||
as the cornerstone for network automation in thousands of organizations.
|
||||
|
||||

|
||||
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
|
||||
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
|
||||
* **Organization:** Manage tenant and contact assignments natively.
|
||||
* **Powerful search:** Easily find anything you need using a single global search function.
|
||||
* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time.
|
||||
* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more!
|
||||
* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions.
|
||||
* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs.
|
||||
* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own!
|
||||
|
||||
[](https://github.com/netbox-community/netbox/commits)
|
||||
[](https://github.com/netbox-community/netbox/issues)
|
||||
[](https://github.com/netbox-community/netbox/pulls)
|
||||
[](https://github.com/netbox-community/netbox/graphs/contributors)
|
||||
<br />Stats via [Repography](https://repography.com)
|
||||

|
||||
|
||||
## About NetBox
|
||||
## Getting Started
|
||||
|
||||

|
||||
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
|
||||
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
|
||||
* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
|
||||
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
|
||||
|
||||
Myriad infrastructure components can be modeled in NetBox, including:
|
||||
## Get Involved
|
||||
|
||||
* Hierarchical regions, site groups, sites, and locations
|
||||
* Racks, devices, and device components
|
||||
* Cables and wireless connections
|
||||
* Power distribution
|
||||
* Data circuits and providers
|
||||
* Virtual machines and clusters
|
||||
* IP prefixes, ranges, and addresses
|
||||
* VRFs and route targets
|
||||
* L2VPN and overlays
|
||||
* FHRP groups (VRRP, HSRP, etc.)
|
||||
* AS numbers
|
||||
* VLANs and scoped VLAN groups
|
||||
* Organizational tenants and contacts
|
||||
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
|
||||
* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
|
||||
* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
|
||||
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
|
||||
|
||||
In addition to its extensive built-in models and functionality, NetBox can be
|
||||
customized and extended through the use of:
|
||||
|
||||
* Custom fields
|
||||
* Custom links
|
||||
* Configuration contexts
|
||||
* Custom model validation rules
|
||||
* Reports
|
||||
* Custom scripts
|
||||
* Export templates
|
||||
* Conditional webhooks
|
||||
* Plugins
|
||||
* Single sign-on (SSO) authentication
|
||||
* NAPALM integration
|
||||
* Detailed change logging
|
||||
|
||||
NetBox also features a complete REST API as well as a GraphQL API for easily
|
||||
integrating with other tools and systems.
|
||||
|
||||
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/).
|
||||
A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev).
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||
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).
|
||||
## Project Stats
|
||||
|
||||
<div align="center">
|
||||
<h3>Thank you to our sponsors!</h3>
|
||||
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
|
||||
<br />Stats via <a href="https://repography.com">Repography</a>
|
||||
</div>
|
||||
|
||||
## Sponsors
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://netboxlabs.com)
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
|
||||
[](https://metal.equinix.com/)
|
||||
|
||||
[](https://ns1.com/)
|
||||
[](https://ns1.com)
|
||||
<br />
|
||||
[](https://sentry.io/)
|
||||
[](https://sentry.io)
|
||||
|
||||
[](https://stellar.tech/)
|
||||
[](https://metal.equinix.com)
|
||||
|
||||
</div>
|
||||
|
||||
### Discussion
|
||||
|
||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
||||
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
||||
|
||||
### Installation
|
||||
|
||||
Please see [the documentation](https://docs.netbox.dev/) 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
|
||||
|
||||
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.
|
||||
|
||||
### Screenshots
|
||||
## Screenshots
|
||||
|
||||
")
|
||||
|
||||
@@ -110,8 +76,3 @@ 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.
|
||||
|
||||
@@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
|
||||
|
||||
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
||||
|
||||
If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||
|
||||
### Bug Bounties
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# HTML sanitizer
|
||||
# https://github.com/mozilla/bleach
|
||||
bleach
|
||||
bleach<6.0
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://github.com/django/django
|
||||
@@ -68,7 +68,7 @@ drf-yasg[validation]
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django
|
||||
graphene_django<3.0
|
||||
graphene_django
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://gunicorn.org/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The IP address (typically localhost) and port that the Netbox WSGI process should listen on
|
||||
# The IP address (typically localhost) and port that the NetBox WSGI process should listen on
|
||||
bind = '127.0.0.1:8001'
|
||||
|
||||
# Number of gunicorn workers to spawn. This should typically be 2n+1, where
|
||||
|
||||
@@ -5,6 +5,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
||||
* Clearing expired authentication sessions from the database
|
||||
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
|
||||
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention)
|
||||
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
|
||||
@@ -58,9 +58,11 @@ The following model fields support configurable choices:
|
||||
* `circuits.Circuit.status`
|
||||
* `dcim.Device.status`
|
||||
* `dcim.Location.status`
|
||||
* `dcim.Module.status`
|
||||
* `dcim.PowerFeed.status`
|
||||
* `dcim.Rack.status`
|
||||
* `dcim.Site.status`
|
||||
* `dcim.VirtualDeviceContext.status`
|
||||
* `extras.JournalEntry.kind`
|
||||
* `ipam.IPAddress.status`
|
||||
* `ipam.IPRange.status`
|
||||
@@ -68,6 +70,7 @@ The following model fields support configurable choices:
|
||||
* `ipam.VLAN.status`
|
||||
* `virtualization.Cluster.status`
|
||||
* `virtualization.VirtualMachine.status`
|
||||
* `wireless.WirelessLAN.status`
|
||||
|
||||
The following colors are supported:
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
|
||||
Default: `2621440` (2.5 MB).
|
||||
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
---
|
||||
|
||||
## GRAPHQL_ENABLED
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@@ -141,6 +149,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
|
||||
|
||||
---
|
||||
|
||||
## QUEUE_MAPPINGS
|
||||
|
||||
Allows changing which queues are used internally for background tasks.
|
||||
|
||||
```python
|
||||
QUEUE_MAPPINGS = {
|
||||
'webhook': 'low',
|
||||
'report': 'high',
|
||||
'script': 'high',
|
||||
}
|
||||
```
|
||||
|
||||
If no queue is defined the queue named `default` will be used.
|
||||
|
||||
---
|
||||
|
||||
## RELEASE_CHECK_URL
|
||||
|
||||
Default: None (disabled)
|
||||
|
||||
@@ -63,6 +63,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
|
||||
|
||||
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
|
||||
* `USERNAME` - Redis username (if set)
|
||||
* `PASSWORD` - Redis password (if set)
|
||||
* `DATABASE` - Numeric database ID
|
||||
* `SSL` - Use SSL connection to Redis
|
||||
@@ -75,6 +76,7 @@ REDIS = {
|
||||
'tasks': {
|
||||
'HOST': 'redis.example.com',
|
||||
'PORT': 1234,
|
||||
'USERNAME': 'netbox'
|
||||
'PASSWORD': 'foobar',
|
||||
'DATABASE': 0,
|
||||
'SSL': False,
|
||||
@@ -82,6 +84,7 @@ REDIS = {
|
||||
'caching': {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'USERNAME': ''
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'SSL': False,
|
||||
|
||||
@@ -137,6 +137,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
|
||||
|
||||
---
|
||||
|
||||
## LOGOUT_REDIRECT_URL
|
||||
|
||||
Default: `'home'`
|
||||
|
||||
The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_NAME
|
||||
|
||||
Default: `sessionid`
|
||||
|
||||
@@ -12,6 +12,17 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
---
|
||||
|
||||
## DEFAULT_LANGUAGE
|
||||
|
||||
Default: `en-us` (US English)
|
||||
|
||||
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
|
||||
|
||||
!!! note
|
||||
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
|
||||
|
||||
---
|
||||
|
||||
## DOCS_ROOT
|
||||
|
||||
Default: `$INSTALL_ROOT/docs/`
|
||||
@@ -54,6 +65,14 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
||||
|
||||
---
|
||||
|
||||
## ENABLE_LOCALIZATION
|
||||
|
||||
Default: False
|
||||
|
||||
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
|
||||
|
||||
---
|
||||
|
||||
## HTTP_PROXIES
|
||||
|
||||
Default: None
|
||||
|
||||
@@ -142,6 +142,19 @@ obj.full_clean()
|
||||
obj.save()
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Sometimes things go wrong and a script will run into an `Exception`. If that happens and an uncaught exception is raised by the custom script, the execution is aborted and a full stack trace is reported.
|
||||
|
||||
Although this is helpful for debugging, in some situations it might be required to cleanly abort the execution of a custom script (e.g. because of invalid input data) and thereby make sure no changes are performed on the database. In this case the script can throw an `AbortScript` exception, which will prevent the stack trace from being reported, but still terminating the script's execution and reporting a given error message.
|
||||
|
||||
```python
|
||||
from utilities.exceptions import AbortScript
|
||||
|
||||
if some_error:
|
||||
raise AbortScript("Some meaningful error message")
|
||||
```
|
||||
|
||||
## Variable Reference
|
||||
|
||||
### Default Options
|
||||
|
||||
@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
active = DeviceStatusChoices.STATUS_ACTIVE
|
||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
|
||||
if console_port.connected_endpoint is None:
|
||||
if not console_port.connected_endpoints:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
"No console connection defined for {}".format(console_port.name)
|
||||
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
|
||||
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
if power_port.connected_endpoints:
|
||||
connected_ports += 1
|
||||
if not power_port.path.is_active:
|
||||
self.log_warning(
|
||||
|
||||
@@ -54,15 +54,19 @@ Each model should have a corresponding FilterSet class defined. This is used to
|
||||
|
||||
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
|
||||
|
||||
## 9. Create the object template
|
||||
## 9. Create a SearchIndex subclass
|
||||
|
||||
If this model will be included in global search results, create a subclass of `netbox.search.SearchIndex` for it and specify the fields to be indexed.
|
||||
|
||||
## 10. Create the object template
|
||||
|
||||
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
|
||||
|
||||
## 10. Add the model to the navigation menu
|
||||
## 11. Add the model to the navigation menu
|
||||
|
||||
Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
|
||||
|
||||
## 11. REST API components
|
||||
## 12. REST API components
|
||||
|
||||
Create the following for each model:
|
||||
|
||||
@@ -71,13 +75,13 @@ Create the following for each model:
|
||||
* API view in `api/views.py`
|
||||
* Endpoint route in `api/urls.py`
|
||||
|
||||
## 12. GraphQL API components
|
||||
## 13. GraphQL API components
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
## 13. Add tests
|
||||
## 14. Add tests
|
||||
|
||||
Add tests for the following:
|
||||
|
||||
@@ -85,7 +89,7 @@ Add tests for the following:
|
||||
* API views
|
||||
* Filter sets
|
||||
|
||||
## 14. Documentation
|
||||
## 15. Documentation
|
||||
|
||||
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
|
||||
|
||||
|
||||
@@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
|
||||
|
||||
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
|
||||
|
||||
## 8. Update the UI templates
|
||||
## 8. Update the SearchIndex
|
||||
|
||||
Where applicable, add the new field to the model's SearchIndex for inclusion in global search.
|
||||
|
||||
## 9. Update the UI templates
|
||||
|
||||
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
|
||||
|
||||
## 9. Create/extend test cases
|
||||
## 10. Create/extend test cases
|
||||
|
||||
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
|
||||
|
||||
@@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac
|
||||
|
||||
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
|
||||
|
||||
## 10. Update the model's documentation
|
||||
## 11. Update the model's documentation
|
||||
|
||||
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.
|
||||
|
||||
@@ -52,4 +52,4 @@ NetBox is built on the enormously popular [Django](http://www.djangoproject.com/
|
||||
* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
|
||||
* The [installation guide](./installation/index.md) will help you get your own deployment up and running
|
||||
* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
|
||||
* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1
|
||||
* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/)
|
||||
|
||||
@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
* Builds the documentation locally (for offline use)
|
||||
* Aggregate static resource files on disk
|
||||
|
||||
!!! warning
|
||||
If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
|
||||
|
||||
```no-highlight
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
```
|
||||
@@ -269,7 +272,10 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt
|
||||
|
||||
## Test the Application
|
||||
|
||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
|
||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
|
||||
|
||||
!!! tip
|
||||
Check that the Python virtual environment is still active before attempting to run the server.
|
||||
|
||||
```no-highlight
|
||||
python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
|
||||
@@ -14,7 +14,10 @@ While the provided configuration should suffice for most initial installations,
|
||||
|
||||
## systemd Setup
|
||||
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon.
|
||||
|
||||
!!! warning "Check user & group assignment"
|
||||
The stock service configuration files packaged with NetBox assume that the service will run with the `netbox` user and group names. If these differ on your installation, be sure to update the service files accordingly.
|
||||
|
||||
```no-highlight
|
||||
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016.
|
||||
|
||||
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation.
|
||||
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. Today, the open source project is stewarded by [NetBox Labs](https://netboxlabs.com/) and a team of volunteer maintainers. Beyond the core product, myriad [plugins](https://netbox.dev/plugins/) have been developed by the NetBox community to enhance and expand its feature set.
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -17,6 +17,7 @@ NetBox was built specifically to serve the needs of network engineers and operat
|
||||
* AS number (ASN) management
|
||||
* Rack elevations with SVG rendering
|
||||
* Device modeling using pre-defined types
|
||||
* Virtual chassis and device contexts
|
||||
* Network, power, and console cabling with SVG traces
|
||||
* Power distribution modeling
|
||||
* Data circuit and provider tracking
|
||||
@@ -29,12 +30,13 @@ NetBox was built specifically to serve the needs of network engineers and operat
|
||||
* Tenant ownership assignment
|
||||
* Device & VM configuration contexts for advanced configuration rendering
|
||||
* Custom fields for data model extension
|
||||
* Support for custom validation rules
|
||||
* Custom validation rules
|
||||
* Custom reports & scripts executable directly within the UI
|
||||
* Extensive plugin framework for adding custom functionality
|
||||
* Single sign-on (SSO) authentication
|
||||
* Robust object-based permissions
|
||||
* Detailed, automatic change logging
|
||||
* Global search engine
|
||||
* NAPALM integration
|
||||
|
||||
## What NetBox Is Not
|
||||
|
||||
@@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed.
|
||||
|
||||
The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module.
|
||||
|
||||
### Status
|
||||
|
||||
The module's operational status.
|
||||
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Serial Number
|
||||
|
||||
The unique physical serial number assigned to this module by its manufacturer.
|
||||
|
||||
@@ -73,6 +73,10 @@ The maximum depth of a mounted device that the rack can accommodate, in millimet
|
||||
|
||||
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
|
||||
|
||||
### Maximum Weight
|
||||
|
||||
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
|
||||
|
||||
### Descending Units
|
||||
|
||||
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)
|
||||
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Branches
|
||||
|
||||
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be mered by executing its `commit()` method. Deleting a branch will delete all its related changes.
|
||||
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes.
|
||||
|
||||
## Fields
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ is represented in JSON as
|
||||
|
||||
```json
|
||||
{
|
||||
'tag': ['alpha', 'bravo'],
|
||||
'status': 'active',
|
||||
'region_id': 51
|
||||
"tag": ["alpha", "bravo"],
|
||||
"status": "active",
|
||||
"region_id": 51
|
||||
}
|
||||
```
|
||||
|
||||
@@ -23,7 +23,7 @@ The IPv4 or IPv6 address and mask, in CIDR notation (e.g. `192.0.2.0/24`).
|
||||
The operational status of the IP address.
|
||||
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `IPAddress.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
Additional statuses may be defined by setting `ipam.IPAddress.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Role
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ menu_items = (item1, item2, item3)
|
||||
|
||||
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
|
||||
|
||||
```python filename="navigation.py"
|
||||
```python title="navigation.py"
|
||||
from extras.plugins import PluginMenuButton, PluginMenuItem
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
|
||||
### Examples
|
||||
|
||||
`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied.
|
||||
`status` is "active" and `primary_ip4` is defined _or_ the "exempt" tag is applied.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -109,8 +109,8 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
"value": "active"
|
||||
},
|
||||
{
|
||||
"attr": "primary_ip",
|
||||
"value": "",
|
||||
"attr": "primary_ip4",
|
||||
"value": null,
|
||||
"negate": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -168,7 +168,7 @@ Some text to show that the reference links can follow later.
|
||||
## Images
|
||||
|
||||
```
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
Here's the NetBox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
@@ -179,7 +179,7 @@ Reference-style:
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
```
|
||||
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
Here's the NetBox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
@@ -10,6 +10,16 @@ Minor releases are published in April, August, and December of each calendar yea
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 3.4](./version-3.4.md) (December 2022)
|
||||
|
||||
* New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
|
||||
* Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
|
||||
* Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
|
||||
* JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
|
||||
* Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
|
||||
* Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
|
||||
* API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
|
||||
|
||||
#### [Version 3.3](./version-3.3.md) (August 2022)
|
||||
|
||||
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
# NetBox v3.3
|
||||
|
||||
## v3.3.9 (FUTURE)
|
||||
## v3.3.10 (2022-12-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
|
||||
* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
|
||||
* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
|
||||
* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
|
||||
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
|
||||
* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
|
||||
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
|
||||
* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
|
||||
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
|
||||
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
|
||||
* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
|
||||
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
|
||||
* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
|
||||
* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
|
||||
* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
|
||||
|
||||
---
|
||||
|
||||
## v3.3.9 (2022-11-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
|
||||
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
|
||||
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
|
||||
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
|
||||
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
|
||||
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
|
||||
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
|
||||
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
|
||||
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
|
||||
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
|
||||
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
|
||||
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
|
||||
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
|
||||
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
|
||||
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
|
||||
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
|
||||
|
||||
---
|
||||
|
||||
@@ -429,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
|
||||
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||
* Added the optional `device` field
|
||||
* Added the `l2vpn_termination` read-only field
|
||||
wireless.WirelessLAN
|
||||
* wireless.WirelessLAN
|
||||
* Added `tenant` field
|
||||
wireless.WirelessLink
|
||||
* wireless.WirelessLink
|
||||
* Added `tenant` field
|
||||
|
||||
@@ -1,22 +1,152 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.5 (2023-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11110](https://github.com/netbox-community/netbox/issues/11110) - Add `start_address` and `end_address` filters for IP ranges
|
||||
* [#11592](https://github.com/netbox-community/netbox/issues/11592) - Introduce `FILE_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#11685](https://github.com/netbox-community/netbox/issues/11685) - Match on containing prefixes and aggregates when querying for IP addresses using global search
|
||||
* [#11787](https://github.com/netbox-community/netbox/issues/11787) - Upgrade script will automatically rebuild missing search cache
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11032](https://github.com/netbox-community/netbox/issues/11032) - Fix false custom validation errors during component creation
|
||||
* [#11226](https://github.com/netbox-community/netbox/issues/11226) - Ensure scripts and reports within submodules are automatically reloaded
|
||||
* [#11459](https://github.com/netbox-community/netbox/issues/11459) - Enable evaluating null values in custom validation rules
|
||||
* [#11473](https://github.com/netbox-community/netbox/issues/11473) - GraphQL requests specifying an invalid filter should return an empty queryset
|
||||
* [#11582](https://github.com/netbox-community/netbox/issues/11582) - Ensure form validation errors are displayed when adding virtual chassis members
|
||||
* [#11601](https://github.com/netbox-community/netbox/issues/11601) - Fix partial matching of start/end addresses for IP range search
|
||||
* [#11683](https://github.com/netbox-community/netbox/issues/11683) - Fix CSV header attribute detection when auto-detecting import format
|
||||
* [#11711](https://github.com/netbox-community/netbox/issues/11711) - Fix CSV import for multiple-object custom fields
|
||||
* [#11723](https://github.com/netbox-community/netbox/issues/11723) - Circuit terminations should link to their associated circuits (rather than site or provider network)
|
||||
* [#11775](https://github.com/netbox-community/netbox/issues/11775) - Skip checking for old search cache records when creating a new object
|
||||
* [#11786](https://github.com/netbox-community/netbox/issues/11786) - List only applicable object types in form widget when filtering custom fields
|
||||
|
||||
---
|
||||
|
||||
## v3.4.4 (2023-02-02)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice
|
||||
* [#11152](https://github.com/netbox-community/netbox/issues/11152) - Introduce AbortScript exception to elegantly abort scripts
|
||||
* [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list
|
||||
* [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services
|
||||
* [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11267](https://github.com/netbox-community/netbox/issues/11267) - Avoid catching ImportErrors when loading plugin resources
|
||||
* [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit
|
||||
* [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table
|
||||
* [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file
|
||||
* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516))
|
||||
* [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names
|
||||
* [#11574](https://github.com/netbox-community/netbox/issues/11574) - Fix exception when attempting to schedule reports/scripts
|
||||
* [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type
|
||||
* [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view
|
||||
* [#11650](https://github.com/netbox-community/netbox/issues/11650) - Display error message when attempting to create device component with duplicate name
|
||||
|
||||
---
|
||||
|
||||
## v3.4.3 (2023-01-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9996](https://github.com/netbox-community/netbox/issues/9996) - Introduce `CA_CERT_PATH` parameter to define SSL CA path for Redis servers
|
||||
* [#10486](https://github.com/netbox-community/netbox/issues/10486) - Add a cable edit button for connected components in component lists
|
||||
* [#11118](https://github.com/netbox-community/netbox/issues/11118) - Add L2VPN filters for VLANs and interfaces
|
||||
* [#11150](https://github.com/netbox-community/netbox/issues/11150) - Add primary IPv4/v6 address filters for devices
|
||||
* [#11227](https://github.com/netbox-community/netbox/issues/11227) - Add 800GE interface types
|
||||
* [#11228](https://github.com/netbox-community/netbox/issues/11228) - List both devices & VMs under device role view
|
||||
* [#11245](https://github.com/netbox-community/netbox/issues/11245) - Enable export templates for journal entries
|
||||
* [#11371](https://github.com/netbox-community/netbox/issues/11371) - Introduce additional 100M Ethernet interface types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10201](https://github.com/netbox-community/netbox/issues/10201) - Fix AssertionError exception when removing some terminations from an existing cable
|
||||
* [#11210](https://github.com/netbox-community/netbox/issues/11210) - Fix ValueError exception when attempting to bulk import cables attached to occupied terminations
|
||||
* [#11340](https://github.com/netbox-community/netbox/issues/11340) - Avoid flagging cable termination changes erroneously
|
||||
* [#11379](https://github.com/netbox-community/netbox/issues/11379) - Fix TypeError exception when bulk editing custom date fields
|
||||
* [#11384](https://github.com/netbox-community/netbox/issues/11384) - Correct current time display on script & report forms
|
||||
* [#11402](https://github.com/netbox-community/netbox/issues/11402) - Avoid LookupError exception when running scripts with commit disabled
|
||||
* [#11403](https://github.com/netbox-community/netbox/issues/11403) - Fix exception when scheduling a job in the past
|
||||
* [#11416](https://github.com/netbox-community/netbox/issues/11416) - Avoid AttributeError exception when deleting a cabled circuit termination
|
||||
* [#11433](https://github.com/netbox-community/netbox/issues/11433) - Avoid AttributeError exception when generating API schema for views with custom schema
|
||||
* [#11438](https://github.com/netbox-community/netbox/issues/11438) - Fix deletion of scheduled job using non-default queues
|
||||
* [#11444](https://github.com/netbox-community/netbox/issues/11444) - Adding/removing a device from a device bay should record a pre-change snapshot on the device bay
|
||||
* [#11467](https://github.com/netbox-community/netbox/issues/11467) - Correct count on interfaces tab when viewing a VC master device
|
||||
* [#11483](https://github.com/netbox-community/netbox/issues/11483) - Apply configured formatting to custom date fields
|
||||
* [#11488](https://github.com/netbox-community/netbox/issues/11488) - Add missing `description` fields to several REST API serializers
|
||||
* [#11497](https://github.com/netbox-community/netbox/issues/11497) - Enforce `run_script` permission when executing scripts via REST API
|
||||
* ~[#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex~
|
||||
* [#11522](https://github.com/netbox-community/netbox/issues/11522) - Correct tag links under contact & tenant list views
|
||||
* [#11537](https://github.com/netbox-community/netbox/issues/11537) - Remove obsolete "Connection" column from power feeds table
|
||||
* [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address
|
||||
|
||||
---
|
||||
|
||||
## v3.4.2 (2023-01-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9285](https://github.com/netbox-community/netbox/issues/9285) - Enable specifying assigned component during bulk import of inventory items
|
||||
* [#10700](https://github.com/netbox-community/netbox/issues/10700) - Match device name when using modules quick search
|
||||
* [#11121](https://github.com/netbox-community/netbox/issues/11121) - Add VM resource totals to cluster view
|
||||
* [#11156](https://github.com/netbox-community/netbox/issues/11156) - Enable selecting assigned component when editing inventory item in UI
|
||||
* [#11223](https://github.com/netbox-community/netbox/issues/11223) - `reindex` management command should accept app label without model name
|
||||
* [#11244](https://github.com/netbox-community/netbox/issues/11244) - Add controls for saved filters to rack elevations list
|
||||
* [#11248](https://github.com/netbox-community/netbox/issues/11248) - Fix database migration when plugin with search indexer is enabled
|
||||
* [#11259](https://github.com/netbox-community/netbox/issues/11259) - Add support for Redis username configuration
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11280](https://github.com/netbox-community/netbox/issues/11280) - Fix errant newlines when exporting interfaces with multiple IP addresses assigned
|
||||
* [#11290](https://github.com/netbox-community/netbox/issues/11290) - Correct reporting of scheduled job duration
|
||||
* [#11232](https://github.com/netbox-community/netbox/issues/11232) - Enable partial & regular expression matching for non-string types in global search
|
||||
* [#11342](https://github.com/netbox-community/netbox/issues/11342) - Correct cable trace URL under "connection" tab for device components
|
||||
* [#11345](https://github.com/netbox-community/netbox/issues/11345) - Fix form validation for bulk import of modules
|
||||
|
||||
---
|
||||
|
||||
## v3.4.1 (2022-12-16)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9971](https://github.com/netbox-community/netbox/issues/9971) - Enable ordering of nested group models by name
|
||||
* [#11214](https://github.com/netbox-community/netbox/issues/11214) - Introduce the `DEFAULT_LANGUAGE` configuration parameter
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11175](https://github.com/netbox-community/netbox/issues/11175) - Fix cloning of fields containing special characters
|
||||
* [#11178](https://github.com/netbox-community/netbox/issues/11178) - Pressing enter in quick search box should not trigger bulk operations
|
||||
* [#11184](https://github.com/netbox-community/netbox/issues/11184) - Correct visualization of cable path which splits across multiple circuit terminations
|
||||
* [#11185](https://github.com/netbox-community/netbox/issues/11185) - Fix TemplateSyntaxError when viewing custom script results
|
||||
* [#11189](https://github.com/netbox-community/netbox/issues/11189) - Fix localization of dates & numbers
|
||||
* [#11205](https://github.com/netbox-community/netbox/issues/11205) - Correct cloning behavior for recursively-nested models
|
||||
* [#11206](https://github.com/netbox-community/netbox/issues/11206) - Avoid clearing assigned groups if `REMOTE_AUTH_DEFAULT_GROUPS` is invalid
|
||||
|
||||
---
|
||||
|
||||
## v3.4.0 (2022-12-14)
|
||||
|
||||
!!! warning "PostgreSQL 11 Required"
|
||||
NetBox v3.4 requires PostgreSQL 11 or later.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
|
||||
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
|
||||
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
|
||||
* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types.
|
||||
* The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead.
|
||||
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" within the same site will raise a validation error.
|
||||
* The `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the ASN and contact models introduced in NetBox v3.1 prior to upgrading.
|
||||
* The `content_type` fields on the CustomLink and ExportTemplate models have been renamed to `content_types` and now support the assignment of multiple content types per object.
|
||||
* Within the Python API, the `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, reference the object's `custom_field_data` attribute instead.
|
||||
* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5.
|
||||
|
||||
### New Features
|
||||
|
||||
#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
|
||||
|
||||
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much speedier, more intelligent search capability. Matches are returned in order of precedence regardless of object type, and matched field values are highlighted in the results. Additionally, custom field values are now included in global search results (when enabled). Plugins can also register their own models with the new global search engine.
|
||||
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much faster, more intelligent search capability. Results are returned in order of precedence regardless of object type, and matching field values are highlighted in the results. Additionally, custom field values are now included in global search results (where enabled). Plugins can also register their own models with the new global search engine.
|
||||
|
||||
#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
|
||||
|
||||
@@ -24,19 +154,31 @@ A new model representing virtual device contexts (VDCs) has been added. VDCs are
|
||||
|
||||
#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
|
||||
|
||||
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse as a convenience. Saved filters can be kept private, or shared among NetBox users.
|
||||
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse. For example, the query string
|
||||
|
||||
### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
|
||||
```
|
||||
?status=active®ion_id=12&tenant=acme
|
||||
```
|
||||
|
||||
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well.
|
||||
can be saved and applied to future queries as
|
||||
|
||||
#### CSV-Based Bulk Updates ([#7961](https://github.com/netbox-community/netbox/issues/7961))
|
||||
```
|
||||
?filter=my-custom-filter
|
||||
```
|
||||
|
||||
Saved filters can be kept private, or shared among NetBox users. They can be applied to both UI and REST API searches.
|
||||
|
||||
#### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
|
||||
|
||||
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most types of objects, has been extended to accept data formatted in JSON or YAML as well. This enables users to directly import objects from a variety of sources without needing to first convert data to CSV. NetBox will attempt to automatically determine the format of import data if not specified by the user.
|
||||
|
||||
#### Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
|
||||
|
||||
NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects.
|
||||
|
||||
#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
|
||||
|
||||
Reports and custom scripts can now be scheduled for execution at a desired time.
|
||||
Reports and custom scripts can now be scheduled for execution at a desired future time. Background scheduling is handled entirely by the existing RQ workers; there is no need to configure additional tasks to support scheduled jobs. When creating a scheduled job, the user may optionally specify an interval at which the job will run repeatedly (e.g. every 24 hours).
|
||||
|
||||
#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
|
||||
|
||||
@@ -47,6 +189,7 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
|
||||
* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search
|
||||
* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import
|
||||
* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
|
||||
@@ -59,23 +202,40 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
|
||||
* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
|
||||
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
|
||||
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data
|
||||
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
|
||||
* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add `status` field for modules
|
||||
* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models
|
||||
* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
|
||||
* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
|
||||
* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks
|
||||
* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns
|
||||
* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN
|
||||
* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types
|
||||
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
|
||||
* [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enable recurring execution of scheduled reports & scripts
|
||||
* [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
|
||||
|
||||
### Bug Fixes (from v3.4-beta1)
|
||||
|
||||
* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned
|
||||
* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs
|
||||
* [#10950](https://github.com/netbox-community/netbox/issues/10950) - Fix validation of VDC primary IPs
|
||||
* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables
|
||||
* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table
|
||||
* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects
|
||||
* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL
|
||||
* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words
|
||||
* [#11000](https://github.com/netbox-community/netbox/issues/11000) - Improve validation of YAML-formatted import data
|
||||
* [#11046](https://github.com/netbox-community/netbox/issues/11046) - Fix exception when caching very large field values for search
|
||||
* [#11154](https://github.com/netbox-community/netbox/issues/11154) - Index VM interface MAC address and MTU for global search
|
||||
* [#11171](https://github.com/netbox-community/netbox/issues/11171) - Fix querying of related objects under GraphQL API
|
||||
|
||||
### Plugins API
|
||||
|
||||
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Add `plugin_list_buttons` template tag to embed buttons on object lists
|
||||
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Enable embedding custom content on core list views via `list_buttons()` method
|
||||
* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
|
||||
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus
|
||||
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus using PluginMenu
|
||||
* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
|
||||
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps
|
||||
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps via `django_apps` attribute
|
||||
* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation
|
||||
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
|
||||
* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function
|
||||
@@ -85,11 +245,13 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
|
||||
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
|
||||
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
|
||||
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute on objects now returns deserialized custom field data
|
||||
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
|
||||
* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk
|
||||
* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app
|
||||
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
|
||||
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views
|
||||
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove unused custom `import_object()` function
|
||||
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
|
||||
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request as context when instantiating a FilterSet within UI views
|
||||
* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo
|
||||
* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization
|
||||
|
||||
@@ -104,41 +266,39 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
* dcim.Device
|
||||
* Added a `description` field
|
||||
* dcim.DeviceType
|
||||
* Added a `description` field
|
||||
* Added optional `weight` and `weight_unit` fields
|
||||
* Added `description`, `weight`, and `weight_unit` fields
|
||||
* dcim.Module
|
||||
* Added a `description` field
|
||||
* dcim.Interface
|
||||
* Added the `vdcs` field
|
||||
* dcim.Module
|
||||
* Added a required `status` field
|
||||
* dcim.ModuleType
|
||||
* Added a `description` field
|
||||
* Added optional `weight` and `weight_unit` fields
|
||||
* Added `description`, `weight`, and `weight_unit` fields
|
||||
* dcim.PowerFeed
|
||||
* Added a `description` field
|
||||
* dcim.PowerPanel
|
||||
* Added `description` and `comments` fields
|
||||
* dcim.Rack
|
||||
* Added a `description` field
|
||||
* Added optional `weight` and `weight_unit` fields
|
||||
* Added `description`, `mounting_depth`, `weight`, `max_weight`, and `weight_unit` fields
|
||||
* dcim.RackReservation
|
||||
* Added a `comments` field
|
||||
* dcim.VirtualChassis
|
||||
* Added `description` and `comments` fields
|
||||
* extras.CustomField
|
||||
* Added the `search_weight` field
|
||||
* Added a `search_weight` field
|
||||
* extras.CustomLink
|
||||
* Renamed `content_type` field to `content_types`
|
||||
* extras.ExportTemplate
|
||||
* Renamed `content_type` field to `content_types`
|
||||
* extras.JobResult
|
||||
* Added `scheduled` and `started` datetime fields
|
||||
* Added `interval`, `scheduled`, and `started` fields
|
||||
* ipam.Aggregate
|
||||
* Added a `comments` field
|
||||
* ipam.ASN
|
||||
* Added a `comments` field
|
||||
* ipam.FHRPGroup
|
||||
* Added a `comments` field
|
||||
* Added optional `name` field
|
||||
* Added `name` and `comments` fields
|
||||
* ipam.IPAddress
|
||||
* Added a `comments` field
|
||||
* ipam.IPRange
|
||||
|
||||
@@ -77,6 +77,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from django import forms
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Site
|
||||
from django.utils.translation import gettext as _
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
|
||||
from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
|
||||
|
||||
__all__ = (
|
||||
'CircuitImportForm',
|
||||
'CircuitTerminationImportForm',
|
||||
'CircuitTypeImportForm',
|
||||
'ProviderImportForm',
|
||||
'ProviderNetworkImportForm',
|
||||
@@ -76,3 +80,23 @@ class CircuitImportForm(NetBoxModelImportForm):
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
|
||||
'description', 'comments', 'tags'
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False
|
||||
)
|
||||
provider_network = CSVModelChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||
'pp_info', 'description',
|
||||
]
|
||||
|
||||
@@ -145,16 +145,28 @@ class CircuitTerminationForm(NetBoxModelForm):
|
||||
},
|
||||
required=False
|
||||
)
|
||||
provider_network_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label='Provider',
|
||||
initial_params={
|
||||
'networks': 'provider_network'
|
||||
}
|
||||
)
|
||||
provider_network = DynamicModelChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
query_params={
|
||||
'provider_id': '$provider_network_provider',
|
||||
},
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
|
||||
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': _("Physical circuit speed"),
|
||||
|
||||
@@ -104,6 +104,10 @@ class Circuit(PrimaryModel):
|
||||
clone_fields = (
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'circuits.CircuitType',
|
||||
'circuits.Provider',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
@@ -117,10 +121,6 @@ class Circuit(PrimaryModel):
|
||||
def __str__(self):
|
||||
return self.cid
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('circuits.Provider'), CircuitType]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
|
||||
@@ -196,12 +196,10 @@ class CircuitTermination(
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'Termination {self.term_side}: {self.site or self.provider_network}'
|
||||
return f'{self.circuit}: Termination {self.term_side}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
if self.site:
|
||||
return self.site.get_absolute_url()
|
||||
return self.provider_network.get_absolute_url()
|
||||
return self.circuit.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -108,6 +108,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = Circuit
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.add_permissions(
|
||||
'circuits.add_circuittermination',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
|
||||
@@ -233,6 +233,16 @@ class CircuitBulkImportView(generic.BulkImportView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitImportForm
|
||||
table = tables.CircuitTable
|
||||
additional_permissions = [
|
||||
'circuits.add_circuittermination',
|
||||
]
|
||||
related_object_forms = {
|
||||
'terminations': forms.CircuitTerminationImportForm,
|
||||
}
|
||||
|
||||
def prep_related_object_data(self, parent, data):
|
||||
data.update({'circuit': parent})
|
||||
return data
|
||||
|
||||
|
||||
class CircuitBulkEditView(generic.BulkEditView):
|
||||
|
||||
@@ -210,9 +210,9 @@ class RackSerializer(NetBoxModelSerializer):
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'weight', 'max_weight', 'weight_unit', 'desc_units',
|
||||
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -672,6 +672,22 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
config_context = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
|
||||
def get_config_context(self, obj):
|
||||
return obj.get_config_context()
|
||||
|
||||
|
||||
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
@@ -680,11 +696,15 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
|
||||
# Related object counts
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'interface_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -693,31 +713,16 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
module_bay = NestedModuleBaySerializer()
|
||||
module_type = NestedModuleTypeSerializer()
|
||||
status = ChoiceField(choices=ModuleStatusChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
config_context = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
|
||||
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
|
||||
def get_config_context(self, obj):
|
||||
return obj.get_config_context()
|
||||
|
||||
|
||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.JSONField()
|
||||
|
||||
@@ -931,7 +936,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['id', 'url', 'display', 'name', 'label']
|
||||
fields = ['id', 'url', 'display', 'name', 'label', 'description']
|
||||
|
||||
|
||||
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
@@ -1055,7 +1060,7 @@ class TracedCableSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit',
|
||||
'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -541,6 +541,8 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDeviceContext.objects.prefetch_related(
|
||||
'device__device_type', 'device', 'tenant', 'tags',
|
||||
).annotate(
|
||||
interface_count=count_related(Interface, 'vdcs'),
|
||||
)
|
||||
serializer_class = serializers.VirtualDeviceContextSerializer
|
||||
filterset_class = filtersets.VirtualDeviceContextFilterSet
|
||||
|
||||
@@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
|
||||
TYPE_4POST = '4-post-frame'
|
||||
TYPE_CABINET = '4-post-cabinet'
|
||||
TYPE_WALLFRAME = 'wall-frame'
|
||||
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
|
||||
TYPE_WALLCABINET = 'wall-cabinet'
|
||||
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_2POST, '2-post frame'),
|
||||
(TYPE_4POST, '4-post frame'),
|
||||
(TYPE_CABINET, '4-post cabinet'),
|
||||
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
|
||||
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
|
||||
)
|
||||
|
||||
|
||||
@@ -194,6 +198,30 @@ class DeviceAirflowChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleStatusChoices(ChoiceSet):
|
||||
key = 'Module.status'
|
||||
|
||||
STATUS_OFFLINE = 'offline'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGED = 'staged'
|
||||
STATUS_FAILED = 'failed'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_OFFLINE, 'Offline', 'gray'),
|
||||
(STATUS_ACTIVE, 'Active', 'green'),
|
||||
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||
(STATUS_STAGED, 'Staged', 'blue'),
|
||||
(STATUS_FAILED, 'Failed', 'red'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# ConsolePorts
|
||||
#
|
||||
@@ -757,7 +785,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_LAG = 'lag'
|
||||
|
||||
# Ethernet
|
||||
TYPE_100ME_FX = '100base-fx'
|
||||
TYPE_100ME_LFX = '100base-lfx'
|
||||
TYPE_100ME_FIXED = '100base-tx'
|
||||
TYPE_100ME_T1 = '100base-t1'
|
||||
TYPE_1GE_FIXED = '1000base-t'
|
||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||
@@ -782,6 +813,8 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
TYPE_800GE_OSFP = '800gbase-x-osfp'
|
||||
|
||||
# Ethernet Backplane
|
||||
TYPE_1GE_KX = '1000base-kx'
|
||||
@@ -890,7 +923,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(
|
||||
'Ethernet (fixed)',
|
||||
(
|
||||
(TYPE_100ME_FX, '100BASE-FX (10/100ME FIBER)'),
|
||||
(TYPE_100ME_LFX, '100BASE-LFX (10/100ME FIBER)'),
|
||||
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
||||
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
||||
@@ -920,6 +956,8 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
|
||||
@@ -55,6 +55,8 @@ class MACAddressField(models.Field):
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if type(value) is str:
|
||||
value = value.replace(' ', '')
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from ipam.models import ASN, VRF
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
)
|
||||
@@ -322,7 +322,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'weight_unit'
|
||||
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -958,6 +958,16 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_device_bays',
|
||||
label=_('Has device bays'),
|
||||
)
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -1082,18 +1092,23 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device (ID)'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=ModuleStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['id', 'asset_tag']
|
||||
fields = ['id', 'status', 'asset_tag']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value.strip()) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
@@ -1399,6 +1414,17 @@ class InterfaceFilterSet(
|
||||
to_field_name='name',
|
||||
label='Virtual Device Context',
|
||||
)
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn',
|
||||
queryset=L2VPN.objects.all(),
|
||||
label=_('L2VPN (ID)'),
|
||||
)
|
||||
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn__identifier',
|
||||
queryset=L2VPN.objects.all(),
|
||||
to_field_name='identifier',
|
||||
label=_('L2VPN'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
|
||||
@@ -294,6 +294,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
min_value=0,
|
||||
required=False
|
||||
)
|
||||
max_weight = forms.IntegerField(
|
||||
min_value=0,
|
||||
required=False
|
||||
)
|
||||
weight_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(WeightUnitChoices),
|
||||
required=False,
|
||||
@@ -316,11 +320,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
('Hardware', (
|
||||
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
|
||||
)),
|
||||
('Weight', ('weight', 'weight_unit')),
|
||||
('Weight', ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
|
||||
'weight_unit', 'description', 'comments',
|
||||
'max_weight', 'weight_unit', 'description', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@@ -574,6 +578,12 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
'manufacturer_id': '$manufacturer'
|
||||
}
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
choices=add_blank_choice(ModuleStatusChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
serial = forms.CharField(
|
||||
max_length=50,
|
||||
required=False,
|
||||
@@ -590,7 +600,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Module
|
||||
fieldsets = (
|
||||
(None, ('manufacturer', 'module_type', 'serial', 'description')),
|
||||
(None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
|
||||
)
|
||||
nullable_fields = ('serial', 'description', 'comments')
|
||||
|
||||
@@ -1321,7 +1331,7 @@ class FrontPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class RearPortBulkEditForm(
|
||||
@@ -1332,7 +1342,7 @@ class RearPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class ModuleBayBulkEditForm(
|
||||
|
||||
@@ -14,10 +14,10 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableImportForm',
|
||||
'ChildDeviceImportForm',
|
||||
'ConsolePortImportForm',
|
||||
'ConsoleServerPortImportForm',
|
||||
'DeviceBayImportForm',
|
||||
@@ -195,13 +195,18 @@ class RackImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('Unit for outer dimensions')
|
||||
)
|
||||
weight_unit = CSVChoiceField(
|
||||
choices=WeightUnitChoices,
|
||||
required=False,
|
||||
help_text=_('Unit for rack weights')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = (
|
||||
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
|
||||
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
|
||||
'description', 'comments', 'tags',
|
||||
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
|
||||
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -407,6 +412,18 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
required=False,
|
||||
help_text=_('Mounted rack face')
|
||||
)
|
||||
parent = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Parent device (for child devices)')
|
||||
)
|
||||
device_bay = CSVModelChoiceField(
|
||||
queryset=DeviceBay.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Device bay in which this device is installed (for child devices)')
|
||||
)
|
||||
airflow = CSVChoiceField(
|
||||
choices=DeviceAirflowChoices,
|
||||
required=False,
|
||||
@@ -416,8 +433,8 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
class Meta(BaseDeviceImportForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'cluster', 'description', 'comments', 'tags',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -428,6 +445,7 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
# Limit location queryset by assigned site
|
||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
# Limit rack queryset by assigned site and group
|
||||
params = {
|
||||
@@ -436,25 +454,58 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
}
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
# Limit device bay queryset by parent device
|
||||
if parent := data.get('parent'):
|
||||
params = {f"device__{self.fields['parent'].to_field_name}": parent}
|
||||
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
|
||||
|
||||
class ModuleImportForm(NetBoxModelImportForm):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Inherit site and rack from parent device
|
||||
if parent := self.cleaned_data.get('parent'):
|
||||
self.instance.site = parent.site
|
||||
self.instance.rack = parent.rack
|
||||
|
||||
# Set parent_bay reverse relationship
|
||||
if device_bay := self.cleaned_data.get('device_bay'):
|
||||
self.instance.parent_bay = device_bay
|
||||
|
||||
|
||||
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
to_field_name='name',
|
||||
help_text=_('The device in which this module is installed')
|
||||
)
|
||||
module_bay = CSVModelChoiceField(
|
||||
queryset=ModuleBay.objects.all(),
|
||||
to_field_name='name'
|
||||
to_field_name='name',
|
||||
help_text=_('The module bay in which this module is installed')
|
||||
)
|
||||
module_type = CSVModelChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
to_field_name='model'
|
||||
to_field_name='model',
|
||||
help_text=_('The type of module')
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=ModuleStatusChoices,
|
||||
help_text=_('Operational status')
|
||||
)
|
||||
replicate_components = forms.BooleanField(
|
||||
required=False,
|
||||
help_text=_('Automatically populate components associated with this module type (enabled by default)')
|
||||
)
|
||||
adopt_components = forms.BooleanField(
|
||||
required=False,
|
||||
help_text=_('Adopt already existing components')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = (
|
||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags',
|
||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments',
|
||||
'replicate_components', 'adopt_components', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -465,47 +516,12 @@ class ModuleImportForm(NetBoxModelImportForm):
|
||||
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
||||
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
|
||||
|
||||
|
||||
class ChildDeviceImportForm(BaseDeviceImportForm):
|
||||
parent = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Parent device')
|
||||
)
|
||||
device_bay = CSVModelChoiceField(
|
||||
queryset=DeviceBay.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Device bay in which this device is installed')
|
||||
)
|
||||
|
||||
class Meta(BaseDeviceImportForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags'
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
|
||||
# Limit device bay queryset by parent device
|
||||
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
|
||||
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Set parent_bay reverse relationship
|
||||
device_bay = self.cleaned_data.get('device_bay')
|
||||
if device_bay:
|
||||
self.instance.parent_bay = device_bay
|
||||
|
||||
# Inherit site and rack from parent device
|
||||
parent = self.cleaned_data.get('parent')
|
||||
if parent:
|
||||
self.instance.site = parent.site
|
||||
self.instance.rack = parent.rack
|
||||
def clean_replicate_components(self):
|
||||
# Make sure replicate_components is True when it's not included in the uploaded data
|
||||
if 'replicate_components' not in self.data:
|
||||
return True
|
||||
else:
|
||||
return self.cleaned_data['replicate_components']
|
||||
|
||||
|
||||
#
|
||||
@@ -856,12 +872,22 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('Parent inventory item')
|
||||
)
|
||||
component_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=MODULAR_COMPONENT_MODELS,
|
||||
required=False,
|
||||
help_text=_('Component Type')
|
||||
)
|
||||
component_name = forms.CharField(
|
||||
required=False,
|
||||
help_text=_('Component Name')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'description', 'tags'
|
||||
'description', 'tags', 'component_type', 'component_name',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -879,6 +905,24 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
self.fields['parent'].queryset = InventoryItem.objects.none()
|
||||
|
||||
def clean_component_name(self):
|
||||
content_type = self.cleaned_data.get('component_type')
|
||||
component_name = self.cleaned_data.get('component_name')
|
||||
device = self.cleaned_data.get("device")
|
||||
|
||||
if not device and hasattr(self, 'instance'):
|
||||
device = self.instance.device
|
||||
|
||||
if not all([device, content_type, component_name]):
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
|
||||
@@ -6,6 +6,7 @@ from dcim.constants import *
|
||||
|
||||
__all__ = (
|
||||
'InterfaceCommonForm',
|
||||
'ModuleCommonForm'
|
||||
)
|
||||
|
||||
|
||||
@@ -48,3 +49,62 @@ class InterfaceCommonForm(forms.Form):
|
||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||
f"the interface's parent device/VM, or they must be global"
|
||||
})
|
||||
|
||||
|
||||
class ModuleCommonForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get('replicate_components')
|
||||
adopt_components = self.cleaned_data.get('adopt_components')
|
||||
device = self.cleaned_data.get('device')
|
||||
module_type = self.cleaned_data.get('module_type')
|
||||
module_bay = self.cleaned_data.get('module_bay')
|
||||
|
||||
if adopt_components:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components (or if
|
||||
# validation has already failed)
|
||||
if self.errors or self.instance.pk or not replicate_components:
|
||||
self.instance._disable_replication = True
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from ipam.models import ASN, VRF
|
||||
from ipam.models import ASN, L2VPN, VRF
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import (
|
||||
@@ -229,7 +229,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Weight', ('weight', 'weight_unit')),
|
||||
('Weight', ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -284,7 +284,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
weight = forms.DecimalField(
|
||||
required=False
|
||||
required=False,
|
||||
min_value=1
|
||||
)
|
||||
max_weight = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1
|
||||
)
|
||||
weight_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(WeightUnitChoices),
|
||||
@@ -763,7 +768,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
model = Module
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
|
||||
('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -780,6 +785,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
label=_('Type'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=ModuleStatusChoices,
|
||||
required=False
|
||||
)
|
||||
serial = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
@@ -1103,7 +1112,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
|
||||
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
|
||||
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
|
||||
@@ -1161,7 +1170,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label='PoE type'
|
||||
)
|
||||
@@ -1194,6 +1203,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
required=False,
|
||||
label='VRF'
|
||||
)
|
||||
l2vpn_id = DynamicModelMultipleChoiceField(
|
||||
queryset=L2VPN.objects.all(),
|
||||
required=False,
|
||||
label=_('L2VPN')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from utilities.forms import (
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableForm',
|
||||
@@ -279,7 +279,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
|
||||
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'site': _("The site at which the rack exists"),
|
||||
@@ -662,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class ModuleForm(NetBoxModelForm):
|
||||
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
initial_params={
|
||||
@@ -703,7 +703,7 @@ class ModuleForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Module', (
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags',
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
|
||||
)),
|
||||
('Hardware', (
|
||||
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
|
||||
@@ -713,7 +713,7 @@ class ModuleForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
|
||||
'replicate_components', 'adopt_components', 'description', 'comments',
|
||||
]
|
||||
|
||||
@@ -727,68 +727,6 @@ class ModuleForm(NetBoxModelForm):
|
||||
self.fields['adopt_components'].initial = False
|
||||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# If replicate_components is False, disable automatic component replication on the instance
|
||||
if self.instance.pk or not self.cleaned_data['replicate_components']:
|
||||
self.instance._disable_replication = True
|
||||
|
||||
if self.cleaned_data['adopt_components']:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data['device']
|
||||
module_type = self.cleaned_data['module_type']
|
||||
module_bay = self.cleaned_data['module_bay']
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
@@ -1462,7 +1400,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
|
||||
wwn = forms.CharField(
|
||||
empty_value=None,
|
||||
required=False,
|
||||
@@ -1470,9 +1407,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
@@ -1612,15 +1549,63 @@ class InventoryItemForm(DeviceComponentForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
)
|
||||
component_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=MODULAR_COMPONENT_MODELS,
|
||||
|
||||
# Assigned component selectors
|
||||
consoleport = DynamicModelChoiceField(
|
||||
queryset=ConsolePort.objects.all(),
|
||||
required=False,
|
||||
widget=forms.HiddenInput
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Console port')
|
||||
)
|
||||
component_id = forms.IntegerField(
|
||||
consoleserverport = DynamicModelChoiceField(
|
||||
queryset=ConsoleServerPort.objects.all(),
|
||||
required=False,
|
||||
widget=forms.HiddenInput
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Console server port')
|
||||
)
|
||||
frontport = DynamicModelChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Front port')
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Interface')
|
||||
)
|
||||
poweroutlet = DynamicModelChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Power outlet')
|
||||
)
|
||||
powerport = DynamicModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Power port')
|
||||
)
|
||||
rearport = DynamicModelChoiceField(
|
||||
queryset=RearPort.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
},
|
||||
label=_('Rear port')
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
@@ -1632,11 +1617,57 @@ class InventoryItemForm(DeviceComponentForm):
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
|
||||
'description', 'component_type', 'component_id', 'tags',
|
||||
'description', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
component_type = initial.get('component_type')
|
||||
component_id = initial.get('component_id')
|
||||
|
||||
# Used for picking the default active tab for component selection
|
||||
self.no_component = True
|
||||
|
||||
if instance:
|
||||
# When editing set the initial value for component selectin
|
||||
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
|
||||
if type(instance.component) is component_model.model_class():
|
||||
initial[component_model.model] = instance.component
|
||||
self.no_component = False
|
||||
break
|
||||
elif component_type and component_id:
|
||||
# When adding the InventoryItem from a component page
|
||||
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
|
||||
if component := content_type.model_class().objects.filter(pk=component_id).first():
|
||||
initial[content_type.model] = component
|
||||
self.no_component = False
|
||||
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Specifically allow editing the device of IntentoryItems
|
||||
if self.instance.pk:
|
||||
self.fields['device'].disabled = False
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Handle object assignment
|
||||
selected_objects = [
|
||||
field for field in (
|
||||
'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'
|
||||
) if self.cleaned_data[field]
|
||||
]
|
||||
if len(selected_objects) > 1:
|
||||
raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
|
||||
elif selected_objects:
|
||||
self.instance.component = self.cleaned_data[selected_objects[0]]
|
||||
else:
|
||||
self.instance.component = None
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
#
|
||||
|
||||
@@ -1705,6 +1736,24 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
)
|
||||
primary_ip4 = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
label='Primary IPv4',
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'family': '4',
|
||||
}
|
||||
)
|
||||
primary_ip6 = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
label='Primary IPv6',
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'family': '6',
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Generated by Django 4.0.7 on 2022-09-23 01:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -10,11 +8,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Device types
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='weight',
|
||||
@@ -26,10 +21,12 @@ class Migration(migrations.Migration):
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='moduletype',
|
||||
model_name='devicetype',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Module types
|
||||
migrations.AddField(
|
||||
model_name='moduletype',
|
||||
name='weight',
|
||||
@@ -41,18 +38,35 @@ class Migration(migrations.Migration):
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
model_name='moduletype',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Racks
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='weight',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='max_weight',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='weight_unit',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='_abs_max_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0163_rack_devicetype_moduletype_weights'),
|
||||
('dcim', '0163_weight_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
18
netbox/dcim/migrations/0167_module_status.py
Normal file
18
netbox/dcim/migrations/0167_module_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.2 on 2022-12-09 15:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0166_virtualdevicecontext'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='status',
|
||||
field=models.CharField(default='active', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -112,6 +112,10 @@ class Cable(PrimaryModel):
|
||||
def a_terminations(self):
|
||||
if hasattr(self, '_a_terminations'):
|
||||
return self._a_terminations
|
||||
|
||||
if not self.pk:
|
||||
return []
|
||||
|
||||
# Query self.terminations.all() to leverage cached results
|
||||
return [
|
||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
|
||||
@@ -119,13 +123,18 @@ class Cable(PrimaryModel):
|
||||
|
||||
@a_terminations.setter
|
||||
def a_terminations(self, value):
|
||||
self._terminations_modified = True
|
||||
if not self.pk or self.a_terminations != list(value):
|
||||
self._terminations_modified = True
|
||||
self._a_terminations = value
|
||||
|
||||
@property
|
||||
def b_terminations(self):
|
||||
if hasattr(self, '_b_terminations'):
|
||||
return self._b_terminations
|
||||
|
||||
if not self.pk:
|
||||
return []
|
||||
|
||||
# Query self.terminations.all() to leverage cached results
|
||||
return [
|
||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
|
||||
@@ -133,7 +142,8 @@ class Cable(PrimaryModel):
|
||||
|
||||
@b_terminations.setter
|
||||
def b_terminations(self, value):
|
||||
self._terminations_modified = True
|
||||
if not self.pk or self.b_terminations != list(value):
|
||||
self._terminations_modified = True
|
||||
self._b_terminations = value
|
||||
|
||||
def clean(self):
|
||||
@@ -279,6 +289,17 @@ class CableTermination(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check for existing termination
|
||||
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
|
||||
termination_type=self.termination_type,
|
||||
termination_id=self.termination_id
|
||||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
@@ -516,7 +537,7 @@ class CablePath(models.Model):
|
||||
|
||||
# Step 5: Record the far-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in remote_terminations
|
||||
object_to_path_node(t) for t in remote_terminations if t is not None
|
||||
])
|
||||
|
||||
# Step 6: Determine the "next hop" terminations, if applicable
|
||||
@@ -556,11 +577,12 @@ class CablePath(models.Model):
|
||||
|
||||
elif isinstance(remote_terminations[0], CircuitTermination):
|
||||
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
|
||||
term_side = remote_terminations[0].term_side
|
||||
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
|
||||
if len(remote_terminations) > 1:
|
||||
is_split = True
|
||||
break
|
||||
circuit_termination = CircuitTermination.objects.filter(
|
||||
circuit=remote_terminations[0].circuit,
|
||||
term_side='Z' if term_side == 'A' else 'A'
|
||||
term_side='Z' if remote_terminations[0].term_side == 'A' else 'A'
|
||||
).first()
|
||||
if circuit_termination is None:
|
||||
break
|
||||
@@ -570,6 +592,7 @@ class CablePath(models.Model):
|
||||
[object_to_path_node(circuit_termination)],
|
||||
[object_to_path_node(circuit_termination.provider_network)],
|
||||
])
|
||||
is_complete = True
|
||||
break
|
||||
elif circuit_termination.site and not circuit_termination.cable:
|
||||
# Circuit terminates to a Site
|
||||
@@ -673,6 +696,7 @@ class CablePath(models.Model):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
from circuits.models import CircuitTermination
|
||||
nodes = self.path_objects[-1]
|
||||
|
||||
# RearPort splitting to multiple FrontPorts with no stack position
|
||||
@@ -682,3 +706,8 @@ class CablePath(models.Model):
|
||||
# RearPorts connected to different cables
|
||||
elif type(nodes[0]) is FrontPort:
|
||||
return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
|
||||
# Cable terminating to multiple CircuitTerminations
|
||||
elif type(nodes[0]) is CircuitTermination:
|
||||
return [
|
||||
ct.get_peer_termination() for ct in nodes
|
||||
]
|
||||
|
||||
@@ -197,7 +197,7 @@ class PathEndpoint(models.Model):
|
||||
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
|
||||
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
|
||||
|
||||
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
@@ -1129,3 +1129,25 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
# Validation for moving InventoryItems
|
||||
if self.pk:
|
||||
# Cannot move an InventoryItem to another device if it has a parent
|
||||
if self.parent and self.parent.device != self.device:
|
||||
raise ValidationError({
|
||||
"parent": "Parent inventory item does not belong to the same device."
|
||||
})
|
||||
|
||||
# Prevent moving InventoryItems with children
|
||||
first_child = self.get_children().first()
|
||||
if first_child and first_child.device != self.device:
|
||||
raise ValidationError("Cannot move an inventory item with dependent children")
|
||||
|
||||
# When moving an InventoryItem to another device, remove any associated component
|
||||
if self.component and self.component.device != self.device:
|
||||
self.component = None
|
||||
else:
|
||||
if self.component and self.component.device != self.device:
|
||||
raise ValidationError({
|
||||
"device": "Cannot assign inventory item to component on another device"
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import yaml
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -124,6 +123,9 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.Manufacturer',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'model']
|
||||
@@ -151,10 +153,6 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [Manufacturer, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
@@ -325,6 +323,9 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
)
|
||||
|
||||
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
|
||||
prerequisite_models = (
|
||||
'dcim.Manufacturer',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('manufacturer', 'model')
|
||||
@@ -338,10 +339,6 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
def __str__(self):
|
||||
return self.model
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [Manufacturer, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:moduletype', args=[self.pk])
|
||||
|
||||
@@ -599,6 +596,11 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
|
||||
'cluster', 'virtual_chassis',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
'dcim.DeviceRole',
|
||||
'dcim.DeviceType',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name', 'pk') # Name may be null
|
||||
@@ -638,10 +640,6 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
|
||||
return super().__str__()
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:device', args=[self.pk])
|
||||
|
||||
@@ -927,6 +925,11 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='instances'
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=ModuleStatusChoices,
|
||||
default=ModuleStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
serial = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
@@ -941,7 +944,7 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
help_text=_('A unique tag used to identify this device')
|
||||
)
|
||||
|
||||
clone_fields = ('device', 'module_type')
|
||||
clone_fields = ('device', 'module_type', 'status')
|
||||
|
||||
class Meta:
|
||||
ordering = ('module_bay',)
|
||||
@@ -952,10 +955,13 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:module', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return ModuleStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.module_bay.device != self.device:
|
||||
if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
|
||||
raise ValidationError(
|
||||
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
|
||||
)
|
||||
@@ -1176,3 +1182,20 @@ class VirtualDeviceContext(PrimaryModel):
|
||||
return self.primary_ip4
|
||||
else:
|
||||
return None
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate primary IPv4/v6 assignment
|
||||
for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)):
|
||||
if not primary_ip:
|
||||
continue
|
||||
if primary_ip.family != family:
|
||||
raise ValidationError({
|
||||
f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
|
||||
})
|
||||
device_interfaces = self.device.vc_interfaces(if_master=False)
|
||||
if primary_ip.assigned_object not in device_interfaces:
|
||||
raise ValidationError({
|
||||
f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')
|
||||
})
|
||||
|
||||
@@ -39,7 +39,5 @@ class WeightMixin(models.Model):
|
||||
super().clean()
|
||||
|
||||
# Validate weight and weight_unit
|
||||
if self.weight is not None and not self.weight_unit:
|
||||
if self.weight and not self.weight_unit:
|
||||
raise ValidationError("Must specify a unit when setting a weight")
|
||||
elif self.weight is None:
|
||||
self.weight_unit = ''
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -48,6 +47,10 @@ class PowerPanel(PrimaryModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
constraints = (
|
||||
@@ -60,10 +63,6 @@ class PowerPanel(PrimaryModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerpanel', args=[self.pk])
|
||||
|
||||
@@ -137,6 +136,9 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.PowerPanel',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['power_panel', 'name']
|
||||
@@ -150,10 +152,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [PowerPanel, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerfeed', args=[self.pk])
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import decimal
|
||||
from functools import cached_property
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@@ -18,7 +17,7 @@ from dcim.svg import RackElevationSVG
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.utils import array_to_string, drange
|
||||
from utilities.utils import array_to_string, drange, to_grams
|
||||
from .device_components import PowerPort
|
||||
from .devices import Device, Module
|
||||
from .mixins import WeightMixin
|
||||
@@ -150,6 +149,16 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
choices=RackDimensionUnitChoices,
|
||||
blank=True,
|
||||
)
|
||||
max_weight = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Maximum load capacity for the rack')
|
||||
)
|
||||
# Stores the normalized max weight (in grams) for database ordering
|
||||
_abs_max_weight = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
mounting_depth = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -175,7 +184,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
|
||||
clone_fields = (
|
||||
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit',
|
||||
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -197,10 +209,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
return f'{self.name} ({self.facility_id})'
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
|
||||
@@ -217,6 +225,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
elif self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
# Validate max_weight and weight_unit
|
||||
if self.max_weight and not self.weight_unit:
|
||||
raise ValidationError("Must specify a unit when setting a maximum weight")
|
||||
|
||||
if self.pk:
|
||||
# Validate that Rack is tall enough to house the installed Devices
|
||||
top_device = Device.objects.filter(
|
||||
@@ -239,6 +251,16 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
'location': f"Location must be from the same site, {self.site}."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given max weight (if any) in grams for use in database ordering
|
||||
if self.max_weight and self.weight_unit:
|
||||
self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
|
||||
else:
|
||||
self._abs_max_weight = None
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
"""
|
||||
@@ -488,16 +510,17 @@ class RackReservation(PrimaryModel):
|
||||
max_length=200
|
||||
)
|
||||
|
||||
clone_fields = ('rack', 'user', 'tenant')
|
||||
prerequisite_models = (
|
||||
'dcim.Rack',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), Rack, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rackreservation', args=[self.pk])
|
||||
|
||||
|
||||
@@ -286,6 +286,9 @@ class Location(NestedGroupModel):
|
||||
)
|
||||
|
||||
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -312,10 +315,6 @@ class Location(NestedGroupModel):
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [Site, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableEndChoices, LinkStatusChoices
|
||||
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
@@ -122,4 +124,18 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
|
||||
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
||||
# Remove the deleted CableTermination if it's one of the path's originating nodes
|
||||
if instance.termination in cablepath.origins:
|
||||
cablepath.origins.remove(instance.termination)
|
||||
cablepath.retrace()
|
||||
|
||||
|
||||
@receiver(post_save, sender=FrontPort)
|
||||
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
|
||||
"""
|
||||
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
|
||||
"""
|
||||
if created and not raw:
|
||||
rearport = instance.rear_port
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
|
||||
cablepath.retrace()
|
||||
|
||||
@@ -139,7 +139,8 @@ class PlatformTable(NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
region = tables.Column(
|
||||
@@ -220,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
|
||||
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code=DEVICE_LINK
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
site = tables.Column(
|
||||
@@ -504,6 +506,9 @@ class BaseInterfaceTable(NetBoxTable):
|
||||
verbose_name='Tagged VLANs'
|
||||
)
|
||||
|
||||
def value_ip_addresses(self, value):
|
||||
return ",".join([str(obj.address) for obj in value.all()])
|
||||
|
||||
|
||||
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
|
||||
device = tables.Column(
|
||||
@@ -521,6 +526,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
orderable=False,
|
||||
verbose_name='Wireless LANs'
|
||||
)
|
||||
vdcs = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='VDCs'
|
||||
)
|
||||
vrf = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -534,7 +543,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
@@ -568,10 +577,9 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
|
||||
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
||||
'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
)
|
||||
order_by = ('name',)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
|
||||
'cable', 'connection',
|
||||
@@ -893,7 +901,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
device = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
primary_ip = tables.Column(
|
||||
@@ -909,6 +918,11 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name='IPv6 Address'
|
||||
)
|
||||
interface_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:interface_list',
|
||||
url_params={'vdc_id': 'pk'},
|
||||
verbose_name='Interfaces'
|
||||
)
|
||||
|
||||
comments = columns.MarkdownColumn()
|
||||
|
||||
@@ -919,8 +933,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.VirtualDeviceContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'comments', 'tags', 'interface_count', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',
|
||||
|
||||
@@ -3,7 +3,7 @@ import django_tables2 as tables
|
||||
from dcim import models
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
|
||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'ConsolePortTemplateTable',
|
||||
@@ -34,10 +34,19 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name='Device Types'
|
||||
)
|
||||
inventoryitem_count = tables.Column(
|
||||
moduletype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:moduletype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name='Module Types'
|
||||
)
|
||||
inventoryitem_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:inventoryitem_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name='Inventory Items'
|
||||
)
|
||||
platform_count = tables.Column(
|
||||
platform_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:platform_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
@@ -48,11 +57,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'contacts', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug',
|
||||
)
|
||||
|
||||
|
||||
@@ -84,7 +94,7 @@ class DeviceTypeTable(NetBoxTable):
|
||||
template_code='{{ value|floatformat }}'
|
||||
)
|
||||
weight = columns.TemplateColumn(
|
||||
template_code=DEVICE_WEIGHT,
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
|
||||
from dcim.models import Module, ModuleType
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from .template_code import DEVICE_WEIGHT
|
||||
from .template_code import WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'ModuleTable',
|
||||
@@ -28,7 +28,7 @@ class ModuleTypeTable(NetBoxTable):
|
||||
url_name='dcim:moduletype_list'
|
||||
)
|
||||
weight = columns.TemplateColumn(
|
||||
template_code=DEVICE_WEIGHT,
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ class ModuleTable(NetBoxTable):
|
||||
module_type = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:module_list'
|
||||
@@ -64,9 +65,9 @@ class ModuleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Module
|
||||
fields = (
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description',
|
||||
'comments', 'tags',
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
'description', 'comments', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
|
||||
'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
from .template_code import DEVICE_WEIGHT
|
||||
from .template_code import WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
@@ -81,17 +81,21 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name='Outer Depth'
|
||||
)
|
||||
weight = columns.TemplateColumn(
|
||||
template_code=DEVICE_WEIGHT,
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
max_weight = columns.TemplateColumn(
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_max_weight', 'weight_unit')
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
|
||||
'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
|
||||
'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags',
|
||||
'created', 'last_updated',
|
||||
'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description',
|
||||
'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
|
||||
@@ -99,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
|
||||
'contacts', 'tags', 'created', 'last_updated', 'actions',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns',
|
||||
'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
|
||||
'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
|
||||
@@ -15,15 +15,13 @@ CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_WEIGHT = """
|
||||
WEIGHT = """
|
||||
{% load helpers %}
|
||||
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
|
||||
{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
|
||||
</a>
|
||||
{{ value|default:'<span class="badge bg-info">Unnamed device</span>' }}
|
||||
"""
|
||||
|
||||
DEVICEBAY_STATUS = """
|
||||
@@ -117,10 +115,28 @@ CONSOLEPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
@@ -149,10 +165,28 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
@@ -181,10 +215,28 @@ POWERPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
@@ -212,10 +264,28 @@ POWEROUTLET_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
@@ -260,10 +330,28 @@ INTERFACE_BUTTONS = """
|
||||
{% endif %}
|
||||
{% if record.cable %}
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif record.wireless_link %}
|
||||
{% if perms.wireless.delete_wirelesslink %}
|
||||
@@ -305,10 +393,28 @@ FRONTPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
@@ -342,10 +448,28 @@ REARPORT_BUTTONS = """
|
||||
{% if record.cable %}
|
||||
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
|
||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if perms.dcim.change_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
Edit cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
Delete cable
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
|
||||
@@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
'device': device.pk,
|
||||
'module_bay': module_bays[3].pk,
|
||||
'module_type': module_types[0].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'ABC123',
|
||||
'asset_tag': 'Foo1',
|
||||
},
|
||||
@@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
'device': device.pk,
|
||||
'module_bay': module_bays[4].pk,
|
||||
'module_type': module_types[1].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'DEF456',
|
||||
'asset_tag': 'Foo2',
|
||||
},
|
||||
@@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
'device': device.pk,
|
||||
'module_bay': module_bays[5].pk,
|
||||
'module_type': module_types[2].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'GHI789',
|
||||
'asset_tag': 'Foo3',
|
||||
},
|
||||
@@ -1954,37 +1957,37 @@ class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ConnectedDeviceTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
|
||||
self.device1 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
devices = (
|
||||
Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site),
|
||||
Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site),
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
|
||||
Device.objects.bulk_create(devices)
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='eth0'),
|
||||
Interface(device=devices[1], name='eth0'),
|
||||
Interface(device=devices[0], name='eth1'), # Not connected
|
||||
)
|
||||
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
|
||||
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
|
||||
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
|
||||
cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
|
||||
cable.save()
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_connected_device(self):
|
||||
url = reverse('dcim-api:connected-device-list')
|
||||
|
||||
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
|
||||
url_params = f'?peer_device=TestDevice1&peer_interface=eth0'
|
||||
response = self.client.get(url + url_params, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['name'], self.device2.name)
|
||||
self.assertEqual(response.data['name'], 'TestDevice2')
|
||||
|
||||
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
|
||||
url_params = f'?peer_device=TestDevice1&peer_interface=eth1'
|
||||
response = self.client.get(url + url_params, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
self.assertTrue(CablePath.objects.first().is_complete)
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
@@ -1803,3 +1804,44 @@ class CablePathTestCase(TestCase):
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
def test_303_remove_termination_from_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [IF2]
|
||||
[IF3]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
|
||||
# Create cables 1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[interface2, interface3]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(interface1, cable1, [interface2, interface3]),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
([interface2, interface3], cable1, interface1),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Remove the termination to interface 3
|
||||
cable1 = Cable.objects.first()
|
||||
cable1.b_terminations = [interface2]
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(interface1, cable1, interface2),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(interface2, cable1, interface1),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
@@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[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, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[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, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[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, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
|
||||
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[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, weight=10, max_weight=1000, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[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, weight=20, max_weight=2000, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[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, weight=30, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
@@ -521,6 +521,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'weight': [10, 20]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_max_weight(self):
|
||||
params = {'max_weight': [1000, 2000]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_weight_unit(self):
|
||||
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1622,10 +1626,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||
IPAddress(address='192.0.2.3/24', assigned_object=None),
|
||||
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
|
||||
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
|
||||
IPAddress(address='2001:db8::3/64', assigned_object=None),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
|
||||
Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1])
|
||||
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
|
||||
Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||
@@ -1757,6 +1765,20 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'has_primary_ip': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_primary_ip4(self):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_virtual_chassis_id(self):
|
||||
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1876,15 +1898,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
|
||||
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
|
||||
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
|
||||
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
|
||||
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
|
||||
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
|
||||
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
|
||||
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
|
||||
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'),
|
||||
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'),
|
||||
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'),
|
||||
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'),
|
||||
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'),
|
||||
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'),
|
||||
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'),
|
||||
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'),
|
||||
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
@@ -1912,6 +1934,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_serial(self):
|
||||
params = {'serial': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -73,7 +73,8 @@ class LocationTestCase(TestCase):
|
||||
|
||||
class RackTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
@@ -240,30 +241,31 @@ class RackTestCase(TestCase):
|
||||
|
||||
class DeviceTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
self.device_type = DeviceType.objects.create(
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
self.device_role = DeviceRole.objects.create(
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
|
||||
# Create DeviceType components
|
||||
ConsolePortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Console Port 1'
|
||||
).save()
|
||||
|
||||
ConsoleServerPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Console Server Port 1'
|
||||
).save()
|
||||
|
||||
ppt = PowerPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
@@ -271,21 +273,21 @@ class DeviceTestCase(TestCase):
|
||||
ppt.save()
|
||||
|
||||
PowerOutletTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Power Outlet 1',
|
||||
power_port=ppt,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
).save()
|
||||
|
||||
InterfaceTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
).save()
|
||||
|
||||
rpt = RearPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
@@ -293,7 +295,7 @@ class DeviceTestCase(TestCase):
|
||||
rpt.save()
|
||||
|
||||
FrontPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rpt,
|
||||
@@ -301,12 +303,12 @@ class DeviceTestCase(TestCase):
|
||||
).save()
|
||||
|
||||
ModuleBayTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Module Bay 1'
|
||||
).save()
|
||||
|
||||
DeviceBayTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Device Bay 1'
|
||||
).save()
|
||||
|
||||
@@ -315,9 +317,9 @@ class DeviceTestCase(TestCase):
|
||||
Ensure that all Device components are copied automatically from the DeviceType.
|
||||
"""
|
||||
d = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
d.save()
|
||||
@@ -381,9 +383,9 @@ class DeviceTestCase(TestCase):
|
||||
def test_multiple_unnamed_devices(self):
|
||||
|
||||
device1 = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name=None
|
||||
)
|
||||
device1.save()
|
||||
@@ -402,9 +404,9 @@ class DeviceTestCase(TestCase):
|
||||
def test_device_name_case_sensitivity(self):
|
||||
|
||||
device1 = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='device 1'
|
||||
)
|
||||
device1.save()
|
||||
@@ -423,9 +425,9 @@ class DeviceTestCase(TestCase):
|
||||
def test_device_duplicate_names(self):
|
||||
|
||||
device1 = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
device1.save()
|
||||
@@ -459,7 +461,8 @@ class DeviceTestCase(TestCase):
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
@@ -469,72 +472,76 @@ class CableTestCase(TestCase):
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device1 = Device.objects.create(
|
||||
device1 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
device2 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
|
||||
)
|
||||
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
|
||||
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
|
||||
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
|
||||
self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
|
||||
self.cable.save()
|
||||
interface1 = Interface.objects.create(device=device1, name='eth0')
|
||||
interface2 = Interface.objects.create(device=device2, name='eth0')
|
||||
interface3 = Interface.objects.create(device=device2, name='eth1')
|
||||
Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
|
||||
|
||||
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
|
||||
self.patch_pannel = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
|
||||
power_port1 = PowerPort.objects.create(device=device2, name='psu1')
|
||||
patch_pannel = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site
|
||||
)
|
||||
self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
|
||||
self.front_port1 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
|
||||
rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c')
|
||||
front_port1 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1
|
||||
)
|
||||
self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
|
||||
self.front_port2 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
|
||||
rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2)
|
||||
front_port2 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1
|
||||
)
|
||||
self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
|
||||
self.front_port3 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
|
||||
rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3)
|
||||
front_port3 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1
|
||||
)
|
||||
self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
|
||||
self.front_port4 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
|
||||
rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3)
|
||||
front_port4 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1
|
||||
)
|
||||
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider)
|
||||
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
|
||||
self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2')
|
||||
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A')
|
||||
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z')
|
||||
self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A')
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
|
||||
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
|
||||
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
|
||||
circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
|
||||
circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
|
||||
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
|
||||
|
||||
def test_cable_creation(self):
|
||||
"""
|
||||
When a new Cable is created, it must be cached on either termination point.
|
||||
"""
|
||||
self.interface1.refresh_from_db()
|
||||
self.interface2.refresh_from_db()
|
||||
self.assertEqual(self.interface1.cable, self.cable)
|
||||
self.assertEqual(self.interface2.cable, self.cable)
|
||||
self.assertEqual(self.interface1.cable_end, 'A')
|
||||
self.assertEqual(self.interface2.cable_end, 'B')
|
||||
self.assertEqual(self.interface1.link_peers, [self.interface2])
|
||||
self.assertEqual(self.interface2.link_peers, [self.interface1])
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
cable = Cable.objects.first()
|
||||
self.assertEqual(interface1.cable, cable)
|
||||
self.assertEqual(interface2.cable, cable)
|
||||
self.assertEqual(interface1.cable_end, 'A')
|
||||
self.assertEqual(interface2.cable_end, 'B')
|
||||
self.assertEqual(interface1.link_peers, [interface2])
|
||||
self.assertEqual(interface2.link_peers, [interface1])
|
||||
|
||||
def test_cable_deletion(self):
|
||||
"""
|
||||
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
|
||||
should still return the PK of the string even after being nullified.
|
||||
"""
|
||||
self.cable.delete()
|
||||
self.assertIsNone(self.cable.pk)
|
||||
self.assertNotEqual(str(self.cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
cable = Cable.objects.first()
|
||||
|
||||
cable.delete()
|
||||
self.assertIsNone(cable.pk)
|
||||
self.assertNotEqual(str(cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=interface1.pk)
|
||||
self.assertIsNone(interface1.cable)
|
||||
self.assertListEqual(interface1.link_peers, [])
|
||||
interface2 = Interface.objects.get(pk=self.interface2.pk)
|
||||
interface2 = Interface.objects.get(pk=interface2.pk)
|
||||
self.assertIsNone(interface2.cable)
|
||||
self.assertListEqual(interface2.link_peers, [])
|
||||
|
||||
@@ -542,7 +549,10 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
|
||||
"""
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
|
||||
|
||||
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -550,7 +560,11 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
The clean method should ensure that all terminations at either end of a Cable are of the same type.
|
||||
"""
|
||||
cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1')
|
||||
rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1')
|
||||
|
||||
cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -558,8 +572,11 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
The clean method should have a check to ensure only compatible port types can be connected by a cable
|
||||
"""
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
|
||||
|
||||
# An interface cannot be connected to a power port, for example
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
|
||||
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -567,7 +584,10 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
|
||||
"""
|
||||
cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
|
||||
interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
|
||||
circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A')
|
||||
|
||||
cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -575,8 +595,11 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
A cable cannot terminate to a virtual interface
|
||||
"""
|
||||
virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
|
||||
device1 = Device.objects.get(name='TestDevice1')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
|
||||
virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -584,15 +607,19 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
A cable cannot terminate to a wireless interface
|
||||
"""
|
||||
wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
|
||||
cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
|
||||
device1 = Device.objects.get(name='TestDevice1')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
|
||||
wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
|
||||
cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
@@ -602,36 +629,41 @@ class VirtualDeviceContextTestCase(TestCase):
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device = Device.objects.create(
|
||||
Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
)
|
||||
|
||||
def test_vdc_and_interface_creation(self):
|
||||
device = Device.objects.first()
|
||||
|
||||
vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
|
||||
vdc.full_clean()
|
||||
vdc.save()
|
||||
|
||||
interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t')
|
||||
interface = Interface(device=device, name='Eth1/1', type='10gbase-t')
|
||||
interface.full_clean()
|
||||
interface.save()
|
||||
|
||||
interface.vdcs.set([vdc])
|
||||
|
||||
def test_vdc_duplicate_name(self):
|
||||
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
device = Device.objects.first()
|
||||
|
||||
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
|
||||
vdc1.full_clean()
|
||||
vdc1.save()
|
||||
|
||||
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active')
|
||||
vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active')
|
||||
with self.assertRaises(ValidationError):
|
||||
vdc2.full_clean()
|
||||
|
||||
def test_vdc_duplicate_identifier(self):
|
||||
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
device = Device.objects.first()
|
||||
|
||||
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
|
||||
vdc1.full_clean()
|
||||
vdc1.save()
|
||||
|
||||
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active')
|
||||
vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
|
||||
with self.assertRaises(ValidationError):
|
||||
vdc2.full_clean()
|
||||
|
||||
@@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
|
||||
|
||||
class NaturalOrderingTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
@@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase):
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device = Device.objects.create(
|
||||
Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
|
||||
)
|
||||
|
||||
def test_interface_ordering_numeric(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'0',
|
||||
'0.0',
|
||||
@@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
def test_interface_ordering_linux(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'eth0',
|
||||
'eth0.1',
|
||||
@@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
def test_interface_ordering_junos(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'xe-0/0/0',
|
||||
'xe-0/0/1',
|
||||
@@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
def test_interface_ordering_ios(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'GigabitEthernet0/1',
|
||||
'GigabitEthernet0/2',
|
||||
@@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@@ -388,15 +389,18 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'outer_width': 500,
|
||||
'outer_depth': 500,
|
||||
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
|
||||
'weight': 100,
|
||||
'max_weight': 2000,
|
||||
'weight_unit': WeightUnitChoices.UNIT_POUND,
|
||||
'comments': 'Some comments',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"site,location,name,status,width,u_height",
|
||||
"Site 1,,Rack 4,active,19,42",
|
||||
"Site 1,Location 1,Rack 5,active,19,42",
|
||||
"Site 2,Location 2,Rack 6,active,19,42",
|
||||
"site,location,name,status,width,u_height,weight,max_weight,weight_unit",
|
||||
"Site 1,,Rack 4,active,19,42,100,2000,kg",
|
||||
"Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
|
||||
"Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -420,6 +424,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'outer_width': 30,
|
||||
'outer_depth': 30,
|
||||
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
|
||||
'weight': 200,
|
||||
'max_weight': 4000,
|
||||
'weight_unit': WeightUnitChoices.UNIT_POUND,
|
||||
'comments': 'New comments',
|
||||
}
|
||||
|
||||
@@ -1887,26 +1894,28 @@ class ModuleTestCase(
|
||||
'device': devices[0].pk,
|
||||
'module_bay': module_bays[3].pk,
|
||||
'module_type': module_types[0].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'A',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'module_type': module_types[3].pk,
|
||||
'status': ModuleStatusChoices.STATUS_PLANNED,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,module_bay,module_type,serial,asset_tag",
|
||||
"Device 2,Module Bay 1,Module Type 1,A,A",
|
||||
"Device 2,Module Bay 2,Module Type 2,B,B",
|
||||
"Device 2,Module Bay 3,Module Type 3,C,C",
|
||||
"device,module_bay,module_type,status,serial,asset_tag",
|
||||
"Device 2,Module Bay 1,Module Type 1,active,A,A",
|
||||
"Device 2,Module Bay 2,Module Type 2,planned,B,B",
|
||||
"Device 2,Module Bay 3,Module Type 3,failed,C,C",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,serial",
|
||||
f"{modules[0].pk},Serial 2",
|
||||
f"{modules[1].pk},Serial 3",
|
||||
f"{modules[2].pk},Serial 1",
|
||||
"id,status,serial",
|
||||
f"{modules[0].pk},offline,Serial 2",
|
||||
f"{modules[1].pk},offline,Serial 3",
|
||||
f"{modules[2].pk},offline,Serial 1",
|
||||
)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
@@ -1942,6 +1951,54 @@ class ModuleTestCase(
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_bulk_replication(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
# Add 5 InterfaceTemplates to a ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
interface_templates = [
|
||||
InterfaceTemplate(module_type=module_type, name=f'Interface {i}')
|
||||
for i in range(1, 6)
|
||||
]
|
||||
InterfaceTemplate.objects.bulk_create(interface_templates)
|
||||
|
||||
# Create a module *without* replicating components
|
||||
device = Device.objects.get(name='Device 2')
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||
csv_data = [
|
||||
"device,module_bay,module_type,status,replicate_components",
|
||||
f"{device.name},{module_bay.name},{module_type.model},active,false"
|
||||
]
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = Module.objects.count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 0)
|
||||
|
||||
# Create a second module (in the next bay) with replicated components
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5')
|
||||
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true"
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = Module.objects.count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_component_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
@@ -1979,6 +2036,50 @@ class ModuleTestCase(
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_bulk_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
interface_name = "Interface-1"
|
||||
|
||||
# Add an interface to the ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
InterfaceTemplate(module_type=module_type, name=interface_name).save()
|
||||
|
||||
form_data = self.form_data.copy()
|
||||
device = Device.objects.get(pk=form_data['device'])
|
||||
|
||||
# Create an interface to be adopted
|
||||
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
|
||||
interface.save()
|
||||
|
||||
# Ensure that interface is created with no module
|
||||
self.assertIsNone(interface.module)
|
||||
|
||||
# Create a module with adopted components
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||
csv_data = [
|
||||
"device,module_bay,module_type,status,replicate_components,adopt_components",
|
||||
f"{device.name},{module_bay.name},{module_type.model},active,false,true"
|
||||
]
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
|
||||
|
||||
# Re-retrieve interface to get new module id
|
||||
interface.refresh_from_db()
|
||||
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
|
||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsolePort
|
||||
|
||||
@@ -177,7 +177,6 @@ urlpatterns = [
|
||||
path('devices/', views.DeviceListView.as_view(), name='device_list'),
|
||||
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
|
||||
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
|
||||
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
|
||||
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
||||
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
|
||||
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
||||
|
||||
@@ -21,7 +21,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||
from virtualization.filtersets import VirtualMachineFilterSet
|
||||
from virtualization.models import VirtualMachine
|
||||
from virtualization.tables import VirtualMachineTable
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import DeviceFaceChoices
|
||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||
@@ -391,7 +393,7 @@ class SiteView(generic.ObjectView):
|
||||
scope_id=instance.pk
|
||||
).count(),
|
||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
|
||||
}
|
||||
locations = Location.objects.add_related_count(
|
||||
@@ -640,6 +642,7 @@ class RackListView(generic.ObjectListView):
|
||||
filterset = filtersets.RackFilterSet
|
||||
filterset_form = forms.RackFilterForm
|
||||
table = tables.RackTable
|
||||
template_name = 'dcim/rack_list.html'
|
||||
|
||||
|
||||
class RackElevationListView(generic.ObjectListView):
|
||||
@@ -653,17 +656,18 @@ class RackElevationListView(generic.ObjectListView):
|
||||
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
|
||||
total_count = racks.count()
|
||||
|
||||
# Ordering
|
||||
ORDERING_CHOICES = {
|
||||
'name': 'Name (A-Z)',
|
||||
'-name': 'Name (Z-A)',
|
||||
'facility_id': 'Facility ID (A-Z)',
|
||||
'-facility_id': 'Facility ID (Z-A)',
|
||||
}
|
||||
sort = request.GET.get('sort', "name")
|
||||
sort = request.GET.get('sort', 'name')
|
||||
if sort not in ORDERING_CHOICES:
|
||||
sort = 'name'
|
||||
|
||||
racks = racks.order_by(sort)
|
||||
sort_field = sort.replace("name", "_name") # Use natural ordering
|
||||
racks = racks.order_by(sort_field)
|
||||
|
||||
# Pagination
|
||||
per_page = get_paginate_count(request)
|
||||
@@ -690,6 +694,7 @@ class RackElevationListView(generic.ObjectListView):
|
||||
'sort_choices': ORDERING_CHOICES,
|
||||
'rack_face': rack_face,
|
||||
'filter_form': forms.RackElevationFilterForm(request.GET),
|
||||
'model': self.queryset.model,
|
||||
})
|
||||
|
||||
|
||||
@@ -838,6 +843,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
@@ -952,6 +958,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleporttemplates.count(),
|
||||
permission='dcim.view_consoleporttemplate',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -966,6 +973,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverporttemplates.count(),
|
||||
permission='dcim.view_consoleserverporttemplate',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -980,6 +988,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerporttemplates.count(),
|
||||
permission='dcim.view_powerporttemplate',
|
||||
weight=570,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -994,6 +1003,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlettemplates.count(),
|
||||
permission='dcim.view_poweroutlettemplate',
|
||||
weight=580,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1008,6 +1018,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfacetemplates.count(),
|
||||
permission='dcim.view_interfacetemplate',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1022,6 +1033,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontporttemplates.count(),
|
||||
permission='dcim.view_frontporttemplate',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1036,6 +1048,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearporttemplates.count(),
|
||||
permission='dcim.view_rearporttemplate',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1050,6 +1063,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.modulebaytemplates.count(),
|
||||
permission='dcim.view_modulebaytemplate',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1064,6 +1078,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.devicebaytemplates.count(),
|
||||
permission='dcim.view_devicebaytemplate',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1078,6 +1093,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventoryitemtemplates.count(),
|
||||
permission='dcim.view_invenotryitemtemplate',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1180,6 +1196,7 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleporttemplates.count(),
|
||||
permission='dcim.view_consoleporttemplate',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1194,6 +1211,7 @@ class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverporttemplates.count(),
|
||||
permission='dcim.view_consoleserverporttemplate',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1208,6 +1226,7 @@ class ModuleTypePowerPortsView(ModuleTypeComponentsView):
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerporttemplates.count(),
|
||||
permission='dcim.view_powerporttemplate',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1222,6 +1241,7 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlettemplates.count(),
|
||||
permission='dcim.view_poweroutlettemplate',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1236,6 +1256,7 @@ class ModuleTypeInterfacesView(ModuleTypeComponentsView):
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfacetemplates.count(),
|
||||
permission='dcim.view_interfacetemplate',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1250,6 +1271,7 @@ class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontporttemplates.count(),
|
||||
permission='dcim.view_frontporttemplate',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1264,6 +1286,7 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView):
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearporttemplates.count(),
|
||||
permission='dcim.view_rearporttemplate',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1717,6 +1740,42 @@ class DeviceRoleView(generic.ObjectView):
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(DeviceRole, 'devices', path='devices')
|
||||
class DeviceRoleDevicesView(generic.ObjectChildrenView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
child_model = Device
|
||||
table = tables.DeviceTable
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
template_name = 'dcim/devicerole/devices.html'
|
||||
tab = ViewTab(
|
||||
label=_('Devices'),
|
||||
badge=lambda obj: obj.devices.count(),
|
||||
permission='dcim.view_device',
|
||||
weight=400
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return Device.objects.restrict(request.user, 'view').filter(device_role=parent)
|
||||
|
||||
|
||||
@register_model_view(DeviceRole, 'virtual_machines', path='virtual-machines')
|
||||
class DeviceRoleVirtualMachinesView(generic.ObjectChildrenView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
child_model = VirtualMachine
|
||||
table = VirtualMachineTable
|
||||
filterset = VirtualMachineFilterSet
|
||||
template_name = 'dcim/devicerole/virtual_machines.html'
|
||||
tab = ViewTab(
|
||||
label=_('Virtual machines'),
|
||||
badge=lambda obj: obj.virtual_machines.count(),
|
||||
permission='virtualization.view_virtualmachine',
|
||||
weight=500
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return VirtualMachine.objects.restrict(request.user, 'view').filter(role=parent)
|
||||
|
||||
|
||||
@register_model_view(DeviceRole, 'edit')
|
||||
class DeviceRoleEditView(generic.ObjectEditView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
@@ -1872,6 +1931,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleports.count(),
|
||||
permission='dcim.view_consoleport',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1886,6 +1946,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverports.count(),
|
||||
permission='dcim.view_consoleserverport',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1900,6 +1961,7 @@ class DevicePowerPortsView(DeviceComponentsView):
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerports.count(),
|
||||
permission='dcim.view_powerport',
|
||||
weight=570,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1914,6 +1976,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlets.count(),
|
||||
permission='dcim.view_poweroutlet',
|
||||
weight=580,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1926,8 +1989,9 @@ class DeviceInterfacesView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/interfaces.html'
|
||||
tab = ViewTab(
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfaces.count(),
|
||||
badge=lambda obj: obj.vc_interfaces().count(),
|
||||
permission='dcim.view_interface',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1948,6 +2012,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontports.count(),
|
||||
permission='dcim.view_frontport',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1962,6 +2027,7 @@ class DeviceRearPortsView(DeviceComponentsView):
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearports.count(),
|
||||
permission='dcim.view_rearport',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1976,6 +2042,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.modulebays.count(),
|
||||
permission='dcim.view_modulebay',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1990,6 +2057,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.devicebays.count(),
|
||||
permission='dcim.view_devicebay',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -2004,6 +2072,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventoryitems.count(),
|
||||
permission='dcim.view_inventoryitem',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -2014,7 +2083,8 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
permission='extras.view_configcontext'
|
||||
permission='extras.view_configcontext',
|
||||
weight=2000
|
||||
)
|
||||
|
||||
|
||||
@@ -2022,22 +2092,15 @@ class DeviceBulkImportView(generic.BulkImportView):
|
||||
queryset = Device.objects.all()
|
||||
model_form = forms.DeviceImportForm
|
||||
table = tables.DeviceImportTable
|
||||
template_name = 'dcim/device_import.html'
|
||||
|
||||
|
||||
class ChildDeviceBulkImportView(generic.BulkImportView):
|
||||
queryset = Device.objects.all()
|
||||
model_form = forms.ChildDeviceImportForm
|
||||
table = tables.DeviceImportTable
|
||||
template_name = 'dcim/device_import_child.html'
|
||||
|
||||
def save_object(self, object_form, request):
|
||||
obj = object_form.save()
|
||||
|
||||
# Save the reverse relation to the parent device bay
|
||||
device_bay = obj.parent_bay
|
||||
device_bay.installed_device = obj
|
||||
device_bay.save()
|
||||
# For child devices, save the reverse relation to the parent device bay
|
||||
if getattr(obj, 'parent_bay', None):
|
||||
device_bay = obj.parent_bay
|
||||
device_bay.installed_device = obj
|
||||
device_bay.save()
|
||||
|
||||
return obj
|
||||
|
||||
@@ -2072,6 +2135,7 @@ class NAPALMViewTab(ViewTab):
|
||||
if not (
|
||||
instance.status == 'active' and
|
||||
instance.primary_ip and
|
||||
instance.platform and
|
||||
instance.platform.napalm_driver
|
||||
):
|
||||
return None
|
||||
@@ -2086,6 +2150,7 @@ class DeviceStatusView(generic.ObjectView):
|
||||
tab = NAPALMViewTab(
|
||||
label=_('Status'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3000
|
||||
)
|
||||
|
||||
|
||||
@@ -2097,6 +2162,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
|
||||
tab = NAPALMViewTab(
|
||||
label=_('LLDP Neighbors'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3100
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
@@ -2119,6 +2185,7 @@ class DeviceConfigView(generic.ObjectView):
|
||||
tab = NAPALMViewTab(
|
||||
label=_('Config'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3200
|
||||
)
|
||||
|
||||
|
||||
@@ -2786,7 +2853,7 @@ class DeviceBayPopulateView(generic.ObjectEditView):
|
||||
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
device_bay.snapshot()
|
||||
device_bay.installed_device = form.cleaned_data['installed_device']
|
||||
device_bay.save()
|
||||
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
|
||||
@@ -2820,7 +2887,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
|
||||
form = ConfirmationForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
device_bay.snapshot()
|
||||
removed_device = device_bay.installed_device
|
||||
device_bay.installed_device = None
|
||||
device_bay.save()
|
||||
@@ -2880,23 +2947,14 @@ class InventoryItemView(generic.ObjectView):
|
||||
class InventoryItemEditView(generic.ObjectEditView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
form = forms.InventoryItemForm
|
||||
template_name = 'dcim/inventoryitem_edit.html'
|
||||
|
||||
|
||||
class InventoryItemCreateView(generic.ComponentCreateView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
form = forms.InventoryItemCreateForm
|
||||
model_form = forms.InventoryItemForm
|
||||
|
||||
def alter_object(self, instance, request):
|
||||
# Set component (if any)
|
||||
component_type = request.GET.get('component_type')
|
||||
component_id = request.GET.get('component_id')
|
||||
|
||||
if component_type and component_id:
|
||||
content_type = get_object_or_404(ContentType, pk=component_type)
|
||||
instance.component = get_object_or_404(content_type.model_class(), pk=component_id)
|
||||
|
||||
return instance
|
||||
template_name = 'dcim/inventoryitem_edit.html'
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'delete')
|
||||
@@ -3578,7 +3636,9 @@ register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceVi
|
||||
|
||||
# VDC
|
||||
class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
queryset = VirtualDeviceContext.objects.annotate(
|
||||
interface_count=count_related(Interface, 'vdcs'),
|
||||
)
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
filterset_form = forms.VirtualDeviceContextFilterForm
|
||||
table = tables.VirtualDeviceContextTable
|
||||
@@ -3591,6 +3651,7 @@ class VirtualDeviceContextView(generic.ObjectView):
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user)
|
||||
interfaces_table.configure(request)
|
||||
interfaces_table.columns.hide('device')
|
||||
|
||||
return {
|
||||
'interfaces_table': interfaces_table,
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
|
||||
#
|
||||
@@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
|
||||
"values."
|
||||
)
|
||||
|
||||
# Serialize object and multi-object values
|
||||
for cf in self._get_custom_fields():
|
||||
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
serializer_class = get_serializer_for_model(
|
||||
model=cf.object_type.model_class(),
|
||||
prefix=NESTED_SERIALIZER_PREFIX
|
||||
)
|
||||
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
|
||||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
@@ -385,8 +385,8 @@ class JobResultSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = [
|
||||
'id', 'url', 'display', 'status', 'created', 'scheduled', 'started', 'completed', 'name', 'obj_type',
|
||||
'user', 'data', 'job_id',
|
||||
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
|
||||
'obj_type', 'user', 'data', 'job_id',
|
||||
]
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ class ReportDetailSerializer(ReportSerializer):
|
||||
|
||||
class ReportInputSerializer(serializers.Serializer):
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
#
|
||||
@@ -448,6 +449,7 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
data = serializers.JSONField()
|
||||
commit = serializers.BooleanField()
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
@@ -246,16 +245,14 @@ class ReportViewSet(ViewSet):
|
||||
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||
|
||||
if input_serializer.is_valid():
|
||||
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
name=report.full_name,
|
||||
obj_type=ContentType.objects.get_for_model(Report),
|
||||
user=request.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||
interval=input_serializer.validated_data.get('interval')
|
||||
)
|
||||
report.result = job_result
|
||||
|
||||
@@ -321,6 +318,10 @@ class ScriptViewSet(ViewSet):
|
||||
"""
|
||||
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
|
||||
"""
|
||||
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
raise PermissionDenied("This user does not have permission to run scripts.")
|
||||
|
||||
script = self._get_script(pk)()
|
||||
input_serializer = serializers.ScriptInputSerializer(data=request.data)
|
||||
|
||||
@@ -329,21 +330,17 @@ class ScriptViewSet(ViewSet):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
if input_serializer.is_valid():
|
||||
data = input_serializer.data['data']
|
||||
commit = input_serializer.data['commit']
|
||||
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_script,
|
||||
script.full_name,
|
||||
script_content_type,
|
||||
request.user,
|
||||
data=data,
|
||||
name=script.full_name,
|
||||
obj_type=ContentType.objects.get_for_model(Script),
|
||||
user=request.user,
|
||||
data=input_serializer.data['data'],
|
||||
request=copy_safe_request(request),
|
||||
commit=commit,
|
||||
commit=input_serializer.data['commit'],
|
||||
job_timeout=script.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||
interval=input_serializer.validated_data.get('interval')
|
||||
)
|
||||
script.result = job_result
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
||||
@@ -148,12 +148,12 @@ class JobResultStatusChoices(ChoiceSet):
|
||||
STATUS_FAILED = 'failed'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_PENDING, 'Pending'),
|
||||
(STATUS_SCHEDULED, 'Scheduled'),
|
||||
(STATUS_RUNNING, 'Running'),
|
||||
(STATUS_COMPLETED, 'Completed'),
|
||||
(STATUS_ERRORED, 'Errored'),
|
||||
(STATUS_FAILED, 'Failed'),
|
||||
(STATUS_PENDING, 'Pending', 'cyan'),
|
||||
(STATUS_SCHEDULED, 'Scheduled', 'gray'),
|
||||
(STATUS_RUNNING, 'Running', 'blue'),
|
||||
(STATUS_COMPLETED, 'Completed', 'green'),
|
||||
(STATUS_ERRORED, 'Errored', 'red'),
|
||||
(STATUS_FAILED, 'Failed', 'red'),
|
||||
)
|
||||
|
||||
TERMINAL_STATE_CHOICES = (
|
||||
|
||||
@@ -44,7 +44,8 @@ class Condition:
|
||||
bool: (EQ, CONTAINS),
|
||||
int: (EQ, GT, GTE, LT, LTE, CONTAINS),
|
||||
float: (EQ, GT, GTE, LT, LTE, CONTAINS),
|
||||
list: (EQ, IN, CONTAINS)
|
||||
list: (EQ, IN, CONTAINS),
|
||||
type(None): (EQ,)
|
||||
}
|
||||
|
||||
def __init__(self, attr, value, op=EQ, negate=False):
|
||||
|
||||
8
netbox/extras/fields.py
Normal file
8
netbox/extras/fields.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.db.models import TextField
|
||||
|
||||
|
||||
class CachedValueField(TextField):
|
||||
"""
|
||||
Currently a dummy field to prevent custom lookups being applied globally to TextField.
|
||||
"""
|
||||
pass
|
||||
@@ -17,10 +17,10 @@ __all__ = (
|
||||
'ConfigContextFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
'JobResultFilterSet',
|
||||
'CustomLinkFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'JobResultFilterSet',
|
||||
'JournalEntryFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
@@ -537,7 +537,7 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = ('id', 'status', 'user', 'obj_type', 'name')
|
||||
fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -38,8 +38,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
)
|
||||
@@ -79,8 +78,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
obj_type = ContentTypeChoiceField(
|
||||
label=_('Object Type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
||||
queryset=ContentType.objects.filter(FeatureQuery('job_results').get_query()),
|
||||
required=False,
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
@@ -135,8 +133,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_links'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
@@ -162,8 +159,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('export_templates'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
@@ -187,8 +183,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('export_templates'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
@@ -215,8 +210,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
)
|
||||
|
||||
@@ -32,7 +32,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
label=_('Model(s)')
|
||||
)
|
||||
object_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -116,6 +115,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
parameters = JSONField()
|
||||
|
||||
fieldsets = (
|
||||
('Saved Filter', ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')),
|
||||
@@ -125,9 +125,6 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = SavedFilter
|
||||
exclude = ('user',)
|
||||
widgets = {
|
||||
'parameters': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||
from utilities.utils import local_now
|
||||
|
||||
__all__ = (
|
||||
'ReportForm',
|
||||
@@ -15,3 +17,24 @@ class ReportForm(BootstrapMixin, forms.Form):
|
||||
label=_("Schedule at"),
|
||||
help_text=_("Schedule execution of report to a set time"),
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=SelectDurationWidget(),
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def clean_schedule_at(self):
|
||||
scheduled_time = self.cleaned_data['schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
return scheduled_time
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Annotate the current system time for reference
|
||||
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||
from utilities.utils import local_now
|
||||
|
||||
__all__ = (
|
||||
'ScriptForm',
|
||||
@@ -21,19 +22,43 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
label=_("Schedule at"),
|
||||
help_text=_("Schedule execution of script to a set time"),
|
||||
)
|
||||
_interval = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=SelectDurationWidget(),
|
||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Annotate the current system time for reference
|
||||
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.fields['_schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||
|
||||
# Move _commit and _schedule_at to the end of the form
|
||||
schedule_at = self.fields.pop('_schedule_at')
|
||||
interval = self.fields.pop('_interval')
|
||||
commit = self.fields.pop('_commit')
|
||||
self.fields['_schedule_at'] = schedule_at
|
||||
self.fields['_interval'] = interval
|
||||
self.fields['_commit'] = commit
|
||||
|
||||
def clean(self):
|
||||
scheduled_time = self.cleaned_data['_schedule_at']
|
||||
if scheduled_time and scheduled_time < local_now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['_interval'] and not scheduled_time:
|
||||
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
"""
|
||||
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
|
||||
A boolean indicating whether the form requires user input (ignore the built-in fields).
|
||||
"""
|
||||
return bool(len(self.fields) > 2)
|
||||
return bool(len(self.fields) > 3)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db.models import CharField, Lookup
|
||||
from django.db.models import CharField, TextField, Lookup
|
||||
from .fields import CachedValueField
|
||||
|
||||
|
||||
class Empty(Lookup):
|
||||
@@ -14,4 +15,18 @@ class Empty(Lookup):
|
||||
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
class NetContainsOrEquals(Lookup):
|
||||
"""
|
||||
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
|
||||
"""
|
||||
lookup_name = 'net_contains_or_equals'
|
||||
|
||||
def as_sql(self, qn, connection):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
CharField.register_lookup(Empty)
|
||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||
|
||||
@@ -37,7 +37,7 @@ class Command(BaseCommand):
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
|
||||
# Delete expired ObjectRecords
|
||||
# Delete expired ObjectChanges
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if config.CHANGELOG_RETENTION:
|
||||
|
||||
@@ -15,6 +15,11 @@ class Command(BaseCommand):
|
||||
nargs='*',
|
||||
help='One or more apps or models to reindex',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--lazy',
|
||||
action='store_true',
|
||||
help="For each model, reindex objects only if no cache entries already exist"
|
||||
)
|
||||
|
||||
def _get_indexers(self, *model_names):
|
||||
indexers = {}
|
||||
@@ -27,17 +32,28 @@ class Command(BaseCommand):
|
||||
# Return only indexers for the specified models
|
||||
else:
|
||||
for label in model_names:
|
||||
try:
|
||||
app_label, model_name = label.lower().split('.')
|
||||
except ValueError:
|
||||
labels = label.lower().split('.')
|
||||
|
||||
# Label specifies an exact model
|
||||
if len(labels) == 2:
|
||||
app_label, model_name = labels
|
||||
try:
|
||||
idx = registry['search'][f'{app_label}.{model_name}']
|
||||
indexers[idx.model] = idx
|
||||
except KeyError:
|
||||
raise CommandError(f"No indexer registered for {label}")
|
||||
|
||||
# Label specifies all the models of an app
|
||||
elif len(labels) == 1:
|
||||
app_label = labels[0] + '.'
|
||||
for indexer_label, idx in registry['search'].items():
|
||||
if indexer_label.startswith(app_label):
|
||||
indexers[idx.model] = idx
|
||||
|
||||
else:
|
||||
raise CommandError(
|
||||
f"Invalid model: {label}. Model names must be in the format <app_label>.<model_name>."
|
||||
f"Invalid model: {label}. Model names must be in the format <app_label> or <app_label>.<model_name>."
|
||||
)
|
||||
try:
|
||||
idx = registry['search'][f'{app_label}.{model_name}']
|
||||
indexers[idx.model] = idx
|
||||
except KeyError:
|
||||
raise CommandError(f"No indexer registered for {label}")
|
||||
|
||||
return indexers
|
||||
|
||||
@@ -49,14 +65,15 @@ class Command(BaseCommand):
|
||||
raise CommandError("No indexers found!")
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
if not kwargs['lazy']:
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
self.stdout.write('Indexing models')
|
||||
@@ -65,11 +82,18 @@ class Command(BaseCommand):
|
||||
model_name = model._meta.model_name
|
||||
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
|
||||
self.stdout.flush()
|
||||
|
||||
if kwargs['lazy']:
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
if cached_count := search_backend.count(object_types=[content_type]):
|
||||
self.stdout.write(f'Skipping (found {cached_count} existing).')
|
||||
continue
|
||||
|
||||
i = search_backend.cache(model.objects.iterator(), remove_existing=False)
|
||||
if i:
|
||||
self.stdout.write(f'{i} entries cached.')
|
||||
else:
|
||||
self.stdout.write(f'None found.')
|
||||
self.stdout.write(f'No objects found.')
|
||||
|
||||
msg = f'Completed.'
|
||||
if total_count := search_backend.size:
|
||||
|
||||
@@ -20,7 +20,7 @@ from utilities.utils import NetBoxFakeRequest
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run a script in Netbox"
|
||||
help = "Run a script in NetBox"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -13,6 +14,11 @@ class Migration(migrations.Migration):
|
||||
name='scheduled',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobresult',
|
||||
name='interval',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobresult',
|
||||
name='started',
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.core import management
|
||||
import django.db.models.lookups
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def reindex(apps, schema_editor):
|
||||
# Build the search index (except during tests)
|
||||
if 'test' not in sys.argv:
|
||||
management.call_command('reindex')
|
||||
import extras.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -39,7 +33,7 @@ class Migration(migrations.Migration):
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('field', models.CharField(max_length=200)),
|
||||
('type', models.CharField(max_length=30)),
|
||||
('value', models.TextField(db_index=True)),
|
||||
('value', extras.fields.CachedValueField()),
|
||||
('weight', models.PositiveSmallIntegerField(default=1000)),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
||||
],
|
||||
@@ -47,8 +41,4 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('weight', 'object_type', 'object_id'),
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=reindex,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,10 +20,12 @@ from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.search import FieldTypes
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
from utilities.forms.fields import (
|
||||
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
|
||||
)
|
||||
from utilities.forms.widgets import DatePicker, StaticSelectMultiple, StaticSelect
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
@@ -273,10 +275,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
'choices': "Choices may be set only for custom selection fields."
|
||||
})
|
||||
|
||||
# A selection field must have at least two choices defined
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
|
||||
# Selection fields must have at least one choice defined
|
||||
if self.type in (
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTISELECT
|
||||
) and not self.choices:
|
||||
raise ValidationError({
|
||||
'choices': "Selection fields must specify at least two choices."
|
||||
'choices': "Selection fields must specify at least one choice."
|
||||
})
|
||||
|
||||
# A selection field's default (if any) must be present in its available choices
|
||||
@@ -410,7 +415,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
field = DynamicModelChoiceField(
|
||||
field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
|
||||
field = field_class(
|
||||
queryset=model.objects.all(),
|
||||
required=required,
|
||||
initial=initial
|
||||
@@ -419,10 +425,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
# Multiple objects
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
model = self.object_type.model_class()
|
||||
field = DynamicModelMultipleChoiceField(
|
||||
field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
|
||||
field = field_class(
|
||||
queryset=model.objects.all(),
|
||||
required=required,
|
||||
initial=initial
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
# Text
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import ValidationError
|
||||
from django.core.validators import MinValueValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.urls import reverse
|
||||
@@ -21,6 +21,8 @@ from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.conditions import ConditionSet
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
|
||||
@@ -512,7 +514,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
|
||||
return objectchange
|
||||
|
||||
|
||||
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
|
||||
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
|
||||
@@ -585,6 +587,14 @@ class JobResult(models.Model):
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
interval = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=(
|
||||
MinValueValidator(1),
|
||||
),
|
||||
help_text=_("Recurrence interval (in minutes)")
|
||||
)
|
||||
started = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True
|
||||
@@ -624,7 +634,8 @@ class JobResult(models.Model):
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
queue = django_rq.get_queue("default")
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.name, RQ_QUEUE_DEFAULT)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
|
||||
if job:
|
||||
@@ -633,12 +644,20 @@ class JobResult(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return JobResultStatusChoices.colors.get(self.status)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if not self.completed:
|
||||
return None
|
||||
|
||||
duration = self.completed - self.created
|
||||
start_time = self.started or self.created
|
||||
|
||||
if not start_time:
|
||||
return None
|
||||
|
||||
duration = self.completed - start_time
|
||||
minutes, seconds = divmod(duration.total_seconds(), 60)
|
||||
|
||||
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
||||
@@ -662,32 +681,32 @@ class JobResult(models.Model):
|
||||
self.completed = timezone.now()
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
|
||||
"""
|
||||
Create a JobResult instance and enqueue a job using the given callable
|
||||
|
||||
func: The callable object to be enqueued for execution
|
||||
name: Name for the JobResult instance
|
||||
obj_type: ContentType to link to the JobResult instance obj_type
|
||||
user: User object to link to the JobResult instance
|
||||
schedule_at: Schedule the job to be executed at the passed date and time
|
||||
args: additional args passed to the callable
|
||||
kwargs: additional kargs passed to the callable
|
||||
Args:
|
||||
func: The callable object to be enqueued for execution
|
||||
name: Name for the JobResult instance
|
||||
obj_type: ContentType to link to the JobResult instance obj_type
|
||||
user: User object to link to the JobResult instance
|
||||
schedule_at: Schedule the job to be executed at the passed date and time
|
||||
interval: Recurrence interval (in minutes)
|
||||
"""
|
||||
job_result: JobResult = cls.objects.create(
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
|
||||
job_result: JobResult = JobResult.objects.create(
|
||||
name=name,
|
||||
status=status,
|
||||
obj_type=obj_type,
|
||||
scheduled=schedule_at,
|
||||
interval=interval,
|
||||
user=user,
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
queue = django_rq.get_queue("default")
|
||||
|
||||
if schedule_at:
|
||||
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
|
||||
job_result.scheduled = schedule_at
|
||||
job_result.save()
|
||||
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
from utilities.fields import RestrictedGenericForeignKey
|
||||
from ..fields import CachedValueField
|
||||
|
||||
__all__ = (
|
||||
'CachedValue',
|
||||
@@ -36,9 +37,7 @@ class CachedValue(models.Model):
|
||||
type = models.CharField(
|
||||
max_length=30
|
||||
)
|
||||
value = models.TextField(
|
||||
db_index=True
|
||||
)
|
||||
value = CachedValueField()
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=1000
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import collections
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
@@ -21,6 +22,15 @@ registry['plugins'] = {
|
||||
'template_extensions': collections.defaultdict(list),
|
||||
}
|
||||
|
||||
DEFAULT_RESOURCE_PATHS = {
|
||||
'search_indexes': 'search.indexes',
|
||||
'graphql_schema': 'graphql.schema',
|
||||
'menu': 'navigation.menu',
|
||||
'menu_items': 'navigation.menu_items',
|
||||
'template_extensions': 'template_content.template_extensions',
|
||||
'user_preferences': 'preferences.preferences',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Plugin AppConfig class
|
||||
@@ -58,58 +68,53 @@ class PluginConfig(AppConfig):
|
||||
# Django apps to append to INSTALLED_APPS when plugin requires them.
|
||||
django_apps = []
|
||||
|
||||
# Default integration paths. Plugin authors can override these to customize the paths to
|
||||
# integrated components.
|
||||
search_indexes = 'search.indexes'
|
||||
graphql_schema = 'graphql.schema'
|
||||
menu = 'navigation.menu'
|
||||
menu_items = 'navigation.menu_items'
|
||||
template_extensions = 'template_content.template_extensions'
|
||||
user_preferences = 'preferences.preferences'
|
||||
# Optional plugin resources
|
||||
search_indexes = None
|
||||
graphql_schema = None
|
||||
menu = None
|
||||
menu_items = None
|
||||
template_extensions = None
|
||||
user_preferences = None
|
||||
|
||||
def _load_resource(self, name):
|
||||
# Import from the configured path, if defined.
|
||||
if getattr(self, name):
|
||||
return import_string(f"{self.__module__}.{self.name}")
|
||||
|
||||
# Fall back to the resource's default path. Return None if the module has not been provided.
|
||||
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
||||
default_module, resource_name = default_path.rsplit('.', 1)
|
||||
try:
|
||||
module = import_module(default_module)
|
||||
return getattr(module, resource_name, None)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
def ready(self):
|
||||
plugin_name = self.name.rsplit('.', 1)[-1]
|
||||
|
||||
# Register search extensions (if defined)
|
||||
try:
|
||||
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
|
||||
for idx in search_indexes:
|
||||
register_search(idx)
|
||||
except ImportError:
|
||||
pass
|
||||
search_indexes = self._load_resource('search_indexes') or []
|
||||
for idx in search_indexes:
|
||||
register_search(idx)
|
||||
|
||||
# Register template content (if defined)
|
||||
try:
|
||||
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
|
||||
if template_extensions := self._load_resource('template_extensions'):
|
||||
register_template_extensions(template_extensions)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register navigation menu and/or menu items (if defined)
|
||||
try:
|
||||
menu = import_string(f"{self.__module__}.{self.menu}")
|
||||
if menu := self._load_resource('menu'):
|
||||
register_menu(menu)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
|
||||
if menu_items := self._load_resource('menu_items'):
|
||||
register_menu_items(self.verbose_name, menu_items)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register GraphQL schema (if defined)
|
||||
try:
|
||||
graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
|
||||
if graphql_schema := self._load_resource('graphql_schema'):
|
||||
register_graphql_schema(graphql_schema)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register user preferences (if defined)
|
||||
try:
|
||||
user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
|
||||
if user_preferences := self._load_resource('user_preferences'):
|
||||
register_user_preferences(plugin_name, user_preferences)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def validate(cls, user_config, netbox_version):
|
||||
|
||||
@@ -19,6 +19,10 @@ class PluginMenu:
|
||||
if icon_class is not None:
|
||||
self.icon_class = icon_class
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
|
||||
|
||||
class PluginMenuItem:
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import path
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.module_loading import import_string, module_has_submodule
|
||||
|
||||
from . import views
|
||||
|
||||
@@ -19,24 +21,21 @@ plugin_admin_patterns = [
|
||||
|
||||
# Register base/API URL patterns for each plugin
|
||||
for plugin_path in settings.PLUGINS:
|
||||
plugin = import_module(plugin_path)
|
||||
plugin_name = plugin_path.split('.')[-1]
|
||||
app = apps.get_app_config(plugin_name)
|
||||
base_url = getattr(app, 'base_url') or app.label
|
||||
|
||||
# Check if the plugin specifies any base URLs
|
||||
try:
|
||||
if module_has_submodule(plugin, 'urls'):
|
||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
||||
plugin_patterns.append(
|
||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check if the plugin specifies any API URLs
|
||||
try:
|
||||
if module_has_submodule(plugin, 'api.urls'):
|
||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
||||
plugin_api_patterns.append(
|
||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -11,7 +11,6 @@ from django_rq import job
|
||||
from .choices import JobResultStatusChoices, LogLevelChoices
|
||||
from .models import JobResult
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -85,10 +84,24 @@ def run_report(job_result, *args, **kwargs):
|
||||
try:
|
||||
job_result.start()
|
||||
report.run(job_result)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.save()
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
finally:
|
||||
# Schedule the next job if an interval has been set
|
||||
start_time = job_result.scheduled or job_result.started
|
||||
if start_time and job_result.interval:
|
||||
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
|
||||
JobResult.enqueue_job(
|
||||
run_report,
|
||||
name=job_result.name,
|
||||
obj_type=job_result.obj_type,
|
||||
user=job_result.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job_result.interval
|
||||
)
|
||||
|
||||
|
||||
class Report(object):
|
||||
|
||||
@@ -4,8 +4,9 @@ import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import traceback
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
|
||||
import yaml
|
||||
from django import forms
|
||||
@@ -16,10 +17,11 @@ from django.utils.functional import classproperty
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||
from extras.models import JobResult
|
||||
from extras.signals import clear_webhooks
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .context_managers import change_logging
|
||||
from .forms import ScriptForm
|
||||
@@ -468,6 +470,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
except AbortScript as e:
|
||||
script.log_failure(
|
||||
f"Script aborted with error: {e}"
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Script aborted with error: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -491,6 +501,22 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
else:
|
||||
_run_script()
|
||||
|
||||
# Schedule the next job if an interval has been set
|
||||
if job_result.interval:
|
||||
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
|
||||
JobResult.enqueue_job(
|
||||
run_script,
|
||||
name=job_result.name,
|
||||
obj_type=job_result.obj_type,
|
||||
user=job_result.user,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job_result.interval,
|
||||
job_timeout=script.job_timeout,
|
||||
data=data,
|
||||
request=request,
|
||||
commit=commit
|
||||
)
|
||||
|
||||
|
||||
def get_scripts(use_names=False):
|
||||
"""
|
||||
@@ -498,27 +524,39 @@ def get_scripts(use_names=False):
|
||||
defined name in place of the actual module name.
|
||||
"""
|
||||
scripts = {}
|
||||
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
|
||||
|
||||
# Get all modules within the scripts path. These are the user-created files in which scripts are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
# Use a lock as removing and loading modules is not thread safe
|
||||
with lock:
|
||||
# Remove cached module to ensure consistency with filesystem
|
||||
if module_name in sys.modules:
|
||||
modules = list(pkgutil.iter_modules([settings.SCRIPTS_ROOT]))
|
||||
modules_bases = set([name.split(".")[0] for _, name, _ in modules])
|
||||
|
||||
# Deleting from sys.modules needs to done behind a lock to prevent race conditions where a module is
|
||||
# removed from sys.modules while another thread is importing
|
||||
with lock:
|
||||
for module_name in list(sys.modules.keys()):
|
||||
# Everything sharing a base module path with a module in the script folder is removed.
|
||||
# We also remove all modules with a base module called "scripts". This allows modifying imported
|
||||
# non-script modules without having to reload the RQ worker.
|
||||
module_base = module_name.split(".")[0]
|
||||
if module_base == "scripts" or module_base in modules_bases:
|
||||
del sys.modules[module_name]
|
||||
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
for importer, module_name, _ in modules:
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
|
||||
module_scripts = {}
|
||||
script_order = getattr(module, "script_order", ())
|
||||
ordered_scripts = [cls for cls in script_order if is_script(cls)]
|
||||
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
|
||||
|
||||
for cls in [*ordered_scripts, *unordered_scripts]:
|
||||
# For scripts in submodules use the full import path w/o the root module as the name
|
||||
script_name = cls.full_name.split(".", maxsplit=1)[1]
|
||||
module_scripts[script_name] = cls
|
||||
|
||||
if module_scripts:
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user