mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-30 09:07:46 -06:00
Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c91331e16 | ||
|
|
eef38257b9 | ||
|
|
bb9a125934 | ||
|
|
8de252e34e | ||
|
|
9e305c6181 | ||
|
|
97ed6439ce | ||
|
|
6820796c10 | ||
|
|
4a331b560f | ||
|
|
4c9cf9032c | ||
|
|
ada01b39cc | ||
|
|
b41f8755df | ||
|
|
2c07762b7a | ||
|
|
278f2b173a | ||
|
|
768d6f624e | ||
|
|
1146aaff89 | ||
|
|
5a4feb7099 | ||
|
|
a6fd0ab09a | ||
|
|
9f71cf79e6 | ||
|
|
c26fe266cc | ||
|
|
085cfc58f4 | ||
|
|
63a0ec7a79 | ||
|
|
ccfdc216a5 | ||
|
|
2bf9acfb19 | ||
|
|
74d8baea30 | ||
|
|
f8d40ae824 | ||
|
|
41c92483a0 | ||
|
|
94c2a2e56c | ||
|
|
0a2ae90411 | ||
|
|
b032742418 | ||
|
|
8a684adf66 | ||
|
|
bca00cd97a | ||
|
|
2883fa14de | ||
|
|
3264636b7a | ||
|
|
fbc23424a6 | ||
|
|
6f08c4a4be | ||
|
|
0ac8419005 | ||
|
|
e467589730 | ||
|
|
0330c652bd | ||
|
|
5a6005cdfa | ||
|
|
4eaba7993f | ||
|
|
2840f9d71d | ||
|
|
9946ae2981 | ||
|
|
420ec6791f | ||
|
|
47234f1607 | ||
|
|
b058bd9cea | ||
|
|
5b03636c88 | ||
|
|
be55bb43ad | ||
|
|
293afab730 | ||
|
|
6b622fd9bf | ||
|
|
7280dfacab | ||
|
|
4428a446d0 | ||
|
|
2eedcac383 | ||
|
|
35af1d7b61 | ||
|
|
1b92958870 | ||
|
|
795669113f | ||
|
|
de57446f36 | ||
|
|
3b13cef0c8 | ||
|
|
497f3145fa | ||
|
|
f597b76ddc | ||
|
|
ebaac82560 | ||
|
|
371764fecd | ||
|
|
f67deb0dea | ||
|
|
d3c5f1e744 | ||
|
|
b12551c64b | ||
|
|
9995fad513 | ||
|
|
1d2335d578 | ||
|
|
ad03061edf | ||
|
|
87eabdbffb | ||
|
|
19787dd21d | ||
|
|
7d64e5bc62 | ||
|
|
a2c7452f90 | ||
|
|
654e32cbbe | ||
|
|
879aabe2f9 | ||
|
|
835af32213 | ||
|
|
571d33e660 | ||
|
|
96eb89a469 | ||
|
|
8d25d7812c | ||
|
|
a864e8127b | ||
|
|
fc482ed096 | ||
|
|
85f40bcbe0 | ||
|
|
e7ed280790 | ||
|
|
3d14a79428 | ||
|
|
d93a24d0bb | ||
|
|
4286d74d44 | ||
|
|
016eff52c0 | ||
|
|
6b6ea36b4c | ||
|
|
520493c714 | ||
|
|
e459c46dad | ||
|
|
a71a59c088 | ||
|
|
267a14264b | ||
|
|
065738473e | ||
|
|
f698c42c41 | ||
|
|
ab303db3dd | ||
|
|
07b0b93256 | ||
|
|
d880875e67 | ||
|
|
fa60f9d2a8 | ||
|
|
33286aad39 | ||
|
|
d48a8770de | ||
|
|
ee5b707e68 | ||
|
|
d29a4a60f9 | ||
|
|
07b39fe44a | ||
|
|
e270cb20ba | ||
|
|
6640fc9eb7 | ||
|
|
189668fbfb | ||
|
|
c9e5a4c996 | ||
|
|
ed5fd140eb | ||
|
|
4f12eccde6 | ||
|
|
1f0db6d2fa | ||
|
|
eed6990b39 | ||
|
|
a554164d1d | ||
|
|
6ea30798bf | ||
|
|
3418b7adf6 | ||
|
|
88d5119c59 | ||
|
|
6e7d2f53aa | ||
|
|
559a318584 | ||
|
|
67499cbf06 | ||
|
|
0744ff2fa0 | ||
|
|
cfa6b28ceb | ||
|
|
ed77c03830 | ||
|
|
561f1eadfc | ||
|
|
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 |
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.4.2
|
||||
placeholder: v3.4.8
|
||||
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.4.2
|
||||
placeholder: v3.4.8
|
||||
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,71 @@
|
||||
<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 infrastructure:** 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 critical 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/)
|
||||
<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 +74,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
|
||||
@@ -121,7 +121,8 @@ social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django
|
||||
social-auth-app-django
|
||||
# See https://github.com/python-social-auth/social-app-django/issues/429
|
||||
social-auth-app-django==5.0.0
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
<VirtualHost *:80>
|
||||
# CHANGE THIS TO YOUR SERVER'S NAME
|
||||
ServerName netbox.example.com
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ProxyPreserveHost On
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -18,4 +18,4 @@ interface.
|
||||
|
||||
Default: False
|
||||
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated
|
||||
|
||||
Default: `'netbox.authentication.RemoteUserBackend'`
|
||||
|
||||
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
|
||||
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given.
|
||||
|
||||
* `netbox.authentication.RemoteUserBackend`
|
||||
* `netbox.authentication.LDAPBackend`
|
||||
|
||||
@@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i
|
||||
* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port to use for the connection (default: `25`)
|
||||
* `USERNAME` - Username with which to authenticate
|
||||
* `PASSSWORD` - Password with which to authenticate
|
||||
* `PASSWORD` - Password with which to authenticate
|
||||
* `USE_SSL` - Use SSL when connecting to the server (default: `False`)
|
||||
* `USE_TLS` - Use TLS when connecting to the server (default: `False`)
|
||||
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
|
||||
|
||||
@@ -79,7 +79,22 @@ A human-friendly description of what your script does.
|
||||
|
||||
### `field_order`
|
||||
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered within a default "Script Data" group. Any fields not included in this iterable be listed last. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the form by default for the user.
|
||||
|
||||
### `fieldsets`
|
||||
|
||||
`fieldsets` may be defined as an iterable of field groups and their field names to determine the order in which variables are group and rendered. Any fields not included in this iterable will not be displayed in the form. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the fieldsets by default for the user.
|
||||
|
||||
An example fieldset definition is provided below:
|
||||
|
||||
```python
|
||||
class MyScript(Script):
|
||||
class Meta:
|
||||
fieldsets = (
|
||||
('First group', ('field1', 'field2', 'field3')),
|
||||
('Second group', ('field4', 'field5')),
|
||||
)
|
||||
```
|
||||
|
||||
### `commit_default`
|
||||
|
||||
@@ -142,6 +157,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
|
||||
@@ -289,7 +317,7 @@ Optionally `schedule_at` can be passed in the form data with a datetime string t
|
||||
Scripts can be run on the CLI by invoking the management command:
|
||||
|
||||
```
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
```
|
||||
|
||||
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
|
||||
|
||||
@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
|
||||
```postgresql
|
||||
CREATE DATABASE netbox;
|
||||
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
ALTER DATABASE netbox OWNER TO netbox;
|
||||
```
|
||||
|
||||
!!! danger "Use a strong password"
|
||||
|
||||
@@ -272,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/
|
||||
|
||||
@@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
|
||||
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
|
||||
|
||||
```no-highlight
|
||||
sudo a2enmod ssl proxy proxy_http headers
|
||||
sudo a2enmod ssl proxy proxy_http headers rewrite
|
||||
sudo a2ensite netbox
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
@@ -584,11 +584,16 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
|
||||
|
||||
#### Client IP Restriction
|
||||
|
||||
!!! note
|
||||
This feature was introduced in NetBox v3.3.
|
||||
|
||||
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
|
||||
|
||||
#### Creating Tokens for Other Users
|
||||
|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||

|
||||
|
||||
!!! warning "Exercise Caution"
|
||||
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
|
||||
|
||||
### Authenticating to the API
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
docs/media/admin_ui_grant_permission.png
Normal file
BIN
docs/media/admin_ui_grant_permission.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -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,5 +1,177 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.8 (2023-04-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10414](https://github.com/netbox-community/netbox/issues/10414) - Enable general purpose image attachments for device types
|
||||
* [#10600](https://github.com/netbox-community/netbox/issues/10600) - Allow custom object fields to reference a user or group
|
||||
* [#11015](https://github.com/netbox-community/netbox/issues/11015) - Remove unit from commit rate column header in circuits table
|
||||
* [#11431](https://github.com/netbox-community/netbox/issues/11431) - Disallow changing custom field type after creation
|
||||
* [#11453](https://github.com/netbox-community/netbox/issues/11453) - Display a warning banner when `DEBUG` is enabled
|
||||
* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
|
||||
* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
|
||||
* [#12207](https://github.com/netbox-community/netbox/issues/12207) - Introduce the `grant_token` permission for controlling the creation of API tokens on behalf of other users
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests
|
||||
* [#11432](https://github.com/netbox-community/netbox/issues/11432) - Prevent existing components & component templates from being reassigned to different devices/device types via the REST API
|
||||
* [#11454](https://github.com/netbox-community/netbox/issues/11454) - Raise validation error if generic foreign key assignment does not specify both object type and ID
|
||||
* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
|
||||
* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
|
||||
* [#12040](https://github.com/netbox-community/netbox/issues/12040) - Display relevant UI tab upon bulk import validation failure
|
||||
* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
|
||||
* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
|
||||
* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
|
||||
* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes
|
||||
* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
|
||||
* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
|
||||
* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
|
||||
* [#12227](https://github.com/netbox-community/netbox/issues/12227) - Fix tenant assignment on bulk import of L2VPNs
|
||||
|
||||
---
|
||||
|
||||
## v3.4.7 (2023-03-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval
|
||||
* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms
|
||||
* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set
|
||||
* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter
|
||||
* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings
|
||||
* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters
|
||||
* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters
|
||||
* [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type
|
||||
* [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list
|
||||
* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates
|
||||
* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form
|
||||
* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority
|
||||
* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags
|
||||
* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts
|
||||
|
||||
---
|
||||
|
||||
## v3.4.6 (2023-03-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address
|
||||
* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view
|
||||
* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content
|
||||
* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views
|
||||
* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects
|
||||
* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses
|
||||
* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type
|
||||
* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces
|
||||
* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address
|
||||
* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation
|
||||
* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs
|
||||
* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles
|
||||
* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified
|
||||
* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type
|
||||
* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark
|
||||
* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables
|
||||
* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -8,6 +8,9 @@ theme:
|
||||
custom_dir: docs/_theme/
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
features:
|
||||
- content.code.copy
|
||||
- navigation.footer
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
@@ -20,7 +23,8 @@ theme:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
plugins:
|
||||
- search
|
||||
- search:
|
||||
lang: en
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
|
||||
@@ -77,6 +77,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
StaticSelect,
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -57,7 +57,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
commit_rate = CommitRateColumn(
|
||||
verbose_name='Commit Rate'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
|
||||
@@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
filterset = filtersets.CircuitTypeFilterSet
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -687,7 +703,8 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
model = VirtualDeviceContext
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count',
|
||||
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'interface_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -706,22 +723,6 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -935,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):
|
||||
@@ -1059,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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -785,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'
|
||||
@@ -810,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'
|
||||
@@ -897,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
|
||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||
@@ -918,7 +924,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)'),
|
||||
@@ -948,6 +957,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)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
@@ -1068,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
|
||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -24,6 +24,7 @@ __all__ = (
|
||||
'CableFilterSet',
|
||||
'CabledObjectFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'CommonInterfaceFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
'ConsolePortTemplateFilterSet',
|
||||
@@ -958,6 +959,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
|
||||
@@ -971,7 +982,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
@@ -1309,11 +1322,63 @@ class PowerOutletFilterSet(
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
)
|
||||
vlan = django_filters.CharFilter(
|
||||
method='filter_vlan',
|
||||
label=_('Assigned VID')
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label=_('VRF'),
|
||||
)
|
||||
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf__rd',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
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'),
|
||||
)
|
||||
|
||||
def filter_vlan_id(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id=value) |
|
||||
Q(tagged_vlans=value)
|
||||
)
|
||||
|
||||
def filter_vlan(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id__vid=value) |
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
PathEndpointFilterSet,
|
||||
CommonInterfaceFilterSet
|
||||
):
|
||||
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
|
||||
# members
|
||||
@@ -1358,14 +1423,6 @@ class InterfaceFilterSet(
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
)
|
||||
vlan = django_filters.CharFilter(
|
||||
method='filter_vlan',
|
||||
label=_('Assigned VID')
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
@@ -1376,17 +1433,6 @@ class InterfaceFilterSet(
|
||||
rf_channel = django_filters.MultipleChoiceFilter(
|
||||
choices=WirelessChannelChoices
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label=_('VRF'),
|
||||
)
|
||||
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf__rd',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
vdc_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vdcs',
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
@@ -1433,24 +1479,6 @@ class InterfaceFilterSet(
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
def filter_vlan_id(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id=value) |
|
||||
Q(tagged_vlans=value)
|
||||
)
|
||||
|
||||
def filter_vlan(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id__vid=value) |
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
def filter_kind(self, queryset, name, value):
|
||||
value = value.strip().lower()
|
||||
return {
|
||||
@@ -1639,12 +1667,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_a_id = MultiValueNumberFilter(
|
||||
method='filter_by_cable_end_a',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
termination_b_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_b_id = MultiValueNumberFilter(
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1702,8 +1732,21 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Supported objects: device, rack, location, site
|
||||
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
|
||||
|
||||
def filter_by_cable_end(self, queryset, name, value, side):
|
||||
# Filter by termination id and cable_end type
|
||||
return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct()
|
||||
|
||||
def filter_by_cable_end_a(self, queryset, name, value):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A)
|
||||
|
||||
def filter_by_cable_end_b(self, queryset, name, value):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
|
||||
@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
|
||||
|
||||
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
model = ModuleBay
|
||||
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'position', 'description', 'tags')
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
position_pattern = ExpandableNameField(
|
||||
position = ExpandableNameField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
|
||||
|
||||
@@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
|
||||
DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@@ -1186,6 +1175,14 @@ class InterfaceBulkEditForm(
|
||||
},
|
||||
label=_('LAG')
|
||||
)
|
||||
vdcs = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label='Virtual Device Contexts',
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
widget=SelectSpeedWidget(),
|
||||
@@ -1251,14 +1248,14 @@ class InterfaceBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
@@ -11,14 +11,15 @@ from dcim.models import *
|
||||
from ipam.models import VRF
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableImportForm',
|
||||
'ChildDeviceImportForm',
|
||||
'ConsolePortImportForm',
|
||||
'ConsoleServerPortImportForm',
|
||||
'DeviceBayImportForm',
|
||||
@@ -413,6 +414,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,
|
||||
@@ -422,8 +435,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):
|
||||
@@ -434,14 +447,35 @@ 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
|
||||
# Limit rack queryset by assigned site and location
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
}
|
||||
if 'location' in data:
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
})
|
||||
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)
|
||||
|
||||
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(
|
||||
@@ -495,48 +529,6 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
||||
return self.cleaned_data['replicate_components']
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
@@ -677,6 +669,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Parent LAG interface')
|
||||
)
|
||||
vdcs = CSVModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
help_text=_('Physical medium')
|
||||
@@ -716,7 +714,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
model = Interface
|
||||
fields = (
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
||||
)
|
||||
|
||||
@@ -732,6 +730,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
||||
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
@@ -740,6 +739,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
class FrontPortImportForm(NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
|
||||
@@ -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 (
|
||||
@@ -1112,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',
|
||||
@@ -1170,7 +1170,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
label='PoE mode'
|
||||
)
|
||||
poe_type = MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label='PoE type'
|
||||
)
|
||||
@@ -1203,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)
|
||||
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = [
|
||||
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -10,3 +10,11 @@ class CabledObjectMixin:
|
||||
|
||||
def resolve_link_peers(self, info):
|
||||
return self.link_peers
|
||||
|
||||
|
||||
class PathEndpointMixin:
|
||||
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
|
||||
|
||||
def resolve_connected_endpoints(self, info):
|
||||
# Handle empty values
|
||||
return self.connected_endpoints or None
|
||||
|
||||
@@ -7,7 +7,7 @@ from extras.graphql.mixins import (
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
from .mixins import CabledObjectMixin
|
||||
from .mixins import CabledObjectMixin, PathEndpointMixin
|
||||
|
||||
__all__ = (
|
||||
'CableType',
|
||||
@@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
@@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
@@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
@@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
|
||||
@@ -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):
|
||||
@@ -142,8 +152,6 @@ class Cable(PrimaryModel):
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
@@ -177,6 +185,10 @@ class Cable(PrimaryModel):
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Clear length_unit if no length is defined
|
||||
if self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
@@ -527,7 +539,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
|
||||
|
||||
@@ -120,6 +120,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
if self.device_type is not None:
|
||||
@@ -131,6 +137,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device_type != self.device_type_id:
|
||||
raise ValidationError({
|
||||
"device_type": "Component templates cannot be moved to a different device type."
|
||||
})
|
||||
|
||||
# A component template must belong to a DeviceType *or* to a ModuleType
|
||||
if self.device_type and self.module_type:
|
||||
raise ValidationError(
|
||||
|
||||
@@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
return f"{self.name} ({self.label})"
|
||||
@@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel):
|
||||
objectchange.related_object = self.device
|
||||
return objectchange
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device != self.device_id:
|
||||
raise ValidationError({
|
||||
"device": "Components cannot be moved to a different device."
|
||||
})
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.device
|
||||
@@ -794,8 +808,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({
|
||||
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
|
||||
})
|
||||
elif self.rf_channel:
|
||||
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
|
||||
|
||||
# Validate channel width against interface type and selected channel (if any)
|
||||
if self.rf_channel_width:
|
||||
@@ -803,8 +815,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
|
||||
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
|
||||
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
|
||||
elif self.rf_channel:
|
||||
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
|
||||
|
||||
# VLAN validation
|
||||
|
||||
@@ -815,6 +825,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
f"interface's parent device, or it must be global."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set absolute channel attributes from selected options
|
||||
if self.rf_channel and not self.rf_channel_frequency:
|
||||
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
|
||||
if self.rf_channel and not self.rf_channel_width:
|
||||
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
return super()._occupied or bool(self.wireless_link_id)
|
||||
|
||||
@@ -120,6 +120,10 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
blank=True
|
||||
)
|
||||
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
)
|
||||
@@ -659,8 +663,6 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
raise ValidationError({
|
||||
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
|
||||
})
|
||||
elif self.rack:
|
||||
self.location = self.rack.location
|
||||
|
||||
if self.rack is None:
|
||||
if self.face:
|
||||
@@ -776,8 +778,10 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||
(default). Otherwise, save() will be called on each instance individually.
|
||||
"""
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if components and bulk_create:
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
model = components[0]._meta.model
|
||||
model.objects.bulk_create(components)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
@@ -790,8 +794,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
elif components:
|
||||
for component in components:
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -801,6 +806,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
if is_new and not self.airflow:
|
||||
self.airflow = self.device_type.airflow
|
||||
|
||||
if self.rack and self.rack.location:
|
||||
self.location = self.rack.location
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If this is a new Device, instantiate all the related components per the DeviceType definition
|
||||
|
||||
@@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
raise ValidationError("Must specify a unit when setting an outer width/depth")
|
||||
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:
|
||||
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
else:
|
||||
self._abs_max_weight = None
|
||||
|
||||
# Clear unit if outer width & depth are not set
|
||||
if self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
||||
@@ -124,6 +124,9 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -580,7 +580,6 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'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',
|
||||
@@ -589,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
'tags', '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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -115,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>
|
||||
@@ -147,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>
|
||||
@@ -179,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>
|
||||
@@ -210,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>
|
||||
@@ -258,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 %}
|
||||
@@ -303,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>
|
||||
@@ -340,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>
|
||||
|
||||
@@ -1804,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
|
||||
)
|
||||
|
||||
@@ -1626,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])
|
||||
@@ -1761,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)
|
||||
|
||||
@@ -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
|
||||
@@ -626,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
filterset = filtersets.RackRoleFilterSet
|
||||
table = tables.RackRoleTable
|
||||
|
||||
|
||||
@@ -640,6 +643,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):
|
||||
@@ -840,6 +844,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')
|
||||
)
|
||||
@@ -905,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
filterset = filtersets.ManufacturerFilterSet
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@@ -1736,6 +1742,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()
|
||||
@@ -1768,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
filterset = filtersets.DeviceRoleFilterSet
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
|
||||
@@ -1828,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView):
|
||||
|
||||
class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Platform.objects.all()
|
||||
filterset = filtersets.PlatformFilterSet
|
||||
table = tables.PlatformTable
|
||||
|
||||
|
||||
@@ -1949,7 +1993,7 @@ 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
|
||||
@@ -2052,22 +2096,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
|
||||
|
||||
@@ -2820,7 +2857,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))
|
||||
@@ -2854,7 +2891,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()
|
||||
@@ -2948,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
|
||||
|
||||
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
@@ -3005,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItemRole.objects.annotate(
|
||||
inventoryitem_count=count_related(InventoryItem, 'role'),
|
||||
)
|
||||
filterset = filtersets.InventoryItemRoleFilterSet
|
||||
table = tables.InventoryItemRoleTable
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
|
||||
return value
|
||||
|
||||
def get_data_type(self, obj):
|
||||
types = CustomFieldTypeChoices
|
||||
if obj.type == types.TYPE_INTEGER:
|
||||
|
||||
@@ -318,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -210,6 +210,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
created = django_filters.DateTimeFromToRangeFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
@@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
changed_object_type = ContentTypeFilter()
|
||||
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
|
||||
@@ -2,6 +2,7 @@ from .model_forms import *
|
||||
from .filtersets import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .misc import *
|
||||
from .mixins import *
|
||||
from .config import *
|
||||
from .scripts import *
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
14
netbox/extras/forms/misc.py
Normal file
14
netbox/extras/forms/misc.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'RenderMarkdownForm',
|
||||
)
|
||||
|
||||
|
||||
class RenderMarkdownForm(forms.Form):
|
||||
"""
|
||||
Provides basic validation for markup to be rendered.
|
||||
"""
|
||||
text = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import QueryDict
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
@@ -36,7 +38,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
# TODO: Come up with a canonical way to register suitable models
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
|
||||
required=False,
|
||||
help_text=_("Type of the related object (for object/multi-object fields only)")
|
||||
)
|
||||
@@ -63,6 +65,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'ui_visibility': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
|
||||
if self.instance.pk:
|
||||
self.fields['type'].disabled = True
|
||||
|
||||
|
||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
@@ -128,11 +137,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
# Convert any parameters delivered via initial data to a dictionary
|
||||
# Convert any parameters delivered via initial data to JSON data
|
||||
if initial and 'parameters' in initial:
|
||||
if type(initial['parameters']) is str:
|
||||
# TODO: Make a utility function for this
|
||||
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
|
||||
initial['parameters'] = json.loads(initial['parameters'])
|
||||
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
@@ -254,6 +262,15 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
'tenants', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
# Convert data delivered via initial data to JSON data
|
||||
if initial and 'data' in initial:
|
||||
if type(initial['data']) is str:
|
||||
initial['data'] = json.loads(initial['data'])
|
||||
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
|
||||
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||
from utilities.utils import local_now
|
||||
|
||||
__all__ = (
|
||||
'ReportForm',
|
||||
@@ -24,16 +25,20 @@ class ReportForm(BootstrapMixin, forms.Form):
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def clean_schedule_at(self):
|
||||
def clean(self):
|
||||
scheduled_time = self.cleaned_data['schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
if scheduled_time and scheduled_time < local_now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
return scheduled_time
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['interval'] and not scheduled_time:
|
||||
self.cleaned_data['schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Annotate the current system time for reference
|
||||
now = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||
from utilities.utils import local_now
|
||||
|
||||
__all__ = (
|
||||
'ScriptForm',
|
||||
@@ -34,7 +34,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Annotate the current system time for reference
|
||||
now = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
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
|
||||
@@ -45,14 +45,16 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
self.fields['_interval'] = interval
|
||||
self.fields['_commit'] = commit
|
||||
|
||||
def clean__schedule_at(self):
|
||||
def clean(self):
|
||||
scheduled_time = self.cleaned_data['_schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
raise forms.ValidationError({
|
||||
'_schedule_at': _('Scheduled time must be in the future.')
|
||||
})
|
||||
if scheduled_time and scheduled_time < local_now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
return scheduled_time
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['_interval'] and not scheduled_time:
|
||||
self.cleaned_data['_schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -60,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')
|
||||
@@ -76,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,25 +1,9 @@
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.lookups
|
||||
from django.core import management
|
||||
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',
|
||||
'circuits',
|
||||
'dcim',
|
||||
'extras',
|
||||
'ipam',
|
||||
'tenancy',
|
||||
'virtualization',
|
||||
'wireless',
|
||||
)
|
||||
import extras.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -49,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()),
|
||||
('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')),
|
||||
],
|
||||
@@ -57,8 +41,4 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('weight', 'object_type', 'object_id'),
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=reindex,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, WebhooksMixin
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
class ConfigContext(CloningMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@@ -108,6 +108,12 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
|
||||
objects = ConfigContextQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -213,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
|
||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
||||
for instance in instances:
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
@@ -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
|
||||
|
||||
@@ -245,7 +245,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -280,7 +280,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
}
|
||||
|
||||
|
||||
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='export_templates',
|
||||
@@ -301,7 +301,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='MIME type',
|
||||
help_text=_('Defaults to <code>text/plain</code>')
|
||||
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
|
||||
)
|
||||
file_extension = models.CharField(
|
||||
max_length=15,
|
||||
@@ -313,6 +313,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
help_text=_("Download file as attachment")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
|
||||
@@ -353,7 +357,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
Render the template to an HTTP response, delivered as a named file attachment
|
||||
"""
|
||||
output = self.render(queryset)
|
||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||
mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
|
||||
|
||||
# Build the response
|
||||
response = HttpResponse(output, content_type=mime_type)
|
||||
@@ -406,7 +410,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
parameters = models.JSONField()
|
||||
|
||||
clone_fields = (
|
||||
'enabled', 'weight',
|
||||
'content_types', 'weight', 'enabled', 'parameters',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -514,7 +518,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
|
||||
@@ -634,7 +638,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:
|
||||
|
||||
@@ -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,7 +37,7 @@ class CachedValue(models.Model):
|
||||
type = models.CharField(
|
||||
max_length=30
|
||||
)
|
||||
value = models.TextField()
|
||||
value = CachedValueField()
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=1000
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
|
||||
@@ -14,7 +14,7 @@ from utilities.fields import ColorField
|
||||
# Tags
|
||||
#
|
||||
|
||||
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
class Tag(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
id = models.BigAutoField(
|
||||
primary_key=True
|
||||
)
|
||||
@@ -26,6 +26,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'color', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@@ -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 path := getattr(self, name, None):
|
||||
return import_string(f"{self.__module__}.{path}")
|
||||
|
||||
# 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):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
@@ -21,7 +22,7 @@ class PluginMenu:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
return slugify(self.label)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -21,7 +21,7 @@ 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
|
||||
@@ -352,6 +352,18 @@ class BaseScript:
|
||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
default_fieldset = (
|
||||
('Script Execution Parameters', ('_schedule_at', '_interval', '_commit')),
|
||||
)
|
||||
if not hasattr(self.Meta, 'fieldsets'):
|
||||
fields = (
|
||||
name for name, _ in self._get_vars().items()
|
||||
)
|
||||
self.Meta.fieldsets = (('Script Data', fields),)
|
||||
|
||||
self.Meta.fieldsets += default_fieldset
|
||||
|
||||
return form
|
||||
|
||||
# Logging
|
||||
@@ -470,6 +482,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(
|
||||
@@ -516,27 +536,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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -110,11 +112,14 @@ class SavedFilterTable(NetBoxTable):
|
||||
enabled = columns.BooleanColumn()
|
||||
shared = columns.BooleanColumn()
|
||||
|
||||
def value_parameters(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = SavedFilter
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
|
||||
'created', 'last_updated',
|
||||
'created', 'last_updated', 'parameters'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import datetime
|
||||
from unittest import skipIf
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rq import Worker
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
@@ -16,8 +13,6 @@ from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
rq_worker_running = Worker.count(get_connection('default'))
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
|
||||
@@ -101,11 +96,17 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
'content_types': ['dcim.site'],
|
||||
'name': 'cf6',
|
||||
'type': 'select',
|
||||
'choices': ['A', 'B', 'C']
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
update_data = {
|
||||
'content_types': ['dcim.device'],
|
||||
'name': 'New_Name',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -538,16 +539,6 @@ class ReportTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['name'], self.TestReport.__name__)
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_report(self):
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
url = reverse('extras-api:report-run', kwargs={'pk': None})
|
||||
response = self.client.post(url, {}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
@@ -588,26 +579,6 @@ class ScriptTest(APITestCase):
|
||||
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_script(self):
|
||||
|
||||
script_data = {
|
||||
'var1': 'FooBar',
|
||||
'var2': 123,
|
||||
'var3': False,
|
||||
}
|
||||
|
||||
data = {
|
||||
'data': script_data,
|
||||
'commit': True,
|
||||
}
|
||||
|
||||
url = reverse('extras-api:script-detail', kwargs={'pk': None})
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
|
||||
@@ -126,6 +126,16 @@ class ConditionSetTest(TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
ConditionSet({'foo': []})
|
||||
|
||||
def test_null_value(self):
|
||||
cs = ConditionSet({
|
||||
'and': [
|
||||
{'attr': 'a', 'value': None, 'op': 'eq', 'negate': True},
|
||||
]
|
||||
})
|
||||
self.assertFalse(cs.eval({'a': None}))
|
||||
self.assertTrue(cs.eval({'a': "string"}))
|
||||
self.assertTrue(cs.eval({'a': {"key": "value"}}))
|
||||
|
||||
def test_and_single_depth(self):
|
||||
cs = ConditionSet({
|
||||
'and': [
|
||||
|
||||
@@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_assigned_object_type(self):
|
||||
params = {'assigned_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_assigned_object(self):
|
||||
@@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||
def test_changed_object_type(self):
|
||||
params = {'changed_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_changed_object_type_id(self):
|
||||
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.test import TestCase
|
||||
from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
@@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase):
|
||||
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
||||
self.assertIn(field_type, instance.custom_field_data)
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
|
||||
|
||||
class SavedFilterFormTest(TestCase):
|
||||
|
||||
def test_basic_submit(self):
|
||||
"""
|
||||
Test form submission and validation
|
||||
"""
|
||||
form = SavedFilterForm({
|
||||
'name': 'test-sf',
|
||||
'slug': 'test-sf',
|
||||
'content_types': [ContentType.objects.get_for_model(Site).pk],
|
||||
'weight': 100,
|
||||
'parameters': {
|
||||
"status": [
|
||||
"active"
|
||||
]
|
||||
}
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
form.save()
|
||||
|
||||
@@ -92,4 +92,6 @@ urlpatterns = [
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
# Markdown
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
@@ -10,6 +10,7 @@ from rq import Worker
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
@@ -413,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
table = tables.ConfigContextTable
|
||||
|
||||
|
||||
@@ -885,3 +887,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JobResult.objects.all()
|
||||
filterset = filtersets.JobResultFilterSet
|
||||
table = tables.JobResultTable
|
||||
|
||||
|
||||
#
|
||||
# Markdown
|
||||
#
|
||||
|
||||
class RenderMarkdownView(View):
|
||||
|
||||
def post(self, request):
|
||||
form = forms.RenderMarkdownForm(request.POST)
|
||||
if not form.is_valid():
|
||||
HttpResponseBadRequest()
|
||||
rendered = render_markdown(form.cleaned_data['text'])
|
||||
|
||||
return HttpResponse(rendered)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import secrets
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
__all__ = (
|
||||
'AggregateFilterSet',
|
||||
@@ -405,6 +406,14 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
field_name='start_address',
|
||||
lookup_expr='family'
|
||||
)
|
||||
start_address = MultiValueCharFilter(
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
end_address = MultiValueCharFilter(
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
contains = django_filters.CharFilter(
|
||||
method='search_contains',
|
||||
label=_('Ranges which contain this prefix or IP'),
|
||||
@@ -441,9 +450,9 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
qs_filter = Q(description__icontains=value) | Q(start_address__contains=value) | Q(end_address__contains=value)
|
||||
try:
|
||||
ipaddress = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
ipaddress = str(netaddr.IPNetwork(value.strip()))
|
||||
qs_filter |= Q(start_address=ipaddress)
|
||||
qs_filter |= Q(end_address=ipaddress)
|
||||
except (AddrFormatError, ValueError):
|
||||
@@ -461,6 +470,12 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
try:
|
||||
return queryset.filter(**{f'{name}__net_in': value})
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
@@ -585,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
def parse_inet_addresses(self, value):
|
||||
'''
|
||||
Parse networks or IP addresses and cast to a format
|
||||
acceptable by the Postgres inet type.
|
||||
|
||||
Skips invalid values.
|
||||
'''
|
||||
parsed = []
|
||||
for addr in value:
|
||||
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
|
||||
parsed.append(addr)
|
||||
continue
|
||||
try:
|
||||
network = netaddr.IPNetwork(addr)
|
||||
parsed.append(str(network))
|
||||
except (AddrFormatError, ValueError):
|
||||
continue
|
||||
return parsed
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
# Let's first parse the addresses passed
|
||||
# as argument. If they are all invalid,
|
||||
# we return an empty queryset
|
||||
value = self.parse_inet_addresses(value)
|
||||
if (len(value) == 0):
|
||||
return queryset.none()
|
||||
|
||||
try:
|
||||
return queryset.filter(address__net_in=value)
|
||||
except ValidationError:
|
||||
@@ -852,6 +893,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
method='get_for_virtualmachine'
|
||||
)
|
||||
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 = VLAN
|
||||
@@ -912,6 +964,18 @@ class ServiceFilterSet(NetBoxModelFilterSet):
|
||||
to_field_name='name',
|
||||
label=_('Virtual machine (name)'),
|
||||
)
|
||||
ipaddress_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='ipaddresses',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('IP address (ID)'),
|
||||
)
|
||||
ipaddress = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='ipaddresses__address',
|
||||
queryset=IPAddress.objects.all(),
|
||||
to_field_name='address',
|
||||
label=_('IP address'),
|
||||
)
|
||||
|
||||
port = NumericArrayFilter(
|
||||
field_name='ports',
|
||||
lookup_expr='contains'
|
||||
|
||||
@@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
|
||||
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
|
||||
StaticSelect, DynamicModelMultipleChoiceField
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
|
||||
@@ -443,7 +443,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
|
||||
fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
|
||||
'comments', 'tags')
|
||||
|
||||
|
||||
class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -413,7 +413,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Attributes', ('group_id', 'status', 'role_id', 'vid')),
|
||||
('Attributes', ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -458,6 +458,11 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label='VLAN ID'
|
||||
)
|
||||
l2vpn_id = DynamicModelMultipleChoiceField(
|
||||
queryset=L2VPN.objects.all(),
|
||||
required=False,
|
||||
label=_('L2VPN')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.populate_custom_field_defaults()
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user