mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 09:28:38 -06:00
commit
504800a7db
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.4.2
|
placeholder: v3.4.3
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.4.2
|
placeholder: v3.4.3
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
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
|
<div align="center">
|
||||||
following resources to get assistance. Please **do not** open a GitHub issue
|
<h3>
|
||||||
except to report bugs or request features.
|
: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
|
* Register for a free [GitHub account](https://github.com/signup) if you haven't already.
|
||||||
new functionality. Their integration with GitHub allows for easily cross-
|
* 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.
|
||||||
referencing and converting posts to issues as needed. There are several
|
* 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.)
|
||||||
categories for discussions:
|
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
|
||||||
|
|
||||||
* **General** - General community discussion
|
## :bug: Reporting Bugs
|
||||||
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
|
|
||||||
feature request
|
|
||||||
* **Q&A** - Request help with installing or using NetBox
|
|
||||||
|
|
||||||
### 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/).
|
* 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.
|
||||||
Unfortunately, the Slack channel does not provide long-term retention of chat
|
|
||||||
history, so try to avoid it for any discussions would benefit from being
|
|
||||||
preserved for future reference.
|
|
||||||
|
|
||||||
## Reporting Bugs
|
* 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)
|
* Some other tips to keep in mind:
|
||||||
of NetBox. If you're running an older version, it's possible that the bug has
|
* Error messages and screenshots are especially helpful.
|
||||||
already been fixed.
|
* 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.
|
||||||
* Next, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
|
* Verify that you have GitHub notifications enabled and are subscribed to your issue after submitting.
|
||||||
to see if the bug you've found has already been reported. If you think you may
|
* We appreciate your patience as bugs are prioritized by their severity, impact, and difficulty to resolve.
|
||||||
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.
|
|
||||||
|
|
||||||
* For more information on how bug reports are handled, please see our [issue
|
* 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).
|
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)
|
* 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.
|
||||||
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.)
|
|
||||||
|
|
||||||
* Before filing a new feature request, consider raising your idea in a
|
* 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.
|
||||||
[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.
|
|
||||||
|
|
||||||
* Good feature requests are very narrowly defined. Be sure to thoroughly
|
* 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.
|
||||||
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.
|
|
||||||
|
|
||||||
* When submitting a feature request on GitHub, be sure to include all
|
* Some other tips to keep in mind:
|
||||||
information requested by the issue template, including:
|
* 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
|
* 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).
|
||||||
* 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
|
|
||||||
|
|
||||||
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
|
## :arrow_heading_up: Submitting Pull Requests
|
||||||
title. The issue will be reviewed by a moderator after submission and the
|
|
||||||
appropriate labels will be applied for categorization.
|
|
||||||
|
|
||||||
* For more information on how feature requests are handled, please see our
|
* [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:
|
||||||
[issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
* 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
|
* 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.)
|
||||||
[getting started](https://docs.netbox.dev/en/stable/development/getting-started/)
|
|
||||||
documentation for tips on setting up your development environment.
|
|
||||||
|
|
||||||
* Be sure to open an issue and wait for it to be assigned to you **before**
|
* 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.)
|
||||||
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.
|
|
||||||
|
|
||||||
* Once you've opened or identified an issue you'd like to work on, ask that it
|
* All code submissions should meet the following criteria (CI will enforce these checks):
|
||||||
be assigned to you so that others are aware it's being worked on. If it meets
|
* Python syntax is valid
|
||||||
the acceptance criteria, a maintainer will then mark the issue as "accepted"
|
* All tests pass when run with `./manage.py test`
|
||||||
and assign it to you. (Note that GitHub requires that a user first comment on
|
* PEP 8 compliance is enforced, with the exception that lines may be
|
||||||
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
|
|
||||||
greater than 80 characters in length
|
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
|
## :jigsaw: Creating Plugins
|
||||||
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.
|
|
||||||
|
|
||||||
## 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).
|
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
|
||||||
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.
|
|
||||||
|
|
||||||
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
## :rescue_worker_helmet: Become a Maintainer
|
||||||
to aid in issue management.
|
|
||||||
|
|
||||||
* Issues will be marked as stale after 60 days of no activity.
|
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:
|
||||||
* 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`
|
|
||||||
|
|
||||||
It is natural that some new issues get more attention than others. The stale
|
* Python development with a strong focus on the [Django](https://www.djangoproject.com/) framework
|
||||||
bot helps bring renewed attention to potentially valuable issues that may have
|
* Expertise working with PostgreSQL databases
|
||||||
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
|
* Javascript & TypeScript proficiency
|
||||||
effort to circumvent the bot: This will result in the immediate closure of the
|
* A knack for web application design (HTML & CSS)
|
||||||
issue, and you may be barred from participating in future discussions.
|
* 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
|
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).
|
||||||
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/).
|
|
||||||
|
|
||||||
* Maintainers are expected to attend (where feasible) our biweekly ~30-minute
|
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!
|
||||||
sync to review agenda items. This meeting provides opportunity to present and
|
|
||||||
discuss pressing topics. Meetings are held as virtual audio/video conferences.
|
|
||||||
|
|
||||||
* Maintainers with no substantial recorded activity in a 60-day period will be
|
## :heart: Other Ways to Contribute
|
||||||
removed from the project.
|
|
||||||
|
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).
|
||||||
|
@ -19,7 +19,7 @@ employed by thousands of organizations around the world.
|
|||||||
|
|
||||||
## About NetBox
|
## About NetBox
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Myriad infrastructure components can be modeled in NetBox, including:
|
Myriad infrastructure components can be modeled in NetBox, including:
|
||||||
|
|
||||||
|
@ -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'
|
bind = '127.0.0.1:8001'
|
||||||
|
|
||||||
# Number of gunicorn workers to spawn. This should typically be 2n+1, where
|
# Number of gunicorn workers to spawn. This should typically be 2n+1, where
|
||||||
|
@ -40,8 +40,8 @@ is represented in JSON as
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
'tag': ['alpha', 'bravo'],
|
"tag": ["alpha", "bravo"],
|
||||||
'status': 'active',
|
"status": "active",
|
||||||
'region_id': 51
|
"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.
|
The operational status of the IP address.
|
||||||
|
|
||||||
!!! tip
|
!!! 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
|
### Role
|
||||||
|
|
||||||
|
@ -168,7 +168,7 @@ Some text to show that the reference links can follow later.
|
|||||||
## Images
|
## 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:
|
Inline-style:
|
||||||

|

|
||||||
@ -179,7 +179,7 @@ Reference-style:
|
|||||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
[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:
|
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.
|
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)
|
#### [Version 3.3](./version-3.3.md) (August 2022)
|
||||||
|
|
||||||
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
|
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
|
||||||
|
@ -1,5 +1,41 @@
|
|||||||
# NetBox v3.4
|
# NetBox v3.4
|
||||||
|
|
||||||
|
## 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
|
||||||
|
* [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v3.4.2 (2023-01-03)
|
## v3.4.2 (2023-01-03)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -77,6 +77,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
|||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||||
|
'description',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -672,6 +672,22 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||||||
return data
|
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):
|
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
@ -687,7 +703,8 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
|||||||
model = VirtualDeviceContext
|
model = VirtualDeviceContext
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
|
'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):
|
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||||
method = serializers.JSONField()
|
method = serializers.JSONField()
|
||||||
|
|
||||||
@ -935,7 +936,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RearPort
|
model = RearPort
|
||||||
fields = ['id', 'url', 'display', 'name', 'label']
|
fields = ['id', 'url', 'display', 'name', 'label', 'description']
|
||||||
|
|
||||||
|
|
||||||
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||||
@ -1059,7 +1060,7 @@ class TracedCableSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Cable
|
model = Cable
|
||||||
fields = [
|
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'
|
TYPE_LAG = 'lag'
|
||||||
|
|
||||||
# Ethernet
|
# Ethernet
|
||||||
|
TYPE_100ME_FX = '100base-fx'
|
||||||
|
TYPE_100ME_LFX = '100base-lfx'
|
||||||
TYPE_100ME_FIXED = '100base-tx'
|
TYPE_100ME_FIXED = '100base-tx'
|
||||||
|
TYPE_100ME_T1 = '100base-t1'
|
||||||
TYPE_1GE_FIXED = '1000base-t'
|
TYPE_1GE_FIXED = '1000base-t'
|
||||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||||
@ -810,6 +813,8 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||||
|
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||||
|
TYPE_800GE_OSFP = '800gbase-x-osfp'
|
||||||
|
|
||||||
# Ethernet Backplane
|
# Ethernet Backplane
|
||||||
TYPE_1GE_KX = '1000base-kx'
|
TYPE_1GE_KX = '1000base-kx'
|
||||||
@ -918,7 +923,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(
|
(
|
||||||
'Ethernet (fixed)',
|
'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_FIXED, '100BASE-TX (10/100ME)'),
|
||||||
|
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||||
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
||||||
@ -948,6 +956,8 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||||
|
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||||
|
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.filtersets import LocalConfigContextFilterSet
|
from extras.filtersets import LocalConfigContextFilterSet
|
||||||
from ipam.models import ASN, VRF
|
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||||
from netbox.filtersets import (
|
from netbox.filtersets import (
|
||||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||||
)
|
)
|
||||||
@ -958,6 +958,16 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
|||||||
method='_device_bays',
|
method='_device_bays',
|
||||||
label=_('Has 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:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
@ -1404,6 +1414,17 @@ class InterfaceFilterSet(
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
label='Virtual Device Context',
|
label='Virtual Device Context',
|
||||||
)
|
)
|
||||||
|
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='l2vpn_terminations__l2vpn',
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
label=_('L2VPN (ID)'),
|
||||||
|
)
|
||||||
|
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='l2vpn_terminations__l2vpn__identifier',
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
to_field_name='identifier',
|
||||||
|
label=_('L2VPN'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
|
@ -6,7 +6,7 @@ from dcim.choices import *
|
|||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from extras.forms import LocalConfigContextFilterForm
|
from extras.forms import LocalConfigContextFilterForm
|
||||||
from ipam.models import ASN, VRF
|
from ipam.models import ASN, L2VPN, VRF
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
@ -1112,7 +1112,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
|
('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')),
|
('PoE', ('poe_mode', 'poe_type')),
|
||||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
('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',
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
|
||||||
@ -1203,6 +1203,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='VRF'
|
label='VRF'
|
||||||
)
|
)
|
||||||
|
l2vpn_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('L2VPN')
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -112,6 +112,10 @@ class Cable(PrimaryModel):
|
|||||||
def a_terminations(self):
|
def a_terminations(self):
|
||||||
if hasattr(self, '_a_terminations'):
|
if hasattr(self, '_a_terminations'):
|
||||||
return self._a_terminations
|
return self._a_terminations
|
||||||
|
|
||||||
|
if not self.pk:
|
||||||
|
return []
|
||||||
|
|
||||||
# Query self.terminations.all() to leverage cached results
|
# Query self.terminations.all() to leverage cached results
|
||||||
return [
|
return [
|
||||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
|
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
|
@a_terminations.setter
|
||||||
def a_terminations(self, value):
|
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
|
self._a_terminations = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def b_terminations(self):
|
def b_terminations(self):
|
||||||
if hasattr(self, '_b_terminations'):
|
if hasattr(self, '_b_terminations'):
|
||||||
return self._b_terminations
|
return self._b_terminations
|
||||||
|
|
||||||
|
if not self.pk:
|
||||||
|
return []
|
||||||
|
|
||||||
# Query self.terminations.all() to leverage cached results
|
# Query self.terminations.all() to leverage cached results
|
||||||
return [
|
return [
|
||||||
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
|
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
|
@b_terminations.setter
|
||||||
def b_terminations(self, value):
|
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
|
self._b_terminations = value
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@ -527,7 +537,7 @@ class CablePath(models.Model):
|
|||||||
|
|
||||||
# Step 5: Record the far-end termination object(s)
|
# Step 5: Record the far-end termination object(s)
|
||||||
path.append([
|
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
|
# Step 6: Determine the "next hop" terminations, if applicable
|
||||||
|
@ -124,6 +124,9 @@ def nullify_connected_endpoints(instance, **kwargs):
|
|||||||
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
|
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
|
||||||
|
|
||||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
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()
|
cablepath.retrace()
|
||||||
|
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ class PowerFeedTable(CableTerminationTable):
|
|||||||
model = PowerFeed
|
model = PowerFeed
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
'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',
|
'description', 'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
|
@ -115,10 +115,28 @@ CONSOLEPORT_BUTTONS = """
|
|||||||
{% if record.cable %}
|
{% 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>
|
<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 %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% 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>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% 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>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% 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>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
{% if record.cable %}
|
{% if record.cable %}
|
||||||
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif record.wireless_link %}
|
{% elif record.wireless_link %}
|
||||||
{% if perms.wireless.delete_wirelesslink %}
|
{% if perms.wireless.delete_wirelesslink %}
|
||||||
@ -303,10 +393,28 @@ FRONTPORT_BUTTONS = """
|
|||||||
{% if record.cable %}
|
{% 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>
|
<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 %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% 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>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.change_cable or 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">
|
<span class="dropdown">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
|
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
</a>
|
<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 %}
|
{% endif %}
|
||||||
{% elif perms.dcim.add_cable %}
|
{% 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>
|
<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
|
is_active=True
|
||||||
)
|
)
|
||||||
self.assertEqual(CablePath.objects.count(), 2)
|
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 = (
|
ipaddresses = (
|
||||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
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.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)
|
IPAddress.objects.bulk_create(ipaddresses)
|
||||||
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
|
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])
|
Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
|
||||||
|
|
||||||
# VirtualChassis assignment for filtering
|
# VirtualChassis assignment for filtering
|
||||||
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||||
@ -1761,6 +1765,20 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'has_primary_ip': 'false'}
|
params = {'has_primary_ip': 'false'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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):
|
def test_virtual_chassis_id(self):
|
||||||
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
|
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -21,7 +21,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
|
|||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||||
|
from virtualization.filtersets import VirtualMachineFilterSet
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
|
from virtualization.tables import VirtualMachineTable
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .choices import DeviceFaceChoices
|
from .choices import DeviceFaceChoices
|
||||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||||
@ -1736,6 +1738,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')
|
@register_model_view(DeviceRole, 'edit')
|
||||||
class DeviceRoleEditView(generic.ObjectEditView):
|
class DeviceRoleEditView(generic.ObjectEditView):
|
||||||
queryset = DeviceRole.objects.all()
|
queryset = DeviceRole.objects.all()
|
||||||
@ -1949,7 +1987,7 @@ class DeviceInterfacesView(DeviceComponentsView):
|
|||||||
template_name = 'dcim/device/interfaces.html'
|
template_name = 'dcim/device/interfaces.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Interfaces'),
|
label=_('Interfaces'),
|
||||||
badge=lambda obj: obj.interfaces.count(),
|
badge=lambda obj: obj.vc_interfaces().count(),
|
||||||
permission='dcim.view_interface',
|
permission='dcim.view_interface',
|
||||||
weight=520,
|
weight=520,
|
||||||
hide_if_empty=True
|
hide_if_empty=True
|
||||||
@ -2820,7 +2858,7 @@ class DeviceBayPopulateView(generic.ObjectEditView):
|
|||||||
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
|
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
device_bay.snapshot()
|
||||||
device_bay.installed_device = form.cleaned_data['installed_device']
|
device_bay.installed_device = form.cleaned_data['installed_device']
|
||||||
device_bay.save()
|
device_bay.save()
|
||||||
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
|
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
|
||||||
@ -2854,7 +2892,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
|
|||||||
form = ConfirmationForm(request.POST)
|
form = ConfirmationForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
device_bay.snapshot()
|
||||||
removed_device = device_bay.installed_device
|
removed_device = device_bay.installed_device
|
||||||
device_bay.installed_device = None
|
device_bay.installed_device = None
|
||||||
device_bay.save()
|
device_bay.save()
|
||||||
|
@ -318,6 +318,10 @@ class ScriptViewSet(ViewSet):
|
|||||||
"""
|
"""
|
||||||
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
|
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)()
|
script = self._get_script(pk)()
|
||||||
input_serializer = serializers.ScriptInputSerializer(data=request.data)
|
input_serializer = serializers.ScriptInputSerializer(data=request.data)
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||||
|
from utilities.utils import local_now
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ReportForm',
|
'ReportForm',
|
||||||
@ -35,5 +36,5 @@ class ReportForm(BootstrapMixin, forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Annotate the current system time for reference
|
# 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>)'
|
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||||
|
from utilities.utils import local_now
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ScriptForm',
|
'ScriptForm',
|
||||||
@ -34,7 +34,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Annotate the current system time for reference
|
# 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>)'
|
self.fields['_schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||||
|
|
||||||
# Move _commit and _schedule_at to the end of the form
|
# Move _commit and _schedule_at to the end of the form
|
||||||
@ -48,9 +48,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
def clean__schedule_at(self):
|
def clean__schedule_at(self):
|
||||||
scheduled_time = self.cleaned_data['_schedule_at']
|
scheduled_time = self.cleaned_data['_schedule_at']
|
||||||
if scheduled_time and scheduled_time < timezone.now():
|
if scheduled_time and scheduled_time < timezone.now():
|
||||||
raise forms.ValidationError({
|
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||||
'_schedule_at': _('Scheduled time must be in the future.')
|
|
||||||
})
|
|
||||||
|
|
||||||
return scheduled_time
|
return scheduled_time
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ from utilities.utils import NetBoxFakeRequest
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Run a script in Netbox"
|
help = "Run a script in NetBox"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -514,7 +514,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
|
|||||||
return objectchange
|
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
|
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
|
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
|
||||||
@ -634,7 +634,8 @@ class JobResult(models.Model):
|
|||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
super().delete(*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))
|
job = queue.fetch_job(str(self.job_id))
|
||||||
|
|
||||||
if job:
|
if job:
|
||||||
|
@ -590,6 +590,7 @@ class ScriptTest(APITestCase):
|
|||||||
|
|
||||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||||
def test_run_script(self):
|
def test_run_script(self):
|
||||||
|
self.add_permissions('extras.run_script')
|
||||||
|
|
||||||
script_data = {
|
script_data = {
|
||||||
'var1': 'FooBar',
|
'var1': 'FooBar',
|
||||||
|
@ -852,6 +852,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
method='get_for_virtualmachine'
|
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:
|
class Meta:
|
||||||
model = VLAN
|
model = VLAN
|
||||||
|
@ -413,7 +413,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
('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')),
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
)
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
@ -458,6 +458,11 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='VLAN ID'
|
label='VLAN ID'
|
||||||
)
|
)
|
||||||
|
l2vpn_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('L2VPN')
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,6 +38,8 @@ REDIS = {
|
|||||||
# Set this to True to skip TLS certificate verification
|
# Set this to True to skip TLS certificate verification
|
||||||
# This can expose the connection to attacks, be careful
|
# This can expose the connection to attacks, be careful
|
||||||
# 'INSECURE_SKIP_TLS_VERIFY': False,
|
# 'INSECURE_SKIP_TLS_VERIFY': False,
|
||||||
|
# Set a path to a certificate authority, typically used with a self signed certificate.
|
||||||
|
# 'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
|
||||||
},
|
},
|
||||||
'caching': {
|
'caching': {
|
||||||
'HOST': 'localhost',
|
'HOST': 'localhost',
|
||||||
@ -52,6 +54,8 @@ REDIS = {
|
|||||||
# Set this to True to skip TLS certificate verification
|
# Set this to True to skip TLS certificate verification
|
||||||
# This can expose the connection to attacks, be careful
|
# This can expose the connection to attacks, be careful
|
||||||
# 'INSECURE_SKIP_TLS_VERIFY': False,
|
# 'INSECURE_SKIP_TLS_VERIFY': False,
|
||||||
|
# Set a path to a certificate authority, typically used with a self signed certificate.
|
||||||
|
# 'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,4 +7,4 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
current_request = ContextVar('current_request', default=None)
|
current_request = ContextVar('current_request', default=None)
|
||||||
webhooks_queue = ContextVar('webhooks_queue')
|
webhooks_queue = ContextVar('webhooks_queue', default=[])
|
||||||
|
@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.4.2'
|
VERSION = '3.4.3'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
@ -235,6 +235,7 @@ TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
|
|||||||
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
||||||
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
|
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
|
||||||
TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
|
TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
|
||||||
|
TASKS_REDIS_CA_CERT_PATH = TASKS_REDIS.get('CA_CERT_PATH', False)
|
||||||
|
|
||||||
# Caching
|
# Caching
|
||||||
if 'caching' not in REDIS:
|
if 'caching' not in REDIS:
|
||||||
@ -251,6 +252,7 @@ CACHING_REDIS_SENTINELS = REDIS['caching'].get('SENTINELS', [])
|
|||||||
CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'default')
|
CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'default')
|
||||||
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
|
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
|
||||||
CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False)
|
CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False)
|
||||||
|
CACHING_REDIS_CA_CERT_PATH = REDIS['caching'].get('CA_CERT_PATH', False)
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
'default': {
|
'default': {
|
||||||
@ -262,6 +264,8 @@ CACHES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if CACHING_REDIS_SENTINELS:
|
if CACHING_REDIS_SENTINELS:
|
||||||
DJANGO_REDIS_CONNECTION_FACTORY = 'django_redis.pool.SentinelConnectionFactory'
|
DJANGO_REDIS_CONNECTION_FACTORY = 'django_redis.pool.SentinelConnectionFactory'
|
||||||
CACHES['default']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}'
|
CACHES['default']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}'
|
||||||
@ -270,7 +274,9 @@ if CACHING_REDIS_SENTINELS:
|
|||||||
if CACHING_REDIS_SKIP_TLS_VERIFY:
|
if CACHING_REDIS_SKIP_TLS_VERIFY:
|
||||||
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
|
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
|
||||||
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False
|
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False
|
||||||
|
if CACHING_REDIS_CA_CERT_PATH:
|
||||||
|
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
|
||||||
|
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH
|
||||||
|
|
||||||
#
|
#
|
||||||
# Sessions
|
# Sessions
|
||||||
@ -648,6 +654,10 @@ RQ_PARAMS.update({
|
|||||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if TASKS_REDIS_CA_CERT_PATH:
|
||||||
|
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
|
||||||
|
RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH
|
||||||
|
|
||||||
RQ_QUEUES = {
|
RQ_QUEUES = {
|
||||||
RQ_QUEUE_HIGH: RQ_PARAMS,
|
RQ_QUEUE_HIGH: RQ_PARAMS,
|
||||||
RQ_QUEUE_DEFAULT: RQ_PARAMS,
|
RQ_QUEUE_DEFAULT: RQ_PARAMS,
|
||||||
|
@ -494,7 +494,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
return get_permission_for_model(self.queryset.model, 'change')
|
return get_permission_for_model(self.queryset.model, 'change')
|
||||||
|
|
||||||
def _update_objects(self, form, request):
|
def _update_objects(self, form, request):
|
||||||
custom_fields = getattr(form, 'custom_fields', [])
|
custom_fields = getattr(form, 'custom_fields', {})
|
||||||
standard_fields = [
|
standard_fields = [
|
||||||
field for field in form.fields if field not in list(custom_fields) + ['pk']
|
field for field in form.fields if field not in list(custom_fields) + ['pk']
|
||||||
]
|
]
|
||||||
@ -532,13 +532,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
setattr(obj, name, form.cleaned_data[name])
|
setattr(obj, name, form.cleaned_data[name])
|
||||||
|
|
||||||
# Update custom fields
|
# Update custom fields
|
||||||
for name in custom_fields:
|
for name, customfield in custom_fields.items():
|
||||||
assert name.startswith('cf_')
|
assert name.startswith('cf_')
|
||||||
cf_name = name[3:] # Strip cf_ prefix
|
cf_name = name[3:] # Strip cf_ prefix
|
||||||
if name in form.nullable_fields and name in nullified_fields:
|
if name in form.nullable_fields and name in nullified_fields:
|
||||||
obj.custom_field_data[cf_name] = None
|
obj.custom_field_data[cf_name] = None
|
||||||
elif name in form.changed_data:
|
elif name in form.changed_data:
|
||||||
obj.custom_field_data[cf_name] = form.fields[name].prepare_value(form.cleaned_data[name])
|
obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
|
||||||
|
|
||||||
obj.full_clean()
|
obj.full_clean()
|
||||||
obj.save()
|
obj.save()
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -160,7 +161,13 @@ class SearchView(View):
|
|||||||
lookup=lookup
|
lookup=lookup
|
||||||
)
|
)
|
||||||
|
|
||||||
if form.cleaned_data['lookup'] != LookupTypes.EXACT:
|
# If performing a regex search, pass the highlight value as a compiled pattern
|
||||||
|
if form.cleaned_data['lookup'] == LookupTypes.REGEX:
|
||||||
|
try:
|
||||||
|
highlight = re.compile(f"({form.cleaned_data['q']})", flags=re.IGNORECASE)
|
||||||
|
except re.error:
|
||||||
|
pass
|
||||||
|
elif form.cleaned_data['lookup'] != LookupTypes.EXACT:
|
||||||
highlight = form.cleaned_data['q']
|
highlight = form.cleaned_data['q']
|
||||||
|
|
||||||
table = SearchTable(results, highlight=highlight)
|
table = SearchTable(results, highlight=highlight)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
// Netbox-specific Styles and Overrides.
|
// NetBox-specific Styles and Overrides.
|
||||||
|
|
||||||
@use 'sass:map';
|
@use 'sass:map';
|
||||||
@use 'sass:math';
|
@use 'sass:math';
|
||||||
|
@ -21,7 +21,7 @@ Blocks:
|
|||||||
{# Body #}
|
{# Body #}
|
||||||
<div class="content-container" tabindex="-2">
|
<div class="content-container" tabindex="-2">
|
||||||
|
|
||||||
{# Netbox Logo, only visible when printing #}
|
{# NetBox Logo, only visible when printing #}
|
||||||
<div class="p-2 printonly">
|
<div class="p-2 printonly">
|
||||||
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="200px" />
|
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="200px" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -57,6 +57,11 @@
|
|||||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
|
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
|
||||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> Trace
|
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> Trace
|
||||||
</a>
|
</a>
|
||||||
|
{% if perms.dcim.change_cable %}
|
||||||
|
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Edit cable" class="btn btn-warning btn-sm">
|
||||||
|
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if perms.dcim.delete_cable %}
|
{% if perms.dcim.delete_cable %}
|
||||||
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-sm lh-1">
|
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-sm lh-1">
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> Disconnect
|
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> Disconnect
|
||||||
|
@ -71,13 +71,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">Devices</h5>
|
|
||||||
<div class="card-body table-responsive">
|
|
||||||
{% render_table devices_table 'inc/table.html' %}
|
|
||||||
{% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% plugin_full_width_page object %}
|
{% plugin_full_width_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
20
netbox/templates/dcim/devicerole/devices.html
Normal file
20
netbox/templates/dcim/devicerole/devices.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends 'dcim/devicerole.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include 'inc/table_controls_htmx.html' with table_modal='DeviceTable_config' %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" id="object_list">
|
||||||
|
{% include 'htmx/table.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block modals %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% table_config_form table %}
|
||||||
|
{% endblock modals %}
|
20
netbox/templates/dcim/devicerole/virtual_machines.html
Normal file
20
netbox/templates/dcim/devicerole/virtual_machines.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends 'dcim/devicerole.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include 'inc/table_controls_htmx.html' with table_modal='VirtualMachineTable_config' %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" id="object_list">
|
||||||
|
{% include 'htmx/table.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block modals %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% table_config_form table %}
|
||||||
|
{% endblock modals %}
|
@ -62,7 +62,7 @@ class ContactTable(NetBoxTable):
|
|||||||
verbose_name='Assignments'
|
verbose_name='Assignments'
|
||||||
)
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='tenancy:tenant_list'
|
url_name='tenancy:contact_list'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
|
@ -40,7 +40,7 @@ class TenantTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='tenancy:contact_list'
|
url_name='tenancy:tenant_list'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
|
@ -27,7 +27,7 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
|
|||||||
def get_request_serializer(self):
|
def get_request_serializer(self):
|
||||||
serializer = super().get_request_serializer()
|
serializer = super().get_request_serializer()
|
||||||
|
|
||||||
if serializer is not None and self.method in self.implicit_body_methods:
|
if serializer is not None and not isinstance(serializer, openapi.Schema) and self.method in self.implicit_body_methods:
|
||||||
if writable_class := self.get_writable_class(serializer):
|
if writable_class := self.get_writable_class(serializer):
|
||||||
if hasattr(serializer, 'child'):
|
if hasattr(serializer, 'child'):
|
||||||
child_serializer = self.get_writable_class(serializer.child)
|
child_serializer = self.get_writable_class(serializer.child)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django_filters.constants import EMPTY_VALUES
|
from django_filters.constants import EMPTY_VALUES
|
||||||
|
|
||||||
|
|
||||||
@ -67,6 +68,12 @@ class MACAddressFilter(django_filters.CharFilter):
|
|||||||
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
|
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
|
||||||
field_class = multivalue_field_factory(forms.CharField)
|
field_class = multivalue_field_factory(forms.CharField)
|
||||||
|
|
||||||
|
def filter(self, qs, value):
|
||||||
|
try:
|
||||||
|
return super().filter(qs, value)
|
||||||
|
except ValidationError:
|
||||||
|
return qs.none()
|
||||||
|
|
||||||
|
|
||||||
class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
|
class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
|
||||||
field_class = multivalue_field_factory(forms.CharField)
|
field_class = multivalue_field_factory(forms.CharField)
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
{% load helpers %}
|
||||||
{% if customfield.type == 'integer' and value is not None %}
|
{% if customfield.type == 'integer' and value is not None %}
|
||||||
{{ value }}
|
{{ value }}
|
||||||
{% elif customfield.type == 'longtext' and value %}
|
{% elif customfield.type == 'longtext' and value %}
|
||||||
@ -6,6 +7,8 @@
|
|||||||
{% checkmark value true="True" %}
|
{% checkmark value true="True" %}
|
||||||
{% elif customfield.type == 'boolean' and value == False %}
|
{% elif customfield.type == 'boolean' and value == False %}
|
||||||
{% checkmark value false="False" %}
|
{% checkmark value false="False" %}
|
||||||
|
{% elif customfield.type == 'date' and value %}
|
||||||
|
{{ value|annotated_date }}
|
||||||
{% elif customfield.type == 'url' and value %}
|
{% elif customfield.type == 'url' and value %}
|
||||||
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
||||||
{% elif customfield.type == 'json' and value %}
|
{% elif customfield.type == 'json' and value %}
|
||||||
|
@ -12,6 +12,8 @@ from django.db.models import Count, OuterRef, Subquery
|
|||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.timezone import localtime
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
from mptt.models import MPTTModel
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
@ -512,11 +514,21 @@ def clean_html(html, schemes):
|
|||||||
def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'):
|
def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'):
|
||||||
"""
|
"""
|
||||||
Highlight a string within a string and optionally trim the pre/post portions of the original string.
|
Highlight a string within a string and optionally trim the pre/post portions of the original string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The body of text being searched against
|
||||||
|
highlight: The string of compiled regex pattern to highlight in `value`
|
||||||
|
trim_pre: Maximum length of pre-highlight text to include
|
||||||
|
trim_post: Maximum length of post-highlight text to include
|
||||||
|
trim_placeholder: String value to swap in for trimmed pre/post text
|
||||||
"""
|
"""
|
||||||
# Split value on highlight string
|
# Split value on highlight string
|
||||||
try:
|
try:
|
||||||
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
|
if type(highlight) is re.Pattern:
|
||||||
except ValueError:
|
pre, match, post = highlight.split(value, maxsplit=1)
|
||||||
|
else:
|
||||||
|
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
|
||||||
|
except ValueError as e:
|
||||||
# Match not found
|
# Match not found
|
||||||
return escape(value)
|
return escape(value)
|
||||||
|
|
||||||
@ -527,3 +539,10 @@ def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_place
|
|||||||
post = post[:trim_post] + trim_placeholder
|
post = post[:trim_post] + trim_placeholder
|
||||||
|
|
||||||
return f'{escape(pre)}<mark>{escape(match)}</mark>{escape(post)}'
|
return f'{escape(pre)}<mark>{escape(match)}</mark>{escape(post)}'
|
||||||
|
|
||||||
|
|
||||||
|
def local_now():
|
||||||
|
"""
|
||||||
|
Return the current date & time in the system timezone.
|
||||||
|
"""
|
||||||
|
return localtime(timezone.now())
|
||||||
|
@ -96,8 +96,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
|||||||
class Meta(VirtualMachineSerializer.Meta):
|
class Meta(VirtualMachineSerializer.Meta):
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
|
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
|
||||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
|
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
|
||||||
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
|
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
|
||||||
|
@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||||
from extras.filtersets import LocalConfigContextFilterSet
|
from extras.filtersets import LocalConfigContextFilterSet
|
||||||
from ipam.models import VRF
|
from ipam.models import L2VPN, VRF
|
||||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||||
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
||||||
@ -295,6 +295,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
|
|||||||
to_field_name='rd',
|
to_field_name='rd',
|
||||||
label=_('VRF (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'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VMInterface
|
model = VMInterface
|
||||||
|
@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||||
from extras.forms import LocalConfigContextFilterForm
|
from extras.forms import LocalConfigContextFilterForm
|
||||||
from ipam.models import VRF
|
from ipam.models import L2VPN, VRF
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
@ -177,7 +177,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
|
('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
|
||||||
('Attributes', ('enabled', 'mac_address', 'vrf_id')),
|
('Attributes', ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
|
||||||
)
|
)
|
||||||
cluster_id = DynamicModelMultipleChoiceField(
|
cluster_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
@ -207,4 +207,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='VRF'
|
label='VRF'
|
||||||
)
|
)
|
||||||
|
l2vpn_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('L2VPN')
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
@ -10,7 +10,7 @@ django-prometheus==2.2.0
|
|||||||
django-redis==5.2.0
|
django-redis==5.2.0
|
||||||
django-rich==1.4.0
|
django-rich==1.4.0
|
||||||
django-rq==2.6.0
|
django-rq==2.6.0
|
||||||
django-tables2==2.5.0
|
django-tables2==2.5.1
|
||||||
django-taggit==3.1.0
|
django-taggit==3.1.0
|
||||||
django-timezone-field==5.0
|
django-timezone-field==5.0
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
@ -19,13 +19,13 @@ graphene-django==3.0.0
|
|||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
Markdown==3.3.7
|
Markdown==3.3.7
|
||||||
mkdocs-material==8.5.11
|
mkdocs-material==9.0.6
|
||||||
mkdocstrings[python-legacy]==0.19.1
|
mkdocstrings[python-legacy]==0.20.0
|
||||||
netaddr==0.8.0
|
netaddr==0.8.0
|
||||||
Pillow==9.4.0
|
Pillow==9.4.0
|
||||||
psycopg2-binary==2.9.5
|
psycopg2-binary==2.9.5
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
sentry-sdk==1.12.1
|
sentry-sdk==1.13.0
|
||||||
social-auth-app-django==5.0.0
|
social-auth-app-django==5.0.0
|
||||||
social-auth-core[openidconnect]==4.3.0
|
social-auth-core[openidconnect]==4.3.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
|
Loading…
Reference in New Issue
Block a user