mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-27 20:28:16 +01:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946779000f | ||
|
|
c3c3000a53 | ||
|
|
66daeda85f | ||
|
|
0fd0e76183 | ||
|
|
cf480375f6 | ||
|
|
736cd709d9 | ||
|
|
0f42219b4b | ||
|
|
b64f4b93eb | ||
|
|
32ffc1b54b | ||
|
|
608006ee77 | ||
|
|
3d78a67343 | ||
|
|
2f98f133bb | ||
|
|
fe0fbeab49 | ||
|
|
49fa243b4f | ||
|
|
a2308b9c99 | ||
|
|
422c6bad5b | ||
|
|
b7e78028ce | ||
|
|
509a115f68 | ||
|
|
f20d16f188 | ||
|
|
f4514034b8 | ||
|
|
6bc8f2e50b | ||
|
|
fc1245c49d | ||
|
|
4be7ca0c78 | ||
|
|
cb91c9231d | ||
|
|
a5413a5484 | ||
|
|
71120d9899 | ||
|
|
acb66c7dc0 | ||
|
|
2eba84dad5 | ||
|
|
fe89982d4e | ||
|
|
0296aa240a | ||
|
|
d88b3456c4 | ||
|
|
789cf827f2 | ||
|
|
6c19c88e99 | ||
|
|
c1ed2b6068 | ||
|
|
4eacc57522 | ||
|
|
1e740a70f7 | ||
|
|
94a7d8e493 | ||
|
|
883655ce71 | ||
|
|
6019260374 | ||
|
|
e5c5a1a101 | ||
|
|
c13b9d8798 | ||
|
|
03b10b6f73 | ||
|
|
67f4d8fab5 | ||
|
|
7e0073d6f5 | ||
|
|
c66dca399b | ||
|
|
9d085ad83a | ||
|
|
ad565e55f1 | ||
|
|
46c712e735 | ||
|
|
989d6f5af3 | ||
|
|
86865b91f8 | ||
|
|
b53480dd6a | ||
|
|
581ed52b24 | ||
|
|
472486acd6 | ||
|
|
92ec4bd22f | ||
|
|
959a0da0ed | ||
|
|
b23eaeca54 | ||
|
|
4f9271e9ff | ||
|
|
094553dbe7 | ||
|
|
60e4812b32 | ||
|
|
40625d1299 | ||
|
|
c9ec8b71e0 | ||
|
|
73e456495f | ||
|
|
54227ca9c7 | ||
|
|
f3b323536e | ||
|
|
6537f35176 | ||
|
|
b36d0ca3fc | ||
|
|
99809109ab | ||
|
|
1cdbfd6d60 | ||
|
|
4030e5ec24 | ||
|
|
4151e52802 | ||
|
|
b1e8145ffb | ||
|
|
c04d8ca5a7 | ||
|
|
e312c30822 | ||
|
|
40c30baffa | ||
|
|
bca7435a5a | ||
|
|
396bb28967 | ||
|
|
eb40275427 | ||
|
|
8519f546a6 | ||
|
|
e9b2ad9f5c | ||
|
|
39fba4f05d | ||
|
|
38c16d71b4 | ||
|
|
8fef6edb27 | ||
|
|
5cac900380 | ||
|
|
f49467bcb5 | ||
|
|
98a66f7fbe | ||
|
|
ce8d470860 | ||
|
|
dc475f4755 | ||
|
|
a7982bb0e1 | ||
|
|
ea05b5b606 | ||
|
|
996d49de67 | ||
|
|
74997a18a5 | ||
|
|
832fd49339 | ||
|
|
acb2f32304 | ||
|
|
32f39e10c9 | ||
|
|
190e683654 | ||
|
|
770f4c962c | ||
|
|
227921e0a0 | ||
|
|
39d0261d8a | ||
|
|
f267a532f6 | ||
|
|
e7ee4486a5 | ||
|
|
6a3cd83efc | ||
|
|
a7ec0c14f7 | ||
|
|
05bfe94d3e | ||
|
|
8d0aaa4ec1 | ||
|
|
4b5c4b7be5 | ||
|
|
18333973aa | ||
|
|
07a1baef13 | ||
|
|
15545b70d6 | ||
|
|
cfb8b3cf56 | ||
|
|
206732eb62 | ||
|
|
258cc4b50e | ||
|
|
d1f81783ef | ||
|
|
9bd2af48a3 | ||
|
|
d4df965f46 | ||
|
|
7dddd4734c | ||
|
|
c45daca5f2 | ||
|
|
067af26892 | ||
|
|
792f38334a | ||
|
|
dba40cd6bc | ||
|
|
4b19073b8b | ||
|
|
28eca9a026 | ||
|
|
3556051d14 | ||
|
|
e1d1f522ff | ||
|
|
04f3e58ab4 | ||
|
|
e1c61c5019 | ||
|
|
1c0de0093b | ||
|
|
9d8ab81e3a | ||
|
|
fa55571503 | ||
|
|
feb04f0401 | ||
|
|
5c07b6dc1d | ||
|
|
e3b448b7ad | ||
|
|
57f199f899 | ||
|
|
b38bb64c81 | ||
|
|
c2dc243c7c | ||
|
|
25c3c1b431 | ||
|
|
156fbae7d3 | ||
|
|
a0ae7a227d | ||
|
|
9ef3e68479 | ||
|
|
435d248645 | ||
|
|
53db5090c1 | ||
|
|
caa062c8ba | ||
|
|
240bbc2944 | ||
|
|
a3861ed492 | ||
|
|
82c70302fd | ||
|
|
80d1f80b61 | ||
|
|
d3c6caf8a8 | ||
|
|
a707204f98 | ||
|
|
523a1388db | ||
|
|
970586b07b | ||
|
|
28ae6849b4 | ||
|
|
8e3a371688 | ||
|
|
2a219eff23 | ||
|
|
f81641ae96 | ||
|
|
4f0d3e6b32 | ||
|
|
77d1ac8b07 | ||
|
|
5d0ac02704 | ||
|
|
fc5d07bb13 | ||
|
|
395f23e1d3 | ||
|
|
dd85448451 | ||
|
|
6a7af22dea | ||
|
|
37bc17d3a2 | ||
|
|
ca131e5b2a | ||
|
|
c75795ceda | ||
|
|
7dc0591e3e | ||
|
|
cfa078c929 | ||
|
|
57ea73db46 | ||
|
|
7ad9e8a2fb | ||
|
|
1a57120b78 | ||
|
|
d267aeb621 | ||
|
|
a110b0badb | ||
|
|
242ae9eb91 | ||
|
|
53625e0dea | ||
|
|
aa4f73ffbf | ||
|
|
8c7b0cf670 | ||
|
|
05570ae4ad | ||
|
|
0cf94cff16 | ||
|
|
b5455ed882 | ||
|
|
8a4293a4cc | ||
|
|
f649b9f04f | ||
|
|
5caa04ef2b | ||
|
|
f2c49063f8 | ||
|
|
ea2351e902 | ||
|
|
8effb54c89 | ||
|
|
66c99acb9e | ||
|
|
2e5a326315 | ||
|
|
b5177c608d | ||
|
|
be7d5a2310 | ||
|
|
5230127fde | ||
|
|
405320d8ab | ||
|
|
2f2e193cf9 | ||
|
|
1fc206b9c5 | ||
|
|
44e1a477f2 | ||
|
|
e62302c979 | ||
|
|
51b0fe4596 | ||
|
|
7399aa0c5e | ||
|
|
aa4588f9ba | ||
|
|
4c2e2e0fa3 | ||
|
|
6dea8ddbce | ||
|
|
e6623a6ca8 | ||
|
|
0c5f535689 | ||
|
|
ae9d0d894a | ||
|
|
14401c30b6 | ||
|
|
fbb93c72d0 | ||
|
|
5fedcd1f4e | ||
|
|
adeee0bf5c | ||
|
|
84a2b726f5 | ||
|
|
407a60dcc4 | ||
|
|
d31507985b | ||
|
|
0174c9747b | ||
|
|
55b503da5b | ||
|
|
aff4ad0f97 | ||
|
|
50df3acd26 | ||
|
|
e53e1e31de | ||
|
|
1acdf58a4b | ||
|
|
1d1cb867cd | ||
|
|
b46bfaebc1 | ||
|
|
a22c7c1539 | ||
|
|
ea51aa97b7 | ||
|
|
6a6959d041 | ||
|
|
462cede863 | ||
|
|
85c11bbd83 | ||
|
|
77e0564d13 | ||
|
|
3b03d68ac7 | ||
|
|
b57d64c72d | ||
|
|
3b76e0203a | ||
|
|
3c36bec298 | ||
|
|
425670f52a | ||
|
|
6a2651991a | ||
|
|
f25e2a1922 | ||
|
|
95edec5448 | ||
|
|
f2076c9572 | ||
|
|
d71e6698f4 | ||
|
|
9b26225fdd | ||
|
|
f8c5ca942a | ||
|
|
16353ce63c | ||
|
|
7a4b202064 | ||
|
|
a97ebc6d4c | ||
|
|
9e765b1704 | ||
|
|
1048d3909b | ||
|
|
b2caaa6733 | ||
|
|
15722c1871 | ||
|
|
1d18948307 | ||
|
|
6a451e0c0e | ||
|
|
b46e52eccc | ||
|
|
de986ec392 | ||
|
|
4b415caad2 | ||
|
|
bfede60f3d | ||
|
|
03b8759597 | ||
|
|
bdd623f82c | ||
|
|
3c8083ed7f | ||
|
|
5904f1f8ee | ||
|
|
cc848a3f01 | ||
|
|
1aaa101fb5 | ||
|
|
0319450643 | ||
|
|
099774d667 | ||
|
|
b1761f7856 | ||
|
|
1bc38f66ae | ||
|
|
b4433a8471 | ||
|
|
053f49c76a | ||
|
|
fbde6187ea | ||
|
|
5eb5c4bac5 | ||
|
|
fb4283ed53 | ||
|
|
bbd65988f9 | ||
|
|
a11fa44170 | ||
|
|
99a542e4e4 | ||
|
|
7f779e3942 | ||
|
|
a2a83a4a4c |
23
.github/lock.yml
vendored
Normal file
23
.github/lock.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Configuration for Lock (https://github.com/apps/lock)
|
||||
|
||||
# Number of days of inactivity before a closed issue or pull request is locked
|
||||
daysUntilLock: 90
|
||||
|
||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
||||
skipCreatedBefore: 2020-01-01
|
||||
|
||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
||||
exemptLabels: []
|
||||
|
||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
||||
lockLabel: false
|
||||
|
||||
# Comment to post before locking. Set to `false` to disable
|
||||
lockComment: false
|
||||
|
||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
||||
setLockReason: true
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
7
.github/stale.yaml → .github/stale.yml
vendored
7
.github/stale.yaml → .github/stale.yml
vendored
@@ -1,20 +1,27 @@
|
||||
# Configuration for Stale (https://github.com/apps/stale)
|
||||
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- "status: accepted"
|
||||
- "status: gathering feedback"
|
||||
- "status: blocked"
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. NetBox
|
||||
is governed by a small group of core maintainers which means not all opened
|
||||
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
@@ -16,7 +16,7 @@ For real-time discussion, you can join the #netbox Slack channel on [NetworkToCo
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
* First, ensure that you've installed the [latest stable version](https://github.com/netbox-community/netbox/releases)
|
||||
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases)
|
||||
of NetBox. If you're running an older version, it's possible that the bug has
|
||||
already been fixed.
|
||||
|
||||
@@ -28,27 +28,26 @@ 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.
|
||||
|
||||
* If you haven't found an existing issue that describes your suspected bug,
|
||||
please inquire about it on the mailing list. **Do not** file an issue until you
|
||||
have received confirmation that it is in fact a bug. Invalid issues are very
|
||||
distracting and slow the pace at which NetBox is developed.
|
||||
|
||||
* When submitting an issue, please be as descriptive as possible. Be sure to
|
||||
include:
|
||||
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 (if applicable)
|
||||
* 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 moderator after submission and the appropriate
|
||||
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
|
||||
intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
|
||||
## Feature Requests
|
||||
|
||||
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
|
||||
@@ -61,10 +60,10 @@ 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.)
|
||||
|
||||
* Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which substantially extend NetBox's functionality
|
||||
beyond its current feature set. This includes the introduction of any new views
|
||||
or models which have not already been proposed in an existing feature request.
|
||||
* Due to a large backlog of feature requests, we are not currently accepting
|
||||
any proposals which substantially extend NetBox's functionality beyond its
|
||||
current feature set. This includes the introduction of any new views or models
|
||||
which have not already been proposed in an existing feature request.
|
||||
|
||||
* Before filing a new feature request, consider raising your idea on the
|
||||
mailing list first. Feedback you receive there will help validate and shape the
|
||||
@@ -75,8 +74,8 @@ 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 the
|
||||
following:
|
||||
* When submitting a feature request on GitHub, be sure to include all
|
||||
information requested by the issue template, including:
|
||||
|
||||
* A detailed description of the proposed functionality
|
||||
* A use case for the feature; who would use it and what value it would add
|
||||
@@ -89,6 +88,9 @@ following:
|
||||
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
|
||||
[issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
* Be sure to open an issue **before** starting work on a pull request, and
|
||||
@@ -103,7 +105,7 @@ any work that's already in progress.
|
||||
|
||||
* When submitting a pull request, please be sure to work off of the `develop`
|
||||
branch, rather than `master`. The `develop` branch is used for ongoing
|
||||
development, while `master` is used for tagging new stable releases.
|
||||
development, while `master` is used for tagging stable releases.
|
||||
|
||||
* All code submissions should meet the following criteria (CI will enforce
|
||||
these checks):
|
||||
@@ -122,27 +124,26 @@ reduce noise in the discussion.
|
||||
|
||||
## Issue Lifecycle
|
||||
|
||||
When a correctly formatted issue is submitted it is evaluated by a moderator
|
||||
who may elect to immediately label the issue as accepted in addition to another
|
||||
issue type label. In other cases, the issue may be labeled as "status: gathering feedback"
|
||||
which will often be accompanied by a comment from a moderator asking for further dialog from the community.
|
||||
If an issue is labeled as "status: revisions needed" a moderator has identified a problem with
|
||||
the issue itself and is asking for the submitter himself to update the original post with
|
||||
the requested information. If the original post is not updated in a reasonable amount of time,
|
||||
the issue will be closed as invalid.
|
||||
New issues are handled according to our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
|
||||
Maintainers will assign label(s) and/or close new issues as the policy
|
||||
dictates. This helps ensure a productive development environment and avoid
|
||||
accumulating a large backlog of work.
|
||||
|
||||
The core maintainers group has chosen to make use of the GitHub Stale bot to aid in issue management.
|
||||
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
||||
to aid in issue management.
|
||||
|
||||
* Issues will be marked as stale after 14 days of no activity.
|
||||
* Then after 7 more days of inactivity, the issue will be closed.
|
||||
* Any issue bearing one of the following labels will be exempt from all Stale bot actions:
|
||||
* Any issue bearing one of the following labels will be exempt from all Stale
|
||||
bot actions:
|
||||
* `status: accepted`
|
||||
* `status: gathering feedback`
|
||||
* `status: blocked`
|
||||
|
||||
It is natural that some new issues get more attention than others. Often this is a metric of an issues's
|
||||
overall usefulness to the project. In other cases in which issues merely get lost in the shuffle,
|
||||
notifications from Stale bot can bring renewed attention to potentially meaningful issues.
|
||||
It is natural that some new issues get more attention than others. Often this
|
||||
is a metric of an issues's overall value to the project. In other cases in
|
||||
which issues merely get lost in the shuffle, notifications from Stale bot can
|
||||
bring renewed attention to potentially meaningful issues.
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure
|
||||
management (DCIM) tool. Initially conceived by the network engineering team at
|
||||
@@ -36,13 +36,6 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
|
||||
and run `upgrade.sh`.
|
||||
|
||||
## Alternative Installations
|
||||
|
||||
* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||
* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
|
||||
|
||||
# Providing Feedback
|
||||
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
|
||||
@@ -71,6 +71,18 @@ The checkbox to commit database changes when executing a script is checked by de
|
||||
commit_default = False
|
||||
```
|
||||
|
||||
## Accessing Request Data
|
||||
|
||||
Details of the current HTTP request (the one being made to execute the script) are available as the instance attribute `self.request`. This can be used to infer, for example, the user executing the script and the client IP address:
|
||||
|
||||
```python
|
||||
username = self.request.user.username
|
||||
ip_address = self.request.META.get('HTTP_X_FORWARDED_FOR') or self.request.META.get('REMOTE_ADDR')
|
||||
self.log_info("Running as user {} (IP: {})...".format(username, ip_address))
|
||||
```
|
||||
|
||||
For a complete list of available request parameters, please see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/request-response/).
|
||||
|
||||
## Reading Data from Files
|
||||
|
||||
The Script class provides two convenience methods for reading data from files:
|
||||
@@ -182,7 +194,7 @@ class NewBranchScript(Script):
|
||||
class Meta:
|
||||
name = "New Branch"
|
||||
description = "Provision a new branch site"
|
||||
fields = ['site_name', 'switch_count', 'switch_model']
|
||||
field_order = ['site_name', 'switch_count', 'switch_model']
|
||||
|
||||
site_name = StringVar(
|
||||
description="Name of the new site"
|
||||
|
||||
65
docs/additional-features/napalm.md
Normal file
65
docs/additional-features/napalm.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# NAPALM
|
||||
|
||||
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API.
|
||||
|
||||
!!! info
|
||||
To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/2-netbox/#napalm-automation-optional) for more information.
|
||||
|
||||
```
|
||||
GET /api/dcim/devices/1/napalm/?method=get_environment
|
||||
|
||||
{
|
||||
"get_environment": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) are used for NAPALM authentication. They can be overridden for an individual API call through the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
|
||||
|
||||
```
|
||||
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
|
||||
-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-NAPALM-Username: foo" \
|
||||
-H "X-NAPALM-Password: bar"
|
||||
```
|
||||
|
||||
## Method Support
|
||||
|
||||
The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. NetBox only supports [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
|
||||
|
||||
## Multiple Methods
|
||||
|
||||
More than one method in an API call can be invoked by adding multiple `method` parameters. For example:
|
||||
|
||||
```
|
||||
GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
|
||||
|
||||
{
|
||||
"get_ntp_servers": {
|
||||
...
|
||||
},
|
||||
"get_ntp_peers": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Optional Arguments
|
||||
|
||||
The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`.
|
||||
|
||||
|
||||
For instance, the SSH port is changed to 2222 in this API call:
|
||||
|
||||
```
|
||||
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
|
||||
-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-NAPALM-port: 2222"
|
||||
```
|
||||
@@ -69,6 +69,14 @@ If the new field will be included in the object list view, add a column to the m
|
||||
|
||||
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
|
||||
|
||||
### 11. Adjust API and model tests
|
||||
### 11. Create/extend test cases
|
||||
|
||||
Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.
|
||||
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
|
||||
|
||||
* API serializer/view tests
|
||||
* Filter tests
|
||||
* Form tests
|
||||
* Model tests
|
||||
* View tests
|
||||
|
||||
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
# What is NetBox?
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
# yum install -y epel-release
|
||||
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis
|
||||
# easy_install-3.6 pip
|
||||
# ln -s /usr/bin/python36 /usr/bin/python3
|
||||
# ln -s /usr/bin/python3.6 /usr/bin/python3
|
||||
```
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
|
||||
@@ -32,7 +32,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -80,6 +80,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
|
||||
```
|
||||
|
||||
# User Groups for Permissions
|
||||
|
||||
!!! info
|
||||
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
|
||||
|
||||
@@ -117,6 +118,9 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
|
||||
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
|
||||
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
|
||||
|
||||
!!! warning
|
||||
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
|
||||
|
||||
# Troubleshooting LDAP
|
||||
|
||||
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.
|
||||
|
||||
21
docs/netbox_logo.svg
Normal file
21
docs/netbox_logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 320">
|
||||
<g fill="#9cc8f8" stroke="#9cc8f8">
|
||||
<circle cx="37" cy="284" r="23"/>
|
||||
<circle cx="101" cy="37" r="23"/>
|
||||
<circle cx="101" cy="220" r="23"/>
|
||||
<circle cx="284" cy="220" r="23"/>
|
||||
<rect x="93" y="37" width="16" height="180"/>
|
||||
<rect x="101" y="212" width="180" height="16"/>
|
||||
<rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
|
||||
</g>
|
||||
<g fill="#1685fc" stroke="#1685fc">
|
||||
<circle cx="284" cy="37" r="23"/>
|
||||
<circle cx="37" cy="101" r="23"/>
|
||||
<circle cx="220" cy="101" r="23"/>
|
||||
<circle cx="220" cy="284" r="23"/>
|
||||
<rect x="37" y="93" width="180" height="16"/>
|
||||
<rect x="212" y="101" width="16" height="180"/>
|
||||
<rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
|
||||
<path transform="translate(380, 8)" d="M13.60 200L13.60 104L36.40 104L36.40 119.40L36.80 119.40Q40.20 112.20 47.20 106.90Q54.20 101.60 66.20 101.60L66.20 101.60Q75.80 101.60 82.50 104.80Q89.20 108 93.40 113.20Q97.60 118.40 99.40 125.20Q101.20 132 101.20 139.40L101.20 139.40L101.20 200L77.20 200L77.20 151.40Q77.20 147.40 76.80 142.50Q76.40 137.60 74.70 133.30Q73 129 69.40 126.10Q65.80 123.20 59.60 123.20L59.60 123.20Q53.60 123.20 49.50 125.20Q45.40 127.20 42.70 130.60Q40 134 38.80 138.40Q37.60 142.80 37.60 147.60L37.60 147.60L37.60 200L13.60 200ZM224.80 160.40L151.60 160.40Q152.80 171.20 160 177.20Q167.20 183.20 177.40 183.20L177.40 183.20Q186.40 183.20 192.50 179.50Q198.60 175.80 203.20 170.20L203.20 170.20L220.40 183.20Q212 193.60 201.60 198Q191.20 202.40 179.80 202.40L179.80 202.40Q169 202.40 159.40 198.80Q149.80 195.20 142.80 188.60Q135.80 182 131.70 172.70Q127.60 163.40 127.60 152L127.60 152Q127.60 140.60 131.70 131.30Q135.80 122 142.80 115.40Q149.80 108.80 159.40 105.20Q169 101.60 179.80 101.60L179.80 101.60Q189.80 101.60 198.10 105.10Q206.40 108.60 212.30 115.20Q218.20 121.80 221.50 131.50Q224.80 141.20 224.80 153.80L224.80 153.80L224.80 160.40ZM151.60 142.40L200.80 142.40Q200.60 131.80 194.20 125.70Q187.80 119.60 176.40 119.60L176.40 119.60Q165.60 119.60 159.30 125.80Q153 132 151.60 142.40L151.60 142.40ZM259.80 124.40L240.00 124.40L240.00 104L259.80 104L259.80 76.20L283.80 76.20L283.80 104L310.20 104L310.20 124.40L283.80 124.40L283.80 166.40Q283.80 173.60 286.50 177.80Q289.20 182 297.20 182L297.20 182Q300.40 182 304.20 181.30Q308 180.60 310.20 179L310.20 179L310.20 199.20Q306.40 201 300.90 201.70Q295.40 202.40 291.20 202.40L291.20 202.40Q281.60 202.40 275.50 200.30Q269.40 198.20 265.90 193.90Q262.40 189.60 261.10 183.20Q259.80 176.80 259.80 168.40L259.80 168.40L259.80 124.40ZM333.20 200L333.20 48.80L357.20 48.80L357.20 116.20L357.80 116.20Q359.60 113.80 362.40 111.30Q365.20 108.80 369.20 106.60Q373.20 104.40 378.40 103Q383.60 101.60 390.40 101.60L390.40 101.60Q400.60 101.60 409.20 105.50Q417.80 109.40 423.90 116.20Q430 123 433.40 132.20Q436.80 141.40 436.80 152L436.80 152Q436.80 162.60 433.60 171.80Q430.40 181 424.20 187.80Q418 194.60 409.20 198.50Q400.40 202.40 389.40 202.40L389.40 202.40Q379.20 202.40 370.40 198.40Q361.60 194.40 356.40 185.60L356.40 185.60L356 185.60L356 200L333.20 200ZM412.80 152L412.80 152Q412.80 146.40 410.90 141.20Q409 136 405.30 132Q401.60 128 396.40 125.60Q391.20 123.20 384.60 123.20L384.60 123.20Q378 123.20 372.80 125.60Q367.60 128 363.90 132Q360.20 136 358.30 141.20Q356.40 146.40 356.40 152L356.40 152Q356.40 157.60 358.30 162.80Q360.20 168 363.90 172Q367.60 176 372.80 178.40Q378 180.80 384.60 180.80L384.60 180.80Q391.20 180.80 396.40 178.40Q401.60 176 405.30 172Q409 168 410.90 162.80Q412.80 157.60 412.80 152ZM458.40 152L458.40 152Q458.40 140.60 462.50 131.30Q466.60 122 473.60 115.40Q480.60 108.80 490.20 105.20Q499.80 101.60 510.60 101.60L510.60 101.60Q521.40 101.60 531 105.20Q540.60 108.80 547.60 115.40Q554.60 122 558.70 131.30Q562.80 140.60 562.80 152L562.80 152Q562.80 163.40 558.70 172.70Q554.60 182 547.60 188.60Q540.60 195.20 531 198.80Q521.40 202.40 510.60 202.40L510.60 202.40Q499.80 202.40 490.20 198.80Q480.60 195.20 473.60 188.60Q466.60 182 462.50 172.70Q458.40 163.40 458.40 152ZM482.40 152L482.40 152Q482.40 157.60 484.30 162.80Q486.20 168 489.90 172Q493.60 176 498.80 178.40Q504 180.80 510.60 180.80L510.60 180.80Q517.20 180.80 522.40 178.40Q527.60 176 531.30 172Q535 168 536.90 162.80Q538.80 157.60 538.80 152L538.80 152Q538.80 146.40 536.90 141.20Q535 136 531.30 132Q527.60 128 522.40 125.60Q517.20 123.20 510.60 123.20L510.60 123.20Q504 123.20 498.80 125.60Q493.60 128 489.90 132Q486.20 136 484.30 141.20Q482.40 146.40 482.40 152ZM575.40 200L614 148.40L580.80 104L610 104L629.20 132.80L650 104L677.40 104L644.60 148.40L683.20 200L654 200L629 165.60L603.80 200L575.40 200Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1,3 +1,116 @@
|
||||
# v2.6.12 (2020-01-13)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger (OpenAPI)
|
||||
* [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering over the link
|
||||
* [#2113](https://github.com/netbox-community/netbox/issues/2113) - Allow NAPALM driver settings to be changed with request headers
|
||||
* [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle the display of child prefixes/IP addresses
|
||||
* [#3009](https://github.com/netbox-community/netbox/issues/3009) - Search by description when assigning IP address to interfaces
|
||||
* [#3021](https://github.com/netbox-community/netbox/issues/3021) - Add `tenant` filter field for cables
|
||||
* [#3090](https://github.com/netbox-community/netbox/issues/3090) - Enable filtering of interfaces by name on the device view
|
||||
* [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations view
|
||||
* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate assigned circuits at the provider details view
|
||||
* [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total path length to cable trace
|
||||
* [#3491](https://github.com/netbox-community/netbox/issues/3491) - Include content of response on webhook error
|
||||
* [#3623](https://github.com/netbox-community/netbox/issues/3623) - Enable word expansion during interface creation
|
||||
* [#3668](https://github.com/netbox-community/netbox/issues/3668) - Enable searching by DNS name when assigning IP address
|
||||
* [#3851](https://github.com/netbox-community/netbox/issues/3851) - Allow passing initial data to custom script forms
|
||||
* [#3891](https://github.com/netbox-community/netbox/issues/3891) - Add `local_context_data` filter for virtual machines
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3589](https://github.com/netbox-community/netbox/issues/3589) - Fix validation on tagged VLANs of an interface
|
||||
* [#3849](https://github.com/netbox-community/netbox/issues/3849) - Fix ordering of models when dumping data to JSON
|
||||
* [#3853](https://github.com/netbox-community/netbox/issues/3853) - Fix device role link on config context view
|
||||
* [#3856](https://github.com/netbox-community/netbox/issues/3856) - Allow filtering VM interfaces by multiple MAC addresses
|
||||
* [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix rendering of grouped custom links
|
||||
* [#3862](https://github.com/netbox-community/netbox/issues/3862) - Allow filtering device components by multiple device names
|
||||
* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks for prefixes and IP addresses
|
||||
* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs on the IP address view
|
||||
* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fix minimum/maximum value rendering for site ASN field
|
||||
* [#3882](https://github.com/netbox-community/netbox/issues/3882) - Fix filtering of devices by rack group
|
||||
* [#3898](https://github.com/netbox-community/netbox/issues/3898) - Fix references to deleted cables without a label
|
||||
* [#3905](https://github.com/netbox-community/netbox/issues/3905) - Fix divide-by-zero on power feeds with low power values
|
||||
|
||||
---
|
||||
|
||||
# v2.6.11 (2020-01-03)
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3831](https://github.com/netbox-community/netbox/issues/3831) - Fix API-driven filter field rendering (#3812 regression)
|
||||
* [#3833](https://github.com/netbox-community/netbox/issues/3833) - Add missing region filters for multiple objects
|
||||
|
||||
---
|
||||
|
||||
# v2.6.10 (2020-01-02)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2233](https://github.com/netbox-community/netbox/issues/2233) - Add ability to move inventory items between devices
|
||||
* [#2892](https://github.com/netbox-community/netbox/issues/2892) - Extend admin UI to allow deleting old report results
|
||||
* [#3062](https://github.com/netbox-community/netbox/issues/3062) - Add `assigned_to_interface` filter for IP addresses
|
||||
* [#3461](https://github.com/netbox-community/netbox/issues/3461) - Fail gracefully on custom link rendering exception
|
||||
* [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts
|
||||
* [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets
|
||||
* [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items
|
||||
* [#3812](https://github.com/netbox-community/netbox/issues/3812) - Optimize size of pages containing a dynamic selection field
|
||||
* [#3827](https://github.com/netbox-community/netbox/issues/3827) - Allow filtering console/power/interface connections by device ID
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3106](https://github.com/netbox-community/netbox/issues/3106) - Restrict queryset of chained fields when form validation fails
|
||||
* [#3695](https://github.com/netbox-community/netbox/issues/3695) - Include A/Z termination sites for circuits in global search
|
||||
* [#3712](https://github.com/netbox-community/netbox/issues/3712) - Scrolling to target (hash) did not account for the header size
|
||||
* [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs
|
||||
* [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices
|
||||
* [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list
|
||||
* [#3822](https://github.com/netbox-community/netbox/issues/3822) - Fix exception when editing a device bay (regression from #3596)
|
||||
|
||||
---
|
||||
|
||||
# v2.6.9 (2019-12-16)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3152](https://github.com/netbox-community/netbox/issues/3152) - Include direct link to rack elevations on site view
|
||||
* [#3441](https://github.com/netbox-community/netbox/issues/3441) - Move virtual machine results near devices in global search
|
||||
* [#3761](https://github.com/netbox-community/netbox/issues/3761) - Added copy button for API tokens
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2170](https://github.com/netbox-community/netbox/issues/2170) - Prevent the deletion of a virtual chassis when a cross-member LAG is present
|
||||
* [#2358](https://github.com/netbox-community/netbox/issues/2358) - Respect custom field default values when creating objects via the REST API
|
||||
* [#3749](https://github.com/netbox-community/netbox/issues/3749) - Fix exception on password change page for local users
|
||||
* [#3757](https://github.com/netbox-community/netbox/issues/3757) - Fix unable to assign IP to interface
|
||||
|
||||
---
|
||||
|
||||
# v2.6.8 (2019-12-10)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3139](https://github.com/netbox-community/netbox/issues/3139) - Disable password change form for LDAP-authenticated users
|
||||
* [#3457](https://github.com/netbox-community/netbox/issues/3457) - Display cable colors on device view
|
||||
* [#3329](https://github.com/netbox-community/netbox/issues/3329) - Remove obsolete P3P policy header
|
||||
* [#3663](https://github.com/netbox-community/netbox/issues/3663) - Add query filters for `created` and `last_updated` fields
|
||||
* [#3722](https://github.com/netbox-community/netbox/issues/3722) - Allow the underscore character in IPAddress DNS names
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3312](https://github.com/netbox-community/netbox/issues/3312) - Fix validation error when editing power cables in bulk
|
||||
* [#3644](https://github.com/netbox-community/netbox/issues/3644) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
|
||||
* [#3669](https://github.com/netbox-community/netbox/issues/3669) - Include `weight` field in prefix/VLAN role form
|
||||
* [#3674](https://github.com/netbox-community/netbox/issues/3674) - Include comments on PowerFeed view
|
||||
* [#3679](https://github.com/netbox-community/netbox/issues/3679) - Fix link for assigned ipaddress in interface page
|
||||
* [#3709](https://github.com/netbox-community/netbox/issues/3709) - Prevent exception when importing an invalid cable definition
|
||||
* [#3720](https://github.com/netbox-community/netbox/issues/3720) - Correctly indicate power feed terminations on cable list
|
||||
* [#3724](https://github.com/netbox-community/netbox/issues/3724) - Fix API filtering of interfaces by more than one device name
|
||||
* [#3725](https://github.com/netbox-community/netbox/issues/3725) - Enforce client validation for minimum service port number
|
||||
|
||||
---
|
||||
|
||||
# v2.6.7 (2019-11-01)
|
||||
|
||||
## Enhancements
|
||||
|
||||
@@ -31,9 +31,11 @@ pages:
|
||||
- Change Logging: 'additional-features/change-logging.md'
|
||||
- Context Data: 'additional-features/context-data.md'
|
||||
- Custom Fields: 'additional-features/custom-fields.md'
|
||||
- Custom Links: 'additional-features/custom-links.md'
|
||||
- Custom Scripts: 'additional-features/custom-scripts.md'
|
||||
- Export Templates: 'additional-features/export-templates.md'
|
||||
- Graphs: 'additional-features/graphs.md'
|
||||
- NAPALM: 'additional-features/napalm.md'
|
||||
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
|
||||
- Reports: 'additional-features/reports.md'
|
||||
- Tags: 'additional-features/tags.md'
|
||||
|
||||
@@ -2,14 +2,21 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
|
||||
from .constants import *
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
__all__ = (
|
||||
'CircuitFilter',
|
||||
'CircuitTerminationFilter',
|
||||
'CircuitTypeFilter',
|
||||
'ProviderFilter',
|
||||
)
|
||||
|
||||
class ProviderFilter(CustomFieldFilterSet):
|
||||
|
||||
class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -18,6 +25,17 @@ class ProviderFilter(CustomFieldFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='circuits__terminations__site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='circuits__terminations__site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='circuits__terminations__site',
|
||||
queryset=Site.objects.all(),
|
||||
@@ -54,7 +72,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
|
||||
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
@@ -7,7 +7,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
|
||||
FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
|
||||
DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
|
||||
)
|
||||
from .constants import *
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -104,6 +104,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -161,7 +173,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
]
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
'install_date': "Format: YYYY-MM-DD",
|
||||
'commit_rate': "Committed rate",
|
||||
}
|
||||
widgets = {
|
||||
@@ -172,7 +183,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
api_url="/api/circuits/circuit-types/"
|
||||
),
|
||||
'status': StaticSelect2(),
|
||||
|
||||
'install_date': DatePicker(),
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +314,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
|
||||
287
netbox/circuits/tests/test_filters.py
Normal file
287
netbox/circuits/tests/test_filters.py
Normal file
@@ -0,0 +1,287 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_OFFLINE, CIRCUIT_STATUS_PLANNED
|
||||
from circuits.filters import *
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.models import Region, Site
|
||||
|
||||
|
||||
class ProviderTestCase(TestCase):
|
||||
queryset = Provider.objects.all()
|
||||
filterset = ProviderFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'),
|
||||
Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'),
|
||||
Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'),
|
||||
Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'),
|
||||
Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
circuit_types = (
|
||||
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
|
||||
CircuitType(name='Test Circuit Type 2', slug='test-circuit-type-2'),
|
||||
)
|
||||
CircuitType.objects.bulk_create(circuit_types)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'),
|
||||
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 1'),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
CircuitTermination.objects.bulk_create((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000),
|
||||
))
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Provider 1', 'Provider 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['provider-1', 'provider-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_asn(self):
|
||||
params = {'asn': ['65001', '65002']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_account(self):
|
||||
params = {'account': ['1234', '2345']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CircuitTypeTestCase(TestCase):
|
||||
queryset = CircuitType.objects.all()
|
||||
filterset = CircuitTypeFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
CircuitType.objects.bulk_create((
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
|
||||
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
|
||||
))
|
||||
|
||||
def test_id(self):
|
||||
params = {'id': [self.queryset.first().pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Circuit Type 1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['circuit-type-1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class CircuitTestCase(TestCase):
|
||||
queryset = Circuit.objects.all()
|
||||
filterset = CircuitFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
circuit_types = (
|
||||
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
|
||||
CircuitType(name='Test Circuit Type 2', slug='test-circuit-type-2'),
|
||||
)
|
||||
CircuitType.objects.bulk_create(circuit_types)
|
||||
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1'),
|
||||
Provider(name='Provider 2', slug='provider-2'),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CIRCUIT_STATUS_ACTIVE),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CIRCUIT_STATUS_ACTIVE),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CIRCUIT_STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CIRCUIT_STATUS_PLANNED),
|
||||
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CIRCUIT_STATUS_OFFLINE),
|
||||
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CIRCUIT_STATUS_OFFLINE),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=1000),
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
def test_cid(self):
|
||||
params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_install_date(self):
|
||||
params = {'install_date': ['2020-01-01', '2020-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_commit_rate(self):
|
||||
params = {'commit_rate': ['1000', '2000']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_provider(self):
|
||||
provider = Provider.objects.first()
|
||||
params = {'provider_id': [provider.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'provider': [provider.slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_type(self):
|
||||
circuit_type = CircuitType.objects.first()
|
||||
params = {'type_id': [circuit_type.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'type': [circuit_type.slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CircuitTerminationTestCase(TestCase):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
filterset = CircuitTerminationFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1'),
|
||||
Site(name='Test Site 2', slug='test-site-2'),
|
||||
Site(name='Test Site 3', slug='test-site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
circuit_types = (
|
||||
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
|
||||
)
|
||||
CircuitType.objects.bulk_create(circuit_types)
|
||||
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1'),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
circuits = (
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
def test_term_side(self):
|
||||
params = {'term_side': 'A'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_port_speed(self):
|
||||
params = {'port_speed': ['1000', '2000']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_upstream_speed(self):
|
||||
params = {'upstream_speed': ['1000', '2000']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_xconnect_id(self):
|
||||
params = {'xconnect_id': ['ABC', 'DEF']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_circuit_id(self):
|
||||
circuits = Circuit.objects.all()[:2]
|
||||
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
@@ -5,9 +6,11 @@ from django.db import transaction
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
@@ -38,9 +41,18 @@ class ProviderView(PermissionRequiredMixin, View):
|
||||
circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
|
||||
|
||||
circuits_table = tables.CircuitTable(circuits, orderable=False)
|
||||
circuits_table.columns.hide('provider')
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(circuits_table)
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
'circuits': circuits,
|
||||
'circuits_table': circuits_table,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
|
||||
|
||||
@@ -370,6 +370,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
return obj.get_config_context()
|
||||
|
||||
|
||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.DictField()
|
||||
|
||||
|
||||
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
@@ -358,6 +358,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[
|
||||
Parameter(
|
||||
name='method',
|
||||
in_='query',
|
||||
required=True,
|
||||
type=openapi.TYPE_STRING
|
||||
)
|
||||
],
|
||||
responses={'200': serializers.DeviceNAPALMSerializer}
|
||||
)
|
||||
@action(detail=True, url_path='napalm')
|
||||
def napalm(self, request, pk):
|
||||
"""
|
||||
@@ -396,13 +407,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||
ip_address = str(device.primary_ip.address.ip)
|
||||
username = settings.NAPALM_USERNAME
|
||||
password = settings.NAPALM_PASSWORD
|
||||
optional_args = settings.NAPALM_ARGS.copy()
|
||||
if device.platform.napalm_args is not None:
|
||||
optional_args.update(device.platform.napalm_args)
|
||||
|
||||
# Update NAPALM parameters according to the request headers
|
||||
for header in request.headers:
|
||||
if header[:9].lower() != 'x-napalm-':
|
||||
continue
|
||||
|
||||
key = header[9:]
|
||||
if key.lower() == 'username':
|
||||
username = request.headers[header]
|
||||
elif key.lower() == 'password':
|
||||
password = request.headers[header]
|
||||
elif key:
|
||||
optional_args[key.lower()] = request.headers[header]
|
||||
|
||||
d = driver(
|
||||
hostname=ip_address,
|
||||
username=settings.NAPALM_USERNAME,
|
||||
password=settings.NAPALM_PASSWORD,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=settings.NAPALM_TIMEOUT,
|
||||
optional_args=optional_args
|
||||
)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
|
||||
# BGP ASN bounds
|
||||
BGP_ASN_MIN = 1
|
||||
BGP_ASN_MAX = 2**32 - 1
|
||||
|
||||
# Rack types
|
||||
RACK_TYPE_2POST = 100
|
||||
RACK_TYPE_4POST = 200
|
||||
@@ -384,7 +388,8 @@ CONNECTION_STATUS_CHOICES = [
|
||||
|
||||
# Cable endpoint types
|
||||
CABLE_TERMINATION_TYPES = [
|
||||
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
|
||||
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
|
||||
'circuittermination', 'powerfeed',
|
||||
]
|
||||
|
||||
# Cable types
|
||||
|
||||
@@ -3,14 +3,21 @@ from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from netaddr import AddrFormatError, EUI, mac_unix_expanded
|
||||
|
||||
from .constants import *
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
description = "32-bit ASN field"
|
||||
default_validators = [
|
||||
MinValueValidator(1),
|
||||
MaxValueValidator(4294967295),
|
||||
MinValueValidator(BGP_ASN_MIN),
|
||||
MaxValueValidator(BGP_ASN_MAX),
|
||||
]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'min_value': BGP_ASN_MIN, 'max_value': BGP_ASN_MAX}
|
||||
defaults.update(**kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
||||
class mac_unix_expanded_uppercase(mac_unix_expanded):
|
||||
word_fmt = '%.2X'
|
||||
|
||||
@@ -2,13 +2,13 @@ import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.constants import COLOR_CHOICES
|
||||
from utilities.filters import (
|
||||
MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter,
|
||||
TreeNodeMultipleChoiceFilter,
|
||||
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter,
|
||||
TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import *
|
||||
@@ -21,6 +21,45 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CableFilter',
|
||||
'ConsoleConnectionFilter',
|
||||
'ConsolePortFilter',
|
||||
'ConsolePortTemplateFilter',
|
||||
'ConsoleServerPortFilter',
|
||||
'ConsoleServerPortTemplateFilter',
|
||||
'DeviceBayFilter',
|
||||
'DeviceBayTemplateFilter',
|
||||
'DeviceFilter',
|
||||
'DeviceRoleFilter',
|
||||
'DeviceTypeFilter',
|
||||
'FrontPortFilter',
|
||||
'FrontPortTemplateFilter',
|
||||
'InterfaceConnectionFilter',
|
||||
'InterfaceFilter',
|
||||
'InterfaceTemplateFilter',
|
||||
'InventoryItemFilter',
|
||||
'ManufacturerFilter',
|
||||
'PlatformFilter',
|
||||
'PowerConnectionFilter',
|
||||
'PowerFeedFilter',
|
||||
'PowerOutletFilter',
|
||||
'PowerOutletTemplateFilter',
|
||||
'PowerPanelFilter',
|
||||
'PowerPortFilter',
|
||||
'PowerPortTemplateFilter',
|
||||
'RackFilter',
|
||||
'RackGroupFilter',
|
||||
'RackReservationFilter',
|
||||
'RackRoleFilter',
|
||||
'RearPortFilter',
|
||||
'RearPortTemplateFilter',
|
||||
'RegionFilter',
|
||||
'SiteFilter',
|
||||
'VirtualChassisFilter',
|
||||
)
|
||||
|
||||
|
||||
class RegionFilter(NameSlugSearchFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -38,7 +77,7 @@ class RegionFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -93,6 +132,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
|
||||
|
||||
class RackGroupFilter(NameSlugSearchFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
@@ -116,7 +166,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -125,6 +175,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
@@ -251,7 +312,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class DeviceTypeFilter(CustomFieldFilterSet):
|
||||
class DeviceTypeFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -423,7 +484,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver']
|
||||
|
||||
|
||||
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
|
||||
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -624,7 +685,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelChoiceFilter(
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
@@ -696,7 +758,7 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
label='Device',
|
||||
@@ -749,8 +811,10 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
device = Device.objects.get(**{name: value})
|
||||
vc_interface_ids = device.vc_interfaces.values_list('id', flat=True)
|
||||
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||
vc_interface_ids = []
|
||||
for device in devices:
|
||||
vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
@@ -829,6 +893,28 @@ class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='device__site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='device__site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
@@ -866,8 +952,8 @@ class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(part_id__icontains=value) |
|
||||
Q(serial__iexact=value) |
|
||||
Q(asset_tag__iexact=value) |
|
||||
Q(serial__icontains=value) |
|
||||
Q(asset_tag__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -878,6 +964,17 @@ class VirtualChassisFilter(django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='master__site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='master__site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='master__site',
|
||||
queryset=Site.objects.all(),
|
||||
@@ -933,7 +1030,7 @@ class CableFilter(django_filters.FilterSet):
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueNumberFilter(
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__name'
|
||||
)
|
||||
@@ -953,6 +1050,14 @@ class CableFilter(django_filters.FilterSet):
|
||||
method='filter_device',
|
||||
field_name='device__site__slug'
|
||||
)
|
||||
tenant_id = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='device__tenant_id'
|
||||
)
|
||||
tenant = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='device__tenant__slug'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
@@ -976,9 +1081,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
label='Device',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -991,11 +1099,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
return queryset.filter(connected_endpoint__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value) |
|
||||
Q(connected_endpoint__device__name__icontains=value)
|
||||
Q(**{'{}__in'.format(name): value}) |
|
||||
Q(**{'connected_endpoint__{}__in'.format(name): value})
|
||||
)
|
||||
|
||||
|
||||
@@ -1004,9 +1112,12 @@ class PowerConnectionFilter(django_filters.FilterSet):
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
label='Device',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1019,11 +1130,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
|
||||
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value) |
|
||||
Q(_connected_poweroutlet__device__name__icontains=value)
|
||||
Q(**{'{}__in'.format(name): value}) |
|
||||
Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
|
||||
)
|
||||
|
||||
|
||||
@@ -1032,9 +1143,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
label='Device',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1050,11 +1164,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value) |
|
||||
Q(_connected_interface__device__name__icontains=value)
|
||||
Q(**{'{}__in'.format(name): value}) |
|
||||
Q(**{'_connected_interface__{}__in'.format(name): value})
|
||||
)
|
||||
|
||||
|
||||
@@ -1067,6 +1181,17 @@ class PowerPanelFilter(django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
@@ -1096,7 +1221,7 @@ class PowerPanelFilter(django_filters.FilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class PowerFeedFilter(CustomFieldFilterSet):
|
||||
class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -1105,6 +1230,17 @@ class PowerFeedFilter(CustomFieldFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='power_panel__site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='power_panel__site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='power_panel__site',
|
||||
queryset=Site.objects.all(),
|
||||
|
||||
@@ -74,6 +74,17 @@ class InterfaceCommonForm:
|
||||
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
|
||||
self.cleaned_data['tagged_vlans'] = []
|
||||
|
||||
# Validate tagged VLANs; must be a global VLAN or in the same site
|
||||
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED:
|
||||
valid_sites = [None, self.cleaned_data['device'].site]
|
||||
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
|
||||
|
||||
if invalid_vlans:
|
||||
raise forms.ValidationError({
|
||||
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or they must be global".format(', '.join(invalid_vlans))
|
||||
})
|
||||
|
||||
|
||||
class BulkRenameForm(forms.Form):
|
||||
"""
|
||||
@@ -281,8 +292,8 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
)
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=4294967295,
|
||||
min_value=BGP_ASN_MIN,
|
||||
max_value=BGP_ASN_MAX,
|
||||
required=False,
|
||||
label='ASN'
|
||||
)
|
||||
@@ -364,6 +375,18 @@ class RackGroupCSVForm(forms.ModelForm):
|
||||
|
||||
|
||||
class RackGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -635,11 +658,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
|
||||
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = Rack
|
||||
field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -651,16 +686,15 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
}
|
||||
)
|
||||
)
|
||||
group_id = ChainedModelChoiceField(
|
||||
label='Rack group',
|
||||
queryset=RackGroup.objects.prefetch_related('site'),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.prefetch_related(
|
||||
'site'
|
||||
),
|
||||
required=False,
|
||||
label='Rack group',
|
||||
null_label='-- None --',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/rack-groups/",
|
||||
null_option=True,
|
||||
null_option=True
|
||||
)
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
@@ -680,6 +714,34 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Rack elevations
|
||||
#
|
||||
|
||||
class RackElevationFilterForm(RackFilterForm):
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant']
|
||||
id = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group_id', 'group_id'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/racks/',
|
||||
display_field='display_name',
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter the rack field based on the site and group
|
||||
self.fields['site'].widget.add_filter_for('id', 'site')
|
||||
self.fields['group_id'].widget.add_filter_for('id', 'group_id')
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
@@ -1339,7 +1401,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/manufacturers/",
|
||||
filter_for={
|
||||
'device_type': 'manufacturer_id'
|
||||
'device_type': 'manufacturer_id',
|
||||
'platform': 'manufacturer_id'
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1408,7 +1471,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
),
|
||||
'status': StaticSelect2(),
|
||||
'platform': APISelect(
|
||||
api_url="/api/dcim/platforms/"
|
||||
api_url="/api/dcim/platforms/",
|
||||
additional_query_params={
|
||||
"manufacturer_id": "null"
|
||||
}
|
||||
),
|
||||
'primary_ip4': StaticSelect2(),
|
||||
'primary_ip6': StaticSelect2(),
|
||||
@@ -1764,7 +1830,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/rack-groups/",
|
||||
filter_for={
|
||||
'rack_id': 'rack_group_id',
|
||||
'rack_id': 'group_id',
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -2223,36 +2289,6 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG
|
||||
)
|
||||
|
||||
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
|
||||
vlan_choices = []
|
||||
global_vlans = VLAN.objects.filter(site=None, group=None)
|
||||
vlan_choices.append(
|
||||
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
|
||||
)
|
||||
for group in VLANGroup.objects.filter(site=None):
|
||||
global_group_vlans = VLAN.objects.filter(group=group)
|
||||
vlan_choices.append(
|
||||
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
|
||||
)
|
||||
|
||||
site = getattr(self.instance.parent, 'site', None)
|
||||
if site is not None:
|
||||
|
||||
# Add non-grouped site VLANs
|
||||
site_vlans = VLAN.objects.filter(site=site, group=None)
|
||||
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
|
||||
|
||||
# Add grouped site VLANs
|
||||
for group in VLANGroup.objects.filter(site=site):
|
||||
site_group_vlans = VLAN.objects.filter(group=group)
|
||||
vlan_choices.append((
|
||||
'{} / {}'.format(group.site.name, group.name),
|
||||
[(vlan.pk, vlan) for vlan in site_group_vlans]
|
||||
))
|
||||
|
||||
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
|
||||
self.fields['tagged_vlans'].choices = vlan_choices
|
||||
|
||||
|
||||
class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
|
||||
name_pattern = ExpandableNameField(
|
||||
@@ -2333,36 +2369,6 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
|
||||
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
|
||||
vlan_choices = []
|
||||
global_vlans = VLAN.objects.filter(site=None, group=None)
|
||||
vlan_choices.append(
|
||||
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
|
||||
)
|
||||
for group in VLANGroup.objects.filter(site=None):
|
||||
global_group_vlans = VLAN.objects.filter(group=group)
|
||||
vlan_choices.append(
|
||||
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
|
||||
)
|
||||
|
||||
site = getattr(self.parent, 'site', None)
|
||||
if site is not None:
|
||||
|
||||
# Add non-grouped site VLANs
|
||||
site_vlans = VLAN.objects.filter(site=site, group=None)
|
||||
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
|
||||
|
||||
# Add grouped site VLANs
|
||||
for group in VLANGroup.objects.filter(site=site):
|
||||
site_group_vlans = VLAN.objects.filter(group=group)
|
||||
vlan_choices.append((
|
||||
'{} / {}'.format(group.site.name, group.name),
|
||||
[(vlan.pk, vlan) for vlan in site_group_vlans]
|
||||
))
|
||||
|
||||
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
|
||||
self.fields['tagged_vlans'].choices = vlan_choices
|
||||
|
||||
|
||||
class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
@@ -2445,36 +2451,6 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
|
||||
else:
|
||||
self.fields['lag'].choices = []
|
||||
|
||||
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
|
||||
vlan_choices = []
|
||||
global_vlans = VLAN.objects.filter(site=None, group=None)
|
||||
vlan_choices.append(
|
||||
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
|
||||
)
|
||||
for group in VLANGroup.objects.filter(site=None):
|
||||
global_group_vlans = VLAN.objects.filter(group=group)
|
||||
vlan_choices.append(
|
||||
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
|
||||
)
|
||||
if self.parent_obj is not None:
|
||||
site = getattr(self.parent_obj, 'site', None)
|
||||
if site is not None:
|
||||
|
||||
# Add non-grouped site VLANs
|
||||
site_vlans = VLAN.objects.filter(site=site, group=None)
|
||||
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
|
||||
|
||||
# Add grouped site VLANs
|
||||
for group in VLANGroup.objects.filter(site=site):
|
||||
site_group_vlans = VLAN.objects.filter(group=group)
|
||||
vlan_choices.append((
|
||||
'{} / {}'.format(group.site.name, group.name),
|
||||
[(vlan.pk, vlan) for vlan in site_group_vlans]
|
||||
))
|
||||
|
||||
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
|
||||
self.fields['tagged_vlans'].choices = vlan_choices
|
||||
|
||||
|
||||
class InterfaceBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
@@ -2828,6 +2804,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f
|
||||
termination_b_provider = forms.ModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/circuits/providers/',
|
||||
filter_for={
|
||||
@@ -2881,6 +2858,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode
|
||||
termination_b_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/sites/',
|
||||
display_field='cid',
|
||||
@@ -2912,6 +2890,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode
|
||||
('rack_group', 'termination_b_rackgroup'),
|
||||
),
|
||||
label='Power Panel',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/power-panels/',
|
||||
filter_for={
|
||||
@@ -3143,6 +3122,17 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
}
|
||||
)
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/tenancy/tenants/",
|
||||
value_field='slug',
|
||||
filter_for={
|
||||
'device_id': 'tenant',
|
||||
}
|
||||
)
|
||||
)
|
||||
rack_id = FilterChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
@@ -3167,9 +3157,13 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
widget=ColorSelect()
|
||||
)
|
||||
device = forms.CharField(
|
||||
device_id = FilterChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label='Device name'
|
||||
label='Device',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/devices/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -3234,38 +3228,59 @@ class DeviceBayBulkRenameForm(BulkRenameForm):
|
||||
#
|
||||
|
||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
device = forms.CharField(
|
||||
device_id = FilterChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label='Device name'
|
||||
label='Device',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/devices/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
device = forms.CharField(
|
||||
device_id = FilterChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label='Device name'
|
||||
label='Device',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/devices/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
device = forms.CharField(
|
||||
device_id = FilterChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label='Device name'
|
||||
label='Device',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/devices/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -3281,9 +3296,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
|
||||
'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': APISelect(
|
||||
api_url="/api/dcim/devices/"
|
||||
),
|
||||
'manufacturer': APISelect(
|
||||
api_url="/api/dcim/manufacturers/"
|
||||
)
|
||||
@@ -3319,9 +3337,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
queryset=InventoryItem.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/devices/"
|
||||
)
|
||||
)
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/manufacturers/"
|
||||
)
|
||||
)
|
||||
part_id = forms.CharField(
|
||||
max_length=50,
|
||||
@@ -3345,18 +3373,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
device = forms.CharField(
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
label='Device name'
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'device_id': 'site'
|
||||
}
|
||||
)
|
||||
)
|
||||
device_id = FilterChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/devices/',
|
||||
)
|
||||
)
|
||||
manufacturer = FilterChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/manufacturers/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
discovered = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
@@ -3503,6 +3561,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -3608,6 +3678,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -3828,6 +3910,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
|
||||
@@ -174,8 +174,8 @@ class Migration(migrations.Migration):
|
||||
('length', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
|
||||
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'powerfeed']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'powerfeed']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.db.models import Count, F, ProtectedError, Q, Sum
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
@@ -98,6 +98,8 @@ class CableTermination(models.Model):
|
||||
object_id_field='termination_b_id'
|
||||
)
|
||||
|
||||
is_path_endpoint = True
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -2444,6 +2446,8 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
is_path_endpoint = False
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -2506,6 +2510,8 @@ class RearPort(CableTermination, ComponentModel):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
is_path_endpoint = False
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@@ -2591,7 +2597,7 @@ class DeviceBay(ComponentModel):
|
||||
# Check that the installed device is not already installed elsewhere
|
||||
if self.installed_device:
|
||||
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
|
||||
if current_bay:
|
||||
if current_bay and current_bay != self:
|
||||
raise ValidationError({
|
||||
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
|
||||
current_bay
|
||||
@@ -2724,6 +2730,24 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
'master': "The selected master is not assigned to this virtual chassis."
|
||||
})
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
# Check for LAG interfaces split across member chassis
|
||||
interfaces = Interface.objects.filter(
|
||||
device__in=self.members.all(),
|
||||
lag__isnull=False
|
||||
).exclude(
|
||||
lag__device=F('device')
|
||||
)
|
||||
if interfaces:
|
||||
raise ProtectedError(
|
||||
"Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
|
||||
"LAG".format(self),
|
||||
interfaces
|
||||
)
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.master,
|
||||
@@ -2731,255 +2755,6 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
class Cable(ChangeLoggedModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
"""
|
||||
termination_a_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_a_id = models.PositiveIntegerField()
|
||||
termination_a = GenericForeignKey(
|
||||
ct_field='termination_a_type',
|
||||
fk_field='termination_a_id'
|
||||
)
|
||||
termination_b_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_b_id = models.PositiveIntegerField()
|
||||
termination_b = GenericForeignKey(
|
||||
ct_field='termination_b_type',
|
||||
fk_field='termination_b_id'
|
||||
)
|
||||
type = models.PositiveSmallIntegerField(
|
||||
choices=CABLE_TYPE_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
default=CONNECTION_STATUS_CONNECTED
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
color = ColorField(
|
||||
blank=True
|
||||
)
|
||||
length = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
length_unit = models.PositiveSmallIntegerField(
|
||||
choices=CABLE_LENGTH_UNIT_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Stores the normalized length (in meters) for database ordering
|
||||
_abs_length = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
|
||||
# their associated Devices.
|
||||
_termination_a_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_termination_b_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
csv_headers = [
|
||||
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
|
||||
'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['pk']
|
||||
unique_together = (
|
||||
('termination_a_type', 'termination_a_id'),
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
return self.label
|
||||
|
||||
# Save a copy of the PK on the instance since it's nullified if .delete() is called
|
||||
if not hasattr(self, 'id_string'):
|
||||
self.id_string = '#{}'.format(self.pk)
|
||||
|
||||
return self.id_string
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that termination A exists
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
|
||||
})
|
||||
|
||||
# Validate that termination B exists
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# Validate interface types
|
||||
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_a.get_type_display()
|
||||
)
|
||||
})
|
||||
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_b.get_type_display()
|
||||
)
|
||||
})
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
|
||||
# A component with multiple positions must be connected to a component with an equal number of positions
|
||||
term_a_positions = getattr(self.termination_a, 'positions', 1)
|
||||
term_b_positions = getattr(self.termination_b, 'positions', 1)
|
||||
if term_a_positions != term_b_positions:
|
||||
raise ValidationError(
|
||||
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
|
||||
self.termination_a, term_a_positions, self.termination_b, term_b_positions
|
||||
)
|
||||
)
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and self.length_unit is None:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
|
||||
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
|
||||
if hasattr(self.termination_a, 'device'):
|
||||
self._termination_a_device = self.termination_a.device
|
||||
if hasattr(self.termination_b, 'device'):
|
||||
self._termination_b_device = self.termination_b.device
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
||||
self.termination_a_id,
|
||||
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
|
||||
self.termination_b_id,
|
||||
self.get_type_display(),
|
||||
self.get_status_display(),
|
||||
self.label,
|
||||
self.color,
|
||||
self.length,
|
||||
self.length_unit,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return 'success' if self.status else 'info'
|
||||
|
||||
def get_compatible_types(self):
|
||||
"""
|
||||
Return all termination types compatible with termination A.
|
||||
"""
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
|
||||
def get_path_endpoints(self):
|
||||
"""
|
||||
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
|
||||
None.
|
||||
"""
|
||||
a_path = self.termination_b.trace()
|
||||
b_path = self.termination_a.trace()
|
||||
|
||||
# Determine overall path status (connected or planned)
|
||||
if self.status == CONNECTION_STATUS_PLANNED:
|
||||
path_status = CONNECTION_STATUS_PLANNED
|
||||
else:
|
||||
path_status = CONNECTION_STATUS_CONNECTED
|
||||
for segment in a_path[1:] + b_path[1:]:
|
||||
if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED:
|
||||
path_status = CONNECTION_STATUS_PLANNED
|
||||
break
|
||||
|
||||
a_endpoint = a_path[-1][2]
|
||||
b_endpoint = b_path[-1][2]
|
||||
|
||||
return a_endpoint, b_endpoint, path_status
|
||||
|
||||
|
||||
#
|
||||
# Power
|
||||
#
|
||||
@@ -3159,3 +2934,260 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
class Cable(ChangeLoggedModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
"""
|
||||
termination_a_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_a_id = models.PositiveIntegerField()
|
||||
termination_a = GenericForeignKey(
|
||||
ct_field='termination_a_type',
|
||||
fk_field='termination_a_id'
|
||||
)
|
||||
termination_b_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
termination_b_id = models.PositiveIntegerField()
|
||||
termination_b = GenericForeignKey(
|
||||
ct_field='termination_b_type',
|
||||
fk_field='termination_b_id'
|
||||
)
|
||||
type = models.PositiveSmallIntegerField(
|
||||
choices=CABLE_TYPE_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
default=CONNECTION_STATUS_CONNECTED
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
color = ColorField(
|
||||
blank=True
|
||||
)
|
||||
length = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
length_unit = models.PositiveSmallIntegerField(
|
||||
choices=CABLE_LENGTH_UNIT_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Stores the normalized length (in meters) for database ordering
|
||||
_abs_length = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
|
||||
# their associated Devices.
|
||||
_termination_a_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_termination_b_device = models.ForeignKey(
|
||||
to=Device,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
csv_headers = [
|
||||
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
|
||||
'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['pk']
|
||||
unique_together = (
|
||||
('termination_a_type', 'termination_a_id'),
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
|
||||
def __str__(self):
|
||||
return self.label or '#{}'.format(self._pk)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that termination A exists
|
||||
if not hasattr(self, 'termination_a_type'):
|
||||
raise ValidationError('Termination A type has not been specified')
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
|
||||
})
|
||||
|
||||
# Validate that termination B exists
|
||||
if not hasattr(self, 'termination_b_type'):
|
||||
raise ValidationError('Termination B type has not been specified')
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# Validate interface types
|
||||
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_a.get_type_display()
|
||||
)
|
||||
})
|
||||
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
|
||||
self.termination_b.get_type_display()
|
||||
)
|
||||
})
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
|
||||
# A component with multiple positions must be connected to a component with an equal number of positions
|
||||
term_a_positions = getattr(self.termination_a, 'positions', 1)
|
||||
term_b_positions = getattr(self.termination_b, 'positions', 1)
|
||||
if term_a_positions != term_b_positions:
|
||||
raise ValidationError(
|
||||
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
|
||||
self.termination_a, term_a_positions, self.termination_b, term_b_positions
|
||||
)
|
||||
)
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and self.length_unit is None:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
|
||||
if hasattr(self.termination_a, 'device'):
|
||||
self._termination_a_device = self.termination_a.device
|
||||
if hasattr(self.termination_b, 'device'):
|
||||
self._termination_b_device = self.termination_b.device
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
self._pk = self.pk
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
||||
self.termination_a_id,
|
||||
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
|
||||
self.termination_b_id,
|
||||
self.get_type_display(),
|
||||
self.get_status_display(),
|
||||
self.label,
|
||||
self.color,
|
||||
self.length,
|
||||
self.length_unit,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return 'success' if self.status else 'info'
|
||||
|
||||
def get_compatible_types(self):
|
||||
"""
|
||||
Return all termination types compatible with termination A.
|
||||
"""
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
|
||||
def get_path_endpoints(self):
|
||||
"""
|
||||
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
|
||||
None.
|
||||
"""
|
||||
a_path = self.termination_b.trace()
|
||||
b_path = self.termination_a.trace()
|
||||
|
||||
# Determine overall path status (connected or planned)
|
||||
if self.status == CONNECTION_STATUS_PLANNED:
|
||||
path_status = CONNECTION_STATUS_PLANNED
|
||||
else:
|
||||
path_status = CONNECTION_STATUS_CONNECTED
|
||||
for segment in a_path[1:] + b_path[1:]:
|
||||
if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED:
|
||||
path_status = CONNECTION_STATUS_PLANNED
|
||||
break
|
||||
|
||||
a_endpoint = a_path[-1][2]
|
||||
b_endpoint = b_path[-1][2]
|
||||
|
||||
return a_endpoint, b_endpoint, path_status
|
||||
|
||||
@@ -45,7 +45,7 @@ def update_connected_endpoints(instance, **kwargs):
|
||||
|
||||
# Check if this Cable has formed a complete path. If so, update both endpoints.
|
||||
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
|
||||
if endpoint_a is not None and endpoint_b is not None:
|
||||
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
|
||||
endpoint_a.connected_endpoint = endpoint_b
|
||||
endpoint_a.connection_status = path_status
|
||||
endpoint_a.save()
|
||||
|
||||
@@ -181,8 +181,10 @@ VIRTUALCHASSIS_ACTIONS = """
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
{% if value.device %}
|
||||
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
|
||||
{% else %}
|
||||
{% elif value.circuit %}
|
||||
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
|
||||
{% elif value.power_panel %}
|
||||
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -718,7 +720,7 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Termination A'
|
||||
)
|
||||
termination_a = tables.Column(
|
||||
termination_a = tables.LinkColumn(
|
||||
accessor=Accessor('termination_a'),
|
||||
orderable=False,
|
||||
verbose_name=''
|
||||
@@ -729,7 +731,7 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Termination B'
|
||||
)
|
||||
termination_b = tables.Column(
|
||||
termination_b = tables.LinkColumn(
|
||||
accessor=Accessor('termination_b'),
|
||||
orderable=False,
|
||||
verbose_name=''
|
||||
|
||||
2401
netbox/dcim/tests/test_filters.py
Normal file
2401
netbox/dcim/tests/test_filters.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -325,9 +325,12 @@ class CableTestCase(TestCase):
|
||||
|
||||
def test_cable_deletion(self):
|
||||
"""
|
||||
When a Cable is deleted, the `cable` field on its termination points must be nullified.
|
||||
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
|
||||
should still return the PK of the string even after being nullified.
|
||||
"""
|
||||
self.cable.delete()
|
||||
self.assertIsNone(self.cable.pk)
|
||||
self.assertNotEqual(str(self.cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertIsNone(interface1.cable)
|
||||
interface2 = Interface.objects.get(pk=self.interface2.pk)
|
||||
|
||||
@@ -388,7 +388,7 @@ class RackElevationListView(PermissionRequiredMixin, View):
|
||||
'page': page,
|
||||
'total_count': total_count,
|
||||
'face_id': face_id,
|
||||
'filter_form': forms.RackFilterForm(request.GET),
|
||||
'filter_form': forms.RackElevationFilterForm(request.GET),
|
||||
})
|
||||
|
||||
|
||||
@@ -1754,10 +1754,13 @@ class CableTraceView(PermissionRequiredMixin, View):
|
||||
def get(self, request, model, pk):
|
||||
|
||||
obj = get_object_or_404(model, pk=pk)
|
||||
trace = obj.trace(follow_circuits=True)
|
||||
total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length])
|
||||
|
||||
return render(request, 'dcim/cable_trace.html', {
|
||||
'obj': obj,
|
||||
'trace': obj.trace(follow_circuits=True),
|
||||
'trace': trace,
|
||||
'total_length': total_length,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ from django.contrib import admin
|
||||
|
||||
from netbox.admin import admin_site
|
||||
from utilities.forms import LaxURLField
|
||||
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
|
||||
from .models import (
|
||||
CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, TopologyMap, Webhook,
|
||||
)
|
||||
from .reports import get_report
|
||||
|
||||
|
||||
def order_content_types(field):
|
||||
@@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
form = ExportTemplateForm
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
@admin.register(ReportResult, site=admin_site)
|
||||
class ReportResultAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'report', 'active', 'created', 'user', 'passing',
|
||||
]
|
||||
fields = [
|
||||
'report', 'user', 'passing', 'data',
|
||||
]
|
||||
list_filter = [
|
||||
'failed',
|
||||
]
|
||||
readonly_fields = fields
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def active(self, obj):
|
||||
module, report_name = obj.report.split('.')
|
||||
return True if get_report(module, report_name) else False
|
||||
active.boolean = True
|
||||
|
||||
def passing(self, obj):
|
||||
return not obj.failed
|
||||
passing.boolean = True
|
||||
|
||||
|
||||
#
|
||||
# Topology maps
|
||||
#
|
||||
|
||||
@@ -22,7 +22,9 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
|
||||
def to_internal_value(self, data):
|
||||
|
||||
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
|
||||
custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
|
||||
custom_fields = {
|
||||
field.name: field for field in CustomField.objects.filter(obj_type=content_type)
|
||||
}
|
||||
|
||||
for field_name, value in data.items():
|
||||
|
||||
@@ -107,11 +109,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance is not None:
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(obj_type=content_type)
|
||||
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(obj_type=content_type)
|
||||
if self.instance is not None:
|
||||
|
||||
# Populate CustomFieldValues for each instance from database
|
||||
try:
|
||||
@@ -120,6 +122,26 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
except TypeError:
|
||||
_populate_custom_fields(self.instance, fields)
|
||||
|
||||
else:
|
||||
|
||||
if not hasattr(self, 'initial_data'):
|
||||
self.initial_data = {}
|
||||
|
||||
# Populate default values
|
||||
if fields and 'custom_fields' not in self.initial_data:
|
||||
self.initial_data['custom_fields'] = {}
|
||||
|
||||
# Populate initial data using custom field default values
|
||||
for field in fields:
|
||||
if field.name not in self.initial_data['custom_fields'] and field.default:
|
||||
if field.type == CF_TYPE_SELECT:
|
||||
field_value = field.choices.get(value=field.default).pk
|
||||
elif field.type == CF_TYPE_BOOLEAN:
|
||||
field_value = bool(field.default)
|
||||
else:
|
||||
field_value = field.default
|
||||
self.initial_data['custom_fields'][field.name] = field_value
|
||||
|
||||
def _save_custom_fields(self, instance, custom_fields):
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
for field_name, value in custom_fields.items():
|
||||
|
||||
@@ -18,7 +18,7 @@ router.APIRootView = ExtrasRootView
|
||||
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
|
||||
|
||||
# Custom field choices
|
||||
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
|
||||
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
|
||||
|
||||
# Graphs
|
||||
router.register(r'graphs', views.GraphViewSet)
|
||||
|
||||
@@ -8,6 +8,20 @@ from .constants import *
|
||||
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilter',
|
||||
'CreatedUpdatedFilterSet',
|
||||
'CustomFieldFilter',
|
||||
'CustomFieldFilterSet',
|
||||
'ExportTemplateFilter',
|
||||
'GraphFilter',
|
||||
'LocalConfigContextFilter',
|
||||
'ObjectChangeFilter',
|
||||
'TagFilter',
|
||||
'TopologyMapFilter',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
||||
@@ -241,3 +255,24 @@ class ObjectChangeFilter(django_filters.FilterSet):
|
||||
Q(user_name__icontains=value) |
|
||||
Q(object_repr__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CreatedUpdatedFilterSet(django_filters.FilterSet):
|
||||
created = django_filters.DateFilter()
|
||||
created__gte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
created__lte = django_filters.DateFilter(
|
||||
field_name='created',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
last_updated = django_filters.DateTimeFilter()
|
||||
last_updated__gte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
last_updated__lte = django_filters.DateTimeFilter(
|
||||
field_name='last_updated',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
@@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
|
||||
CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
|
||||
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from .constants import *
|
||||
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
|
||||
@@ -52,12 +52,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
else:
|
||||
initial = None
|
||||
field = forms.NullBooleanField(
|
||||
required=cf.required, initial=initial, widget=forms.Select(choices=choices)
|
||||
required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
|
||||
)
|
||||
|
||||
# Date
|
||||
elif cf.type == CF_TYPE_DATE:
|
||||
field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
|
||||
field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
|
||||
|
||||
# Select
|
||||
elif cf.type == CF_TYPE_SELECT:
|
||||
@@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
default_choice = cf.choices.get(value=initial).pk
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
|
||||
field = forms.TypedChoiceField(
|
||||
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
|
||||
)
|
||||
|
||||
# URL
|
||||
elif cf.type == CF_TYPE_URL:
|
||||
@@ -388,16 +390,12 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
time_after = forms.DateTimeField(
|
||||
label='After',
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
|
||||
)
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
time_before = forms.DateTimeField(
|
||||
label='Before',
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
|
||||
)
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
action = forms.ChoiceField(
|
||||
choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),
|
||||
|
||||
@@ -12,12 +12,11 @@ from django.db.models import F, Q
|
||||
from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.urls import reverse
|
||||
from jinja2 import Environment
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CONNECTED
|
||||
from utilities.fields import ColorField
|
||||
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
|
||||
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict, render_jinja2
|
||||
from .constants import *
|
||||
from .querysets import ConfigContextQuerySet
|
||||
|
||||
@@ -502,8 +501,7 @@ class ExportTemplate(models.Model):
|
||||
output = template.render(Context(context))
|
||||
|
||||
elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
|
||||
template = Environment().from_string(source=self.template_code)
|
||||
output = template.render(**context)
|
||||
output = render_jinja2(self.template_code, context)
|
||||
|
||||
else:
|
||||
return None
|
||||
@@ -917,6 +915,13 @@ class ReportResult(models.Model):
|
||||
class Meta:
|
||||
ordering = ['report']
|
||||
|
||||
def __str__(self):
|
||||
return "{} {} at {}".format(
|
||||
self.report,
|
||||
"passed" if not self.failed else "failed",
|
||||
self.created
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
|
||||
@@ -235,6 +235,9 @@ class BaseScript:
|
||||
# Initiate the log
|
||||
self.log = []
|
||||
|
||||
# Declare the placeholder for the current request
|
||||
self.request = None
|
||||
|
||||
# Grab some info about the script
|
||||
self.filename = inspect.getfile(self.__class__)
|
||||
self.source = inspect.getsource(self.__class__)
|
||||
@@ -260,12 +263,12 @@ class BaseScript:
|
||||
def run(self, data):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
|
||||
def as_form(self, data=None, files=None):
|
||||
def as_form(self, data=None, files=None, initial=None):
|
||||
"""
|
||||
Return a Django form suitable for populating the context data required to run this Script.
|
||||
"""
|
||||
vars = self._get_vars()
|
||||
form = ScriptForm(vars, data, files, commit_default=getattr(self.Meta, 'commit_default', True))
|
||||
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True))
|
||||
|
||||
return form
|
||||
|
||||
@@ -337,7 +340,7 @@ def is_variable(obj):
|
||||
return isinstance(obj, ScriptVariable)
|
||||
|
||||
|
||||
def run_script(script, data, files, commit=True):
|
||||
def run_script(script, data, request, commit=True):
|
||||
"""
|
||||
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
|
||||
exists outside of the Script class to ensure it cannot be overridden by a script author.
|
||||
@@ -347,9 +350,13 @@ def run_script(script, data, files, commit=True):
|
||||
end_time = None
|
||||
|
||||
# Add files to form data
|
||||
files = request.FILES
|
||||
for field_name, fileobj in files.items():
|
||||
data[field_name] = fileobj
|
||||
|
||||
# Add the current request as a property of the script
|
||||
script.request = request
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
start_time = time.time()
|
||||
|
||||
@@ -3,9 +3,9 @@ from collections import OrderedDict
|
||||
from django import template
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
from jinja2 import Environment
|
||||
|
||||
from extras.models import CustomLink
|
||||
from utilities.utils import render_jinja2
|
||||
|
||||
|
||||
register = template.Library()
|
||||
@@ -46,12 +46,17 @@ def custom_links(obj):
|
||||
|
||||
# Add non-grouped links
|
||||
else:
|
||||
text_rendered = Environment().from_string(source=cl.text).render(**context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
cl.url, link_target, cl.button_class, text_rendered
|
||||
)
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.text, context)
|
||||
if text_rendered:
|
||||
link_rendered = render_jinja2(cl.url, context)
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
link_rendered, link_target, cl.button_class, text_rendered
|
||||
)
|
||||
except Exception as e:
|
||||
template_code += '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">' \
|
||||
'<i class="fa fa-warning"></i> {}</a>\n'.format(e, cl.name)
|
||||
|
||||
# Add grouped links to template
|
||||
for group, links in group_names.items():
|
||||
@@ -59,11 +64,18 @@ def custom_links(obj):
|
||||
links_rendered = []
|
||||
|
||||
for cl in links:
|
||||
text_rendered = Environment().from_string(source=cl.text).render(**context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.text, context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
link_rendered = render_jinja2(cl.url, context)
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(link_rendered, link_target, text_rendered)
|
||||
)
|
||||
except Exception as e:
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(cl.url, link_target, cl.text)
|
||||
'<li><a disabled="disabled" title="{}"><span class="text-muted">'
|
||||
'<i class="fa fa-warning"></i> {}</span></a></li>'.format(e, cl.name)
|
||||
)
|
||||
|
||||
if links_rendered:
|
||||
@@ -71,7 +83,4 @@ def custom_links(obj):
|
||||
links[0].button_class, group, ''.join(links_rendered)
|
||||
)
|
||||
|
||||
# Render template
|
||||
rendered = Environment().from_string(source=template_code).render(**context)
|
||||
|
||||
return mark_safe(rendered)
|
||||
return mark_safe(template_code)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
|
||||
from extras.constants import GRAPH_TYPE_SITE
|
||||
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -520,3 +523,68 @@ class ConfigContextTest(APITestCase):
|
||||
configcontext6.sites.add(site2)
|
||||
rendered_context = device.get_config_context()
|
||||
self.assertEqual(rendered_context['bar'], 456)
|
||||
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
|
||||
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
|
||||
self.rack1 = Rack.objects.create(
|
||||
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
|
||||
)
|
||||
self.rack2 = Rack.objects.create(
|
||||
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
|
||||
)
|
||||
|
||||
# change the created and last_updated of one
|
||||
Rack.objects.filter(pk=self.rack2.pk).update(
|
||||
last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
|
||||
created=datetime.datetime(2001, 2, 3)
|
||||
)
|
||||
|
||||
def test_get_rack_created(self):
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
def test_get_rack_created_gte(self):
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
|
||||
|
||||
def test_get_rack_created_lte(self):
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
def test_get_rack_last_updated(self):
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
def test_get_rack_last_updated_gte(self):
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
|
||||
|
||||
def test_get_rack_last_updated_lte(self):
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
@@ -301,6 +301,40 @@ class CustomFieldAPITest(APITestCase):
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_select)
|
||||
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
|
||||
|
||||
def test_set_custom_field_defaults(self):
|
||||
"""
|
||||
Create a new object with no custom field data. Custom field values should be created using the custom fields'
|
||||
default values.
|
||||
"""
|
||||
CUSTOM_FIELD_DEFAULTS = {
|
||||
'magic_word': 'foobar',
|
||||
'magic_number': '123',
|
||||
'is_magic': 'true',
|
||||
'magic_date': '2019-12-13',
|
||||
'magic_url': 'http://example.com/',
|
||||
'magic_choice': self.cf_select_choice1.value,
|
||||
}
|
||||
|
||||
# Update CustomFields to set default values
|
||||
for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
|
||||
CustomField.objects.filter(name=field_name).update(default=default_value)
|
||||
|
||||
data = {
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
|
||||
self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
|
||||
self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
|
||||
self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
|
||||
self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
|
||||
self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
|
||||
|
||||
|
||||
class CustomFieldChoiceAPITest(APITestCase):
|
||||
def setUp(self):
|
||||
|
||||
181
netbox/extras/tests/test_filters.py
Normal file
181
netbox/extras/tests/test_filters.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from extras.constants import *
|
||||
from extras.filters import *
|
||||
from extras.models import ConfigContext, ExportTemplate, Graph
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
|
||||
class GraphTestCase(TestCase):
|
||||
queryset = Graph.objects.all()
|
||||
filterset = GraphFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
graphs = (
|
||||
Graph(name='Graph 1', type=GRAPH_TYPE_DEVICE, source='http://example.com/1'),
|
||||
Graph(name='Graph 2', type=GRAPH_TYPE_INTERFACE, source='http://example.com/2'),
|
||||
Graph(name='Graph 3', type=GRAPH_TYPE_SITE, source='http://example.com/3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': 'Graph 1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_type(self):
|
||||
params = {'type': GRAPH_TYPE_DEVICE}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ExportTemplateTestCase(TestCase):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
filterset = ExportTemplateFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||
|
||||
export_templates = (
|
||||
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_language=TEMPLATE_LANGUAGE_DJANGO, template_code='TESTING'),
|
||||
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_language=TEMPLATE_LANGUAGE_JINJA2, template_code='TESTING'),
|
||||
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_language=TEMPLATE_LANGUAGE_JINJA2, template_code='TESTING'),
|
||||
)
|
||||
ExportTemplate.objects.bulk_create(export_templates)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': 'Export Template 1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type': ContentType.objects.get(model='site').pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_template_language(self):
|
||||
params = {'template_language': TEMPLATE_LANGUAGE_JINJA2}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConfigContextTestCase(TestCase):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = ConfigContextFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1'),
|
||||
Site(name='Test Site 2', slug='test-site-2'),
|
||||
Site(name='Test Site 3', slug='test-site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
platforms = (
|
||||
Platform(name='Platform 1', slug='platform-1'),
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
Platform(name='Platform 3', slug='platform-3'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
tenant_groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
|
||||
)
|
||||
TenantGroup.objects.bulk_create(tenant_groups)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
for i in range(0, 3):
|
||||
is_active = bool(i % 2)
|
||||
c = ConfigContext.objects.create(
|
||||
name='Config Context {}'.format(i + 1),
|
||||
is_active=is_active,
|
||||
data='{"foo": 123}'
|
||||
)
|
||||
c.regions.set([regions[i]])
|
||||
c.sites.set([sites[i]])
|
||||
c.roles.set([device_roles[i]])
|
||||
c.platforms.set([platforms[i]])
|
||||
c.tenant_groups.set([tenant_groups[i]])
|
||||
c.tenants.set([tenants[i]])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': 'Config Context 1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_is_active(self):
|
||||
params = {'is_active': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'is_active': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
device_roles = DeviceRole.objects.all()[:2]
|
||||
params = {'role_id': [device_roles[0].pk, device_roles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'role': [device_roles[0].slug, device_roles[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_platform(self):
|
||||
platforms = Platform.objects.all()[:2]
|
||||
params = {'platform_id': [platforms[0].pk, platforms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'platform': [platforms[0].slug, platforms[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant_group(self):
|
||||
tenant_groups = TenantGroup.objects.all()[:2]
|
||||
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant_(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
# TODO: ObjectChangeFilter test
|
||||
@@ -392,7 +392,7 @@ class ScriptView(PermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
|
||||
script = self._get_script(module, name)
|
||||
form = script.as_form()
|
||||
form = script.as_form(initial=request.GET)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
@@ -413,7 +413,7 @@ class ScriptView(PermissionRequiredMixin, View):
|
||||
|
||||
if form.is_valid():
|
||||
commit = form.cleaned_data.pop('_commit')
|
||||
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
|
||||
output, execution_time = run_script(script, form.cleaned_data, request, commit)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
|
||||
@@ -60,5 +60,5 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
|
||||
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
|
||||
else:
|
||||
raise requests.exceptions.RequestException(
|
||||
"Status {} returned, webhook FAILED to process.".format(response.status_code)
|
||||
"Status {} returned with content '{}', webhook FAILED to process.".format(response.status_code, response.content)
|
||||
)
|
||||
|
||||
@@ -4,16 +4,29 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import Site, Device, Interface
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from dcim.models import Device, Interface, Region, Site
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
|
||||
from virtualization.models import VirtualMachine
|
||||
from .constants import *
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
__all__ = (
|
||||
'AggregateFilter',
|
||||
'IPAddressFilter',
|
||||
'PrefixFilter',
|
||||
'RIRFilter',
|
||||
'RoleFilter',
|
||||
'ServiceFilter',
|
||||
'VLANFilter',
|
||||
'VLANGroupFilter',
|
||||
'VRFFilter',
|
||||
)
|
||||
|
||||
|
||||
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -49,7 +62,7 @@ class RIRFilter(NameSlugSearchFilterSet):
|
||||
fields = ['name', 'slug', 'is_private']
|
||||
|
||||
|
||||
class AggregateFilter(CustomFieldFilterSet):
|
||||
class AggregateFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -110,7 +123,7 @@ class RoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -149,6 +162,17 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
to_field_name='rd',
|
||||
label='VRF (RD)',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
@@ -247,7 +271,7 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
return queryset.filter(prefix__net_mask_length=value)
|
||||
|
||||
|
||||
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -309,6 +333,10 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
queryset=Interface.objects.all(),
|
||||
label='Interface (ID)',
|
||||
)
|
||||
assigned_to_interface = django_filters.BooleanFilter(
|
||||
method='_assigned_to_interface',
|
||||
label='Is assigned to an interface',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=IPADDRESS_STATUS_CHOICES,
|
||||
null_value=None
|
||||
@@ -366,8 +394,22 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
def _assigned_to_interface(self, queryset, name, value):
|
||||
return queryset.exclude(interface__isnull=value)
|
||||
|
||||
|
||||
class VLANGroupFilter(NameSlugSearchFilterSet):
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
@@ -384,7 +426,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -393,6 +435,17 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
@@ -444,7 +497,7 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ServiceFilter(django_filters.FilterSet):
|
||||
class ServiceFilter(CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@@ -3,14 +3,14 @@ from django.core.exceptions import MultipleObjectsReturned
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from taggit.forms import TagField
|
||||
|
||||
from dcim.models import Site, Rack, Device, Interface
|
||||
from dcim.models import Device, Interface, Rack, Region, Site
|
||||
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
|
||||
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, SlugField,
|
||||
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
|
||||
CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
|
||||
SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from .constants import *
|
||||
@@ -156,12 +156,12 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
|
||||
help_texts = {
|
||||
'prefix': "IPv4 or IPv6 network",
|
||||
'rir': "Regional Internet Registry responsible for this prefix",
|
||||
'date_added': "Format: YYYY-MM-DD",
|
||||
}
|
||||
widgets = {
|
||||
'rir': APISelect(
|
||||
api_url="/api/ipam/rirs/"
|
||||
)
|
||||
),
|
||||
'date_added': DatePicker(),
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,9 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
|
||||
nullable_fields = [
|
||||
'date_added', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'date_added': DatePicker(),
|
||||
}
|
||||
|
||||
|
||||
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
@@ -240,7 +243,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = [
|
||||
'name', 'slug',
|
||||
'name', 'slug', 'weight',
|
||||
]
|
||||
|
||||
|
||||
@@ -489,8 +492,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
||||
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = Prefix
|
||||
field_order = [
|
||||
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant',
|
||||
'is_pool', 'expand',
|
||||
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'region', 'site', 'role', 'tenant_group',
|
||||
'tenant', 'is_pool', 'expand',
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -531,6 +534,18 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -918,7 +933,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
|
||||
|
||||
|
||||
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
vrf = forms.ModelChoiceField(
|
||||
vrf_id = forms.ModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label='VRF',
|
||||
@@ -927,15 +942,17 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
api_url="/api/ipam/vrfs/"
|
||||
)
|
||||
)
|
||||
address = forms.CharField(
|
||||
label='IP Address'
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search',
|
||||
)
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = IPAddress
|
||||
field_order = [
|
||||
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant',
|
||||
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'assigned_to_interface', 'tenant_group',
|
||||
'tenant',
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -981,6 +998,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
assigned_to_interface = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Assigned to an interface',
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1023,6 +1047,18 @@ class VLANGroupCSVForm(forms.ModelForm):
|
||||
|
||||
|
||||
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region',
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -1204,11 +1240,24 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = VLAN
|
||||
field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region',
|
||||
'group_id': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -1250,6 +1299,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class ServiceForm(BootstrapMixin, CustomFieldForm):
|
||||
port = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=65535
|
||||
)
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -14,6 +14,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='ipaddress',
|
||||
name='dns_name',
|
||||
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]),
|
||||
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', regex='^[0-9A-Za-z._-]+$')]),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -177,6 +177,12 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
|
||||
# Clear host bits from prefix
|
||||
self.prefix = self.prefix.cidr
|
||||
|
||||
# /0 masks are not acceptable
|
||||
if self.prefix.prefixlen == 0:
|
||||
raise ValidationError({
|
||||
'prefix': "Cannot create aggregate with /0 mask."
|
||||
})
|
||||
|
||||
# Ensure that the aggregate being added is not covered by an existing aggregate
|
||||
covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix))
|
||||
if self.pk:
|
||||
@@ -347,6 +353,12 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
if self.prefix:
|
||||
|
||||
# /0 masks are not acceptable
|
||||
if self.prefix.prefixlen == 0:
|
||||
raise ValidationError({
|
||||
'prefix': "Cannot create prefix with /0 mask."
|
||||
})
|
||||
|
||||
# Disallow host masks
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
|
||||
raise ValidationError({
|
||||
@@ -622,6 +634,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
if self.address:
|
||||
|
||||
# /0 masks are not acceptable
|
||||
if self.address.prefixlen == 0:
|
||||
raise ValidationError({
|
||||
'address': "Cannot create IP address with /0 mask."
|
||||
})
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
|
||||
self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
@@ -85,7 +85,11 @@ IPADDRESS_LINK = """
|
||||
"""
|
||||
|
||||
IPADDRESS_ASSIGN_LINK = """
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
{% if request.GET %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
IPADDRESS_PARENT = """
|
||||
@@ -292,7 +296,7 @@ class RoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'weight', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -369,7 +373,7 @@ class IPAddressAssignTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
orderable = False
|
||||
|
||||
|
||||
|
||||
645
netbox/ipam/tests/test_filters.py
Normal file
645
netbox/ipam/tests/test_filters.py
Normal file
@@ -0,0 +1,645 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site
|
||||
from ipam.constants import *
|
||||
from ipam.filters import *
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from virtualization.models import Cluster, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
class VRFTestCase(TestCase):
|
||||
queryset = VRF.objects.all()
|
||||
filterset = VRFFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
vrfs = (
|
||||
VRF(name='VRF 1', rd='65000:100', enforce_unique=False),
|
||||
VRF(name='VRF 2', rd='65000:200', enforce_unique=False),
|
||||
VRF(name='VRF 3', rd='65000:300', enforce_unique=False),
|
||||
VRF(name='VRF 4', rd='65000:400', enforce_unique=True),
|
||||
VRF(name='VRF 5', rd='65000:500', enforce_unique=True),
|
||||
VRF(name='VRF 6', rd='65000:600', enforce_unique=True),
|
||||
)
|
||||
VRF.objects.bulk_create(vrfs)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['VRF 1', 'VRF 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rd(self):
|
||||
params = {'rd': ['65000:100', '65000:200']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enforce_unique(self):
|
||||
params = {'enforce_unique': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'enforce_unique': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class RIRTestCase(TestCase):
|
||||
queryset = RIR.objects.all()
|
||||
filterset = RIRFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
rirs = (
|
||||
RIR(name='RIR 1', slug='rir-1', is_private=False),
|
||||
RIR(name='RIR 2', slug='rir-2', is_private=False),
|
||||
RIR(name='RIR 3', slug='rir-3', is_private=False),
|
||||
RIR(name='RIR 4', slug='rir-4', is_private=True),
|
||||
RIR(name='RIR 5', slug='rir-5', is_private=True),
|
||||
RIR(name='RIR 6', slug='rir-6', is_private=True),
|
||||
)
|
||||
RIR.objects.bulk_create(rirs)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['RIR 1', 'RIR 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['rir-1', 'rir-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_is_private(self):
|
||||
params = {'is_private': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'is_private': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class AggregateTestCase(TestCase):
|
||||
queryset = Aggregate.objects.all()
|
||||
filterset = AggregateFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
rirs = (
|
||||
RIR(name='RIR 1', slug='rir-1'),
|
||||
RIR(name='RIR 2', slug='rir-2'),
|
||||
RIR(name='RIR 3', slug='rir-3'),
|
||||
)
|
||||
RIR.objects.bulk_create(rirs)
|
||||
|
||||
aggregates = (
|
||||
Aggregate(family=4, prefix='10.1.0.0/16', rir=rirs[0], date_added='2020-01-01'),
|
||||
Aggregate(family=4, prefix='10.2.0.0/16', rir=rirs[0], date_added='2020-01-02'),
|
||||
Aggregate(family=4, prefix='10.3.0.0/16', rir=rirs[1], date_added='2020-01-03'),
|
||||
Aggregate(family=6, prefix='2001:db8:1::/48', rir=rirs[1], date_added='2020-01-04'),
|
||||
Aggregate(family=6, prefix='2001:db8:2::/48', rir=rirs[2], date_added='2020-01-05'),
|
||||
Aggregate(family=6, prefix='2001:db8:3::/48', rir=rirs[2], date_added='2020-01-06'),
|
||||
)
|
||||
Aggregate.objects.bulk_create(aggregates)
|
||||
|
||||
def test_family(self):
|
||||
params = {'family': '4'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_date_added(self):
|
||||
params = {'date_added': ['2020-01-01', '2020-01-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Test for multiple values
|
||||
def test_prefix(self):
|
||||
params = {'prefix': '10.1.0.0/16'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_rir(self):
|
||||
rirs = RIR.objects.all()[:2]
|
||||
params = {'rir_id': [rirs[0].pk, rirs[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'rir': [rirs[0].slug, rirs[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class RoleTestCase(TestCase):
|
||||
queryset = Role.objects.all()
|
||||
filterset = RoleFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
roles = (
|
||||
Role(name='Role 1', slug='role-1'),
|
||||
Role(name='Role 2', slug='role-2'),
|
||||
Role(name='Role 3', slug='role-3'),
|
||||
)
|
||||
Role.objects.bulk_create(roles)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Role 1', 'Role 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['role-1', 'role-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class PrefixTestCase(TestCase):
|
||||
queryset = Prefix.objects.all()
|
||||
filterset = PrefixFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
vrfs = (
|
||||
VRF(name='VRF 1', rd='65000:100'),
|
||||
VRF(name='VRF 2', rd='65000:200'),
|
||||
VRF(name='VRF 3', rd='65000:300'),
|
||||
)
|
||||
VRF.objects.bulk_create(vrfs)
|
||||
|
||||
vlans = (
|
||||
VLAN(vid=1, name='VLAN 1'),
|
||||
VLAN(vid=2, name='VLAN 2'),
|
||||
VLAN(vid=3, name='VLAN 3'),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
roles = (
|
||||
Role(name='Role 1', slug='role-1'),
|
||||
Role(name='Role 2', slug='role-2'),
|
||||
Role(name='Role 3', slug='role-3'),
|
||||
)
|
||||
Role.objects.bulk_create(roles)
|
||||
|
||||
prefixes = (
|
||||
Prefix(family=4, prefix='10.0.0.0/24', site=None, vrf=None, vlan=None, role=None, is_pool=True),
|
||||
Prefix(family=4, prefix='10.0.1.0/24', site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
|
||||
Prefix(family=4, prefix='10.0.2.0/24', site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PREFIX_STATUS_DEPRECATED),
|
||||
Prefix(family=4, prefix='10.0.3.0/24', site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PREFIX_STATUS_RESERVED),
|
||||
Prefix(family=6, prefix='2001:db8::/64', site=None, vrf=None, vlan=None, role=None, is_pool=True),
|
||||
Prefix(family=6, prefix='2001:db8:0:1::/64', site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
|
||||
Prefix(family=6, prefix='2001:db8:0:2::/64', site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PREFIX_STATUS_DEPRECATED),
|
||||
Prefix(family=6, prefix='2001:db8:0:3::/64', site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PREFIX_STATUS_RESERVED),
|
||||
Prefix(family=4, prefix='10.0.0.0/16'),
|
||||
Prefix(family=6, prefix='2001:db8::/32'),
|
||||
)
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
|
||||
def test_family(self):
|
||||
params = {'family': '6'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_is_pool(self):
|
||||
params = {'is_pool': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'is_pool': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_within(self):
|
||||
params = {'within': '10.0.0.0/16'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_within_include(self):
|
||||
params = {'within_include': '10.0.0.0/16'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_contains(self):
|
||||
params = {'contains': '10.0.1.0/24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'contains': '2001:db8:0:1::/64'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vlan(self):
|
||||
vlans = VLAN.objects.all()[:2]
|
||||
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
# TODO: Test for multiple values
|
||||
params = {'vlan_vid': vlans[0].vid}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
roles = Role.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'role': [roles[0].slug, roles[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class IPAddressTestCase(TestCase):
|
||||
queryset = IPAddress.objects.all()
|
||||
filterset = IPAddressFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
vrfs = (
|
||||
VRF(name='VRF 1', rd='65000:100'),
|
||||
VRF(name='VRF 2', rd='65000:200'),
|
||||
VRF(name='VRF 3', rd='65000:300'),
|
||||
)
|
||||
VRF.objects.bulk_create(vrfs)
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
devices = (
|
||||
Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
|
||||
Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
|
||||
Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
|
||||
|
||||
virtual_machines = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=cluster),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=cluster),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1'),
|
||||
Interface(device=devices[1], name='Interface 2'),
|
||||
Interface(device=devices[2], name='Interface 3'),
|
||||
Interface(virtual_machine=virtual_machines[0], name='Interface 1'),
|
||||
Interface(virtual_machine=virtual_machines[1], name='Interface 2'),
|
||||
Interface(virtual_machine=virtual_machines[2], name='Interface 3'),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
ipaddresses = (
|
||||
IPAddress(family=4, address='10.0.0.1/24', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-a'),
|
||||
IPAddress(family=4, address='10.0.0.2/24', vrf=vrfs[0], interface=interfaces[0], status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-b'),
|
||||
IPAddress(family=4, address='10.0.0.3/24', vrf=vrfs[1], interface=interfaces[1], status=IPADDRESS_STATUS_RESERVED, role=IPADDRESS_ROLE_VIP, dns_name='ipaddress-c'),
|
||||
IPAddress(family=4, address='10.0.0.4/24', vrf=vrfs[2], interface=interfaces[2], status=IPADDRESS_STATUS_DEPRECATED, role=IPADDRESS_ROLE_SECONDARY, dns_name='ipaddress-d'),
|
||||
IPAddress(family=6, address='2001:db8::1/64', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-a'),
|
||||
IPAddress(family=6, address='2001:db8::2/64', vrf=vrfs[0], interface=interfaces[3], status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-b'),
|
||||
IPAddress(family=6, address='2001:db8::3/64', vrf=vrfs[1], interface=interfaces[4], status=IPADDRESS_STATUS_RESERVED, role=IPADDRESS_ROLE_VIP, dns_name='ipaddress-c'),
|
||||
IPAddress(family=6, address='2001:db8::4/64', vrf=vrfs[2], interface=interfaces[5], status=IPADDRESS_STATUS_DEPRECATED, role=IPADDRESS_ROLE_SECONDARY, dns_name='ipaddress-d'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
|
||||
def test_family(self):
|
||||
params = {'family': '6'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_dns_name(self):
|
||||
params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_parent(self):
|
||||
params = {'parent': '10.0.0.0/24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'parent': '2001:db8::/64'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def filter_address(self):
|
||||
# Check IPv4 and IPv6, with and without a mask
|
||||
params = {'address': '10.0.0.1/24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'address': '10.0.0.1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'address': '2001:db8::1/64'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'address': '2001:db8::1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
# TODO: Test for multiple values
|
||||
def test_device(self):
|
||||
device = Device.objects.first()
|
||||
params = {'device_id': device.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'device': device.name}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_virtual_machine(self):
|
||||
vms = VirtualMachine.objects.all()[:2]
|
||||
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_interface(self):
|
||||
interfaces = Interface.objects.all()[:2]
|
||||
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'interface': ['Interface 1', 'Interface 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_assigned_to_interface(self):
|
||||
params = {'assigned_to_interface': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'assigned_to_interface': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_role(self):
|
||||
params = {'role': [IPADDRESS_ROLE_SECONDARY, IPADDRESS_ROLE_VIP]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class VLANGroupTestCase(TestCase):
|
||||
queryset = VLANGroup.objects.all()
|
||||
filterset = VLANGroupFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
vlan_groups = (
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
|
||||
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2]),
|
||||
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None),
|
||||
)
|
||||
VLANGroup.objects.bulk_create(vlan_groups)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['VLAN Group 1', 'VLAN Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['vlan-group-1', 'vlan-group-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class VLANTestCase(TestCase):
|
||||
queryset = VLAN.objects.all()
|
||||
filterset = VLANFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
roles = (
|
||||
Role(name='Role 1', slug='role-1'),
|
||||
Role(name='Role 2', slug='role-2'),
|
||||
Role(name='Role 3', slug='role-3'),
|
||||
)
|
||||
Role.objects.bulk_create(roles)
|
||||
|
||||
groups = (
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
|
||||
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=None),
|
||||
)
|
||||
VLANGroup.objects.bulk_create(groups)
|
||||
|
||||
vlans = (
|
||||
VLAN(vid=101, name='VLAN 101', site=sites[0], group=groups[0], role=roles[0], status=VLAN_STATUS_ACTIVE),
|
||||
VLAN(vid=102, name='VLAN 102', site=sites[0], group=groups[0], role=roles[0], status=VLAN_STATUS_ACTIVE),
|
||||
VLAN(vid=201, name='VLAN 201', site=sites[1], group=groups[1], role=roles[1], status=VLAN_STATUS_DEPRECATED),
|
||||
VLAN(vid=202, name='VLAN 202', site=sites[1], group=groups[1], role=roles[1], status=VLAN_STATUS_DEPRECATED),
|
||||
VLAN(vid=301, name='VLAN 301', site=sites[2], group=groups[2], role=roles[2], status=VLAN_STATUS_RESERVED),
|
||||
VLAN(vid=302, name='VLAN 302', site=sites[2], group=groups[2], role=roles[2], status=VLAN_STATUS_RESERVED),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['VLAN 101', 'VLAN 102']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rd(self):
|
||||
params = {'vid': ['101', '201', '301']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_group(self):
|
||||
groups = VLANGroup.objects.all()[:2]
|
||||
params = {'group_id': [groups[0].pk, groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'group': [groups[0].slug, groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_role(self):
|
||||
roles = Role.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'role': [roles[0].slug, roles[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [VLAN_STATUS_ACTIVE, VLAN_STATUS_DEPRECATED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class ServiceTestCase(TestCase):
|
||||
queryset = Service.objects.all()
|
||||
filterset = ServiceFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
devices = (
|
||||
Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
|
||||
Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
|
||||
Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
|
||||
|
||||
virtual_machines = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=cluster),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=cluster),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
|
||||
services = (
|
||||
Service(device=devices[0], name='Service 1', protocol=IP_PROTOCOL_TCP, port=1001),
|
||||
Service(device=devices[1], name='Service 2', protocol=IP_PROTOCOL_TCP, port=1002),
|
||||
Service(device=devices[2], name='Service 3', protocol=IP_PROTOCOL_UDP, port=1003),
|
||||
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=IP_PROTOCOL_TCP, port=2001),
|
||||
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=IP_PROTOCOL_TCP, port=2002),
|
||||
Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=IP_PROTOCOL_UDP, port=2003),
|
||||
)
|
||||
Service.objects.bulk_create(services)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:3]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Service 1', 'Service 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_protocol(self):
|
||||
params = {'protocol': IP_PROTOCOL_TCP}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_port(self):
|
||||
params = {'port': ['1001', '1002', '1003']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_virtual_machine(self):
|
||||
vms = VirtualMachine.objects.all()[:2]
|
||||
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -2,7 +2,7 @@ from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
DNSValidator = RegexValidator(
|
||||
regex='^[0-9A-Za-z.-]+$',
|
||||
message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names',
|
||||
regex='^[0-9A-Za-z._-]+$',
|
||||
message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names',
|
||||
code='invalid'
|
||||
)
|
||||
|
||||
@@ -333,7 +333,10 @@ class AggregateView(PermissionRequiredMixin, View):
|
||||
).annotate_depth(
|
||||
limit=0
|
||||
)
|
||||
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
|
||||
|
||||
# Add available prefixes to the table if requested
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
@@ -356,6 +359,7 @@ class AggregateView(PermissionRequiredMixin, View):
|
||||
'aggregate': aggregate,
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
})
|
||||
|
||||
|
||||
@@ -511,8 +515,8 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
|
||||
'site', 'vlan', 'role',
|
||||
).annotate_depth(limit=0)
|
||||
|
||||
# Annotate available prefixes
|
||||
if child_prefixes:
|
||||
# Add available prefixes to the table if requested
|
||||
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
@@ -539,6 +543,7 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
'active_tab': 'prefixes',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
})
|
||||
|
||||
|
||||
@@ -553,7 +558,10 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
|
||||
ipaddresses = prefix.get_child_ips().prefetch_related(
|
||||
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
|
||||
)
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
|
||||
|
||||
# Add available IP addresses to the table if requested
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
@@ -579,6 +587,7 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
'active_tab': 'ip-addresses',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
})
|
||||
|
||||
|
||||
@@ -677,7 +686,14 @@ class IPAddressView(PermissionRequiredMixin, View):
|
||||
).filter(
|
||||
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
|
||||
)
|
||||
related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False)
|
||||
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(related_ips_table)
|
||||
|
||||
return render(request, 'ipam/ipaddress.html', {
|
||||
'ipaddress': ipaddress,
|
||||
@@ -740,13 +756,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
addresses = IPAddress.objects.prefetch_related(
|
||||
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
|
||||
).filter(
|
||||
vrf=form.cleaned_data['vrf'],
|
||||
address__istartswith=form.cleaned_data['address'],
|
||||
)[:100] # Limit to 100 results
|
||||
table = tables.IPAddressAssignTable(queryset)
|
||||
)
|
||||
# Limit to 100 results
|
||||
addresses = filters.IPAddressFilter(request.POST, addresses).qs[:100]
|
||||
table = tables.IPAddressAssignTable(addresses)
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'form': form,
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.6.7'
|
||||
VERSION = '2.6.12'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.db.models import Count, F
|
||||
from django.db.models import Count, F, OuterRef, Subquery
|
||||
from django.shortcuts import render
|
||||
from django.views.generic import View
|
||||
from rest_framework.response import Response
|
||||
@@ -8,7 +8,7 @@ from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from circuits.filters import CircuitFilter, ProviderFilter
|
||||
from circuits.models import Circuit, Provider
|
||||
from circuits.models import Circuit, CircuitTermination, Provider
|
||||
from circuits.tables import CircuitTable, ProviderTable
|
||||
from dcim.filters import (
|
||||
CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter,
|
||||
@@ -49,9 +49,15 @@ SEARCH_TYPES = OrderedDict((
|
||||
('circuit', {
|
||||
'permission': 'circuits.view_circuit',
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant'
|
||||
).prefetch_related(
|
||||
'terminations__site'
|
||||
'type', 'provider', 'tenant', 'terminations__site'
|
||||
).annotate(
|
||||
# Annotate A/Z terminations
|
||||
a_side=Subquery(
|
||||
CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='A').values('site__name')[:1]
|
||||
),
|
||||
z_side=Subquery(
|
||||
CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='Z').values('site__name')[:1]
|
||||
),
|
||||
),
|
||||
'filter': CircuitFilter,
|
||||
'table': CircuitTable,
|
||||
@@ -116,6 +122,23 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'permission': 'virtualization.view_cluster',
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group'),
|
||||
'filter': ClusterFilter,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'permission': 'virtualization.view_virtualmachine',
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filter': VirtualMachineFilter,
|
||||
'table': VirtualMachineDetailTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
# IPAM
|
||||
('vrf', {
|
||||
'permission': 'ipam.view_vrf',
|
||||
@@ -168,23 +191,6 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'permission': 'virtualization.view_cluster',
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group'),
|
||||
'filter': ClusterFilter,
|
||||
'table': ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'permission': 'virtualization.view_virtualmachine',
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filter': VirtualMachineFilter,
|
||||
'table': VirtualMachineDetailTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
|
||||
@@ -457,6 +457,14 @@ table.report th a {
|
||||
width: 80px;
|
||||
border: 1px solid grey;
|
||||
}
|
||||
.inline-color-block {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
border: 1px solid grey;
|
||||
border-radius: .25em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
2
netbox/project-static/flatpickr-4.6.3/flatpickr.min.js
vendored
Normal file
2
netbox/project-static/flatpickr-4.6.3/flatpickr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
784
netbox/project-static/flatpickr-4.6.3/themes/dark.css
Normal file
784
netbox/project-static/flatpickr-4.6.3/themes/dark.css
Normal file
@@ -0,0 +1,784 @@
|
||||
.flatpickr-calendar {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
text-align: center;
|
||||
visibility: hidden;
|
||||
padding: 0;
|
||||
-webkit-animation: none;
|
||||
animation: none;
|
||||
direction: ltr;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
border-radius: 5px;
|
||||
position: absolute;
|
||||
width: 307.875px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-ms-touch-action: manipulation;
|
||||
touch-action: manipulation;
|
||||
background: #3f4458;
|
||||
-webkit-box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
|
||||
box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
|
||||
}
|
||||
.flatpickr-calendar.open,
|
||||
.flatpickr-calendar.inline {
|
||||
opacity: 1;
|
||||
max-height: 640px;
|
||||
visibility: visible;
|
||||
}
|
||||
.flatpickr-calendar.open {
|
||||
display: inline-block;
|
||||
z-index: 99999;
|
||||
}
|
||||
.flatpickr-calendar.animate.open {
|
||||
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.flatpickr-calendar.inline {
|
||||
display: block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
.flatpickr-calendar.static {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
}
|
||||
.flatpickr-calendar.static.open {
|
||||
z-index: 999;
|
||||
display: block;
|
||||
}
|
||||
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
|
||||
-webkit-box-shadow: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
|
||||
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
}
|
||||
.flatpickr-calendar .hasWeeks .dayContainer,
|
||||
.flatpickr-calendar .hasTime .dayContainer {
|
||||
border-bottom: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.flatpickr-calendar .hasWeeks .dayContainer {
|
||||
border-left: 0;
|
||||
}
|
||||
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
|
||||
height: 40px;
|
||||
border-top: 1px solid #20222c;
|
||||
}
|
||||
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
|
||||
height: auto;
|
||||
}
|
||||
.flatpickr-calendar:before,
|
||||
.flatpickr-calendar:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
border: solid transparent;
|
||||
content: '';
|
||||
height: 0;
|
||||
width: 0;
|
||||
left: 22px;
|
||||
}
|
||||
.flatpickr-calendar.rightMost:before,
|
||||
.flatpickr-calendar.rightMost:after {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
}
|
||||
.flatpickr-calendar:before {
|
||||
border-width: 5px;
|
||||
margin: 0 -5px;
|
||||
}
|
||||
.flatpickr-calendar:after {
|
||||
border-width: 4px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:before,
|
||||
.flatpickr-calendar.arrowTop:after {
|
||||
bottom: 100%;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:before {
|
||||
border-bottom-color: #20222c;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:after {
|
||||
border-bottom-color: #3f4458;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:before,
|
||||
.flatpickr-calendar.arrowBottom:after {
|
||||
top: 100%;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:before {
|
||||
border-top-color: #20222c;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:after {
|
||||
border-top-color: #3f4458;
|
||||
}
|
||||
.flatpickr-calendar:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.flatpickr-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.flatpickr-months {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-months .flatpickr-month {
|
||||
background: #3f4458;
|
||||
color: #fff;
|
||||
fill: #fff;
|
||||
height: 34px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 34px;
|
||||
padding: 10px;
|
||||
z-index: 3;
|
||||
color: #fff;
|
||||
fill: #fff;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
|
||||
display: none;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month i,
|
||||
.flatpickr-months .flatpickr-next-month i {
|
||||
position: relative;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
*/
|
||||
left: 0;
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
/*
|
||||
*/
|
||||
}
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
*/
|
||||
right: 0;
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
/*
|
||||
*/
|
||||
}
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
.flatpickr-months .flatpickr-prev-month:hover,
|
||||
.flatpickr-months .flatpickr-next-month:hover {
|
||||
color: #eee;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month:hover svg,
|
||||
.flatpickr-months .flatpickr-next-month:hover svg {
|
||||
fill: #f64747;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month svg,
|
||||
.flatpickr-months .flatpickr-next-month svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month svg path,
|
||||
.flatpickr-months .flatpickr-next-month svg path {
|
||||
-webkit-transition: fill 0.1s;
|
||||
transition: fill 0.1s;
|
||||
fill: inherit;
|
||||
}
|
||||
.numInputWrapper {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
.numInputWrapper input,
|
||||
.numInputWrapper span {
|
||||
display: inline-block;
|
||||
}
|
||||
.numInputWrapper input {
|
||||
width: 100%;
|
||||
}
|
||||
.numInputWrapper input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
.numInputWrapper input::-webkit-outer-spin-button,
|
||||
.numInputWrapper input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.numInputWrapper span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 14px;
|
||||
padding: 0 4px 0 2px;
|
||||
height: 50%;
|
||||
line-height: 50%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.numInputWrapper span:hover {
|
||||
background: rgba(192,187,167,0.1);
|
||||
}
|
||||
.numInputWrapper span:active {
|
||||
background: rgba(192,187,167,0.2);
|
||||
}
|
||||
.numInputWrapper span:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.numInputWrapper span.arrowUp {
|
||||
top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.numInputWrapper span.arrowUp:after {
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid rgba(255,255,255,0.6);
|
||||
top: 26%;
|
||||
}
|
||||
.numInputWrapper span.arrowDown {
|
||||
top: 50%;
|
||||
}
|
||||
.numInputWrapper span.arrowDown:after {
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(255,255,255,0.6);
|
||||
top: 40%;
|
||||
}
|
||||
.numInputWrapper span svg {
|
||||
width: inherit;
|
||||
height: auto;
|
||||
}
|
||||
.numInputWrapper span svg path {
|
||||
fill: rgba(255,255,255,0.5);
|
||||
}
|
||||
.numInputWrapper:hover {
|
||||
background: rgba(192,187,167,0.05);
|
||||
}
|
||||
.numInputWrapper:hover span {
|
||||
opacity: 1;
|
||||
}
|
||||
.flatpickr-current-month {
|
||||
font-size: 135%;
|
||||
line-height: inherit;
|
||||
font-weight: 300;
|
||||
color: inherit;
|
||||
position: absolute;
|
||||
width: 75%;
|
||||
left: 12.5%;
|
||||
padding: 7.48px 0 0 0;
|
||||
line-height: 1;
|
||||
height: 34px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
-webkit-transform: translate3d(0px, 0px, 0px);
|
||||
transform: translate3d(0px, 0px, 0px);
|
||||
}
|
||||
.flatpickr-current-month span.cur-month {
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
display: inline-block;
|
||||
margin-left: 0.5ch;
|
||||
padding: 0;
|
||||
}
|
||||
.flatpickr-current-month span.cur-month:hover {
|
||||
background: rgba(192,187,167,0.05);
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper {
|
||||
width: 6ch;
|
||||
width: 7ch\0;
|
||||
display: inline-block;
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year {
|
||||
background: transparent;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
padding: 0 0 0 0.5ch;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 300;
|
||||
line-height: inherit;
|
||||
height: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
vertical-align: initial;
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year[disabled],
|
||||
.flatpickr-current-month input.cur-year[disabled]:hover {
|
||||
font-size: 100%;
|
||||
color: rgba(255,255,255,0.5);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months {
|
||||
appearance: menulist;
|
||||
background: #3f4458;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 300;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
margin: -1px 0 0 0;
|
||||
outline: none;
|
||||
padding: 0 0 0 0.5ch;
|
||||
position: relative;
|
||||
vertical-align: initial;
|
||||
-webkit-box-sizing: border-box;
|
||||
-webkit-appearance: menulist;
|
||||
-moz-appearance: menulist;
|
||||
width: auto;
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months:active {
|
||||
outline: none;
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
|
||||
background: rgba(192,187,167,0.05);
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
|
||||
background-color: #3f4458;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
.flatpickr-weekdays {
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
}
|
||||
.flatpickr-weekdays .flatpickr-weekdaycontainer {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
span.flatpickr-weekday {
|
||||
cursor: default;
|
||||
font-size: 90%;
|
||||
background: #3f4458;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
display: block;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.dayContainer,
|
||||
.flatpickr-weeks {
|
||||
padding: 1px 0 0 0;
|
||||
}
|
||||
.flatpickr-days {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: start;
|
||||
-webkit-align-items: flex-start;
|
||||
-ms-flex-align: start;
|
||||
align-items: flex-start;
|
||||
width: 307.875px;
|
||||
}
|
||||
.flatpickr-days:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.dayContainer {
|
||||
padding: 0;
|
||||
outline: 0;
|
||||
text-align: left;
|
||||
width: 307.875px;
|
||||
min-width: 307.875px;
|
||||
max-width: 307.875px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
display: -ms-flexbox;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
-ms-flex-wrap: wrap;
|
||||
-ms-flex-pack: justify;
|
||||
-webkit-justify-content: space-around;
|
||||
justify-content: space-around;
|
||||
-webkit-transform: translate3d(0px, 0px, 0px);
|
||||
transform: translate3d(0px, 0px, 0px);
|
||||
opacity: 1;
|
||||
}
|
||||
.dayContainer + .dayContainer {
|
||||
-webkit-box-shadow: -1px 0 0 #20222c;
|
||||
box-shadow: -1px 0 0 #20222c;
|
||||
}
|
||||
.flatpickr-day {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 150px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: rgba(255,255,255,0.95);
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
width: 14.2857143%;
|
||||
-webkit-flex-basis: 14.2857143%;
|
||||
-ms-flex-preferred-size: 14.2857143%;
|
||||
flex-basis: 14.2857143%;
|
||||
max-width: 39px;
|
||||
height: 39px;
|
||||
line-height: 39px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.flatpickr-day.inRange,
|
||||
.flatpickr-day.prevMonthDay.inRange,
|
||||
.flatpickr-day.nextMonthDay.inRange,
|
||||
.flatpickr-day.today.inRange,
|
||||
.flatpickr-day.prevMonthDay.today.inRange,
|
||||
.flatpickr-day.nextMonthDay.today.inRange,
|
||||
.flatpickr-day:hover,
|
||||
.flatpickr-day.prevMonthDay:hover,
|
||||
.flatpickr-day.nextMonthDay:hover,
|
||||
.flatpickr-day:focus,
|
||||
.flatpickr-day.prevMonthDay:focus,
|
||||
.flatpickr-day.nextMonthDay:focus {
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
background: #646c8c;
|
||||
border-color: #646c8c;
|
||||
}
|
||||
.flatpickr-day.today {
|
||||
border-color: #eee;
|
||||
}
|
||||
.flatpickr-day.today:hover,
|
||||
.flatpickr-day.today:focus {
|
||||
border-color: #eee;
|
||||
background: #eee;
|
||||
color: #3f4458;
|
||||
}
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange,
|
||||
.flatpickr-day.selected.inRange,
|
||||
.flatpickr-day.startRange.inRange,
|
||||
.flatpickr-day.endRange.inRange,
|
||||
.flatpickr-day.selected:focus,
|
||||
.flatpickr-day.startRange:focus,
|
||||
.flatpickr-day.endRange:focus,
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.startRange:hover,
|
||||
.flatpickr-day.endRange:hover,
|
||||
.flatpickr-day.selected.prevMonthDay,
|
||||
.flatpickr-day.startRange.prevMonthDay,
|
||||
.flatpickr-day.endRange.prevMonthDay,
|
||||
.flatpickr-day.selected.nextMonthDay,
|
||||
.flatpickr-day.startRange.nextMonthDay,
|
||||
.flatpickr-day.endRange.nextMonthDay {
|
||||
background: #80cbc4;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
border-color: #80cbc4;
|
||||
}
|
||||
.flatpickr-day.selected.startRange,
|
||||
.flatpickr-day.startRange.startRange,
|
||||
.flatpickr-day.endRange.startRange {
|
||||
border-radius: 50px 0 0 50px;
|
||||
}
|
||||
.flatpickr-day.selected.endRange,
|
||||
.flatpickr-day.startRange.endRange,
|
||||
.flatpickr-day.endRange.endRange {
|
||||
border-radius: 0 50px 50px 0;
|
||||
}
|
||||
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
|
||||
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
|
||||
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
|
||||
-webkit-box-shadow: -10px 0 0 #80cbc4;
|
||||
box-shadow: -10px 0 0 #80cbc4;
|
||||
}
|
||||
.flatpickr-day.selected.startRange.endRange,
|
||||
.flatpickr-day.startRange.startRange.endRange,
|
||||
.flatpickr-day.endRange.startRange.endRange {
|
||||
border-radius: 50px;
|
||||
}
|
||||
.flatpickr-day.inRange {
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
|
||||
box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
|
||||
}
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.flatpickr-disabled:hover,
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay,
|
||||
.flatpickr-day.notAllowed,
|
||||
.flatpickr-day.notAllowed.prevMonthDay,
|
||||
.flatpickr-day.notAllowed.nextMonthDay {
|
||||
color: rgba(255,255,255,0.3);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.flatpickr-disabled:hover {
|
||||
cursor: not-allowed;
|
||||
color: rgba(255,255,255,0.1);
|
||||
}
|
||||
.flatpickr-day.week.selected {
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
|
||||
box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
|
||||
}
|
||||
.flatpickr-day.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
.rangeMode .flatpickr-day {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.flatpickr-weekwrapper {
|
||||
float: left;
|
||||
}
|
||||
.flatpickr-weekwrapper .flatpickr-weeks {
|
||||
padding: 0 12px;
|
||||
-webkit-box-shadow: 1px 0 0 #20222c;
|
||||
box-shadow: 1px 0 0 #20222c;
|
||||
}
|
||||
.flatpickr-weekwrapper .flatpickr-weekday {
|
||||
float: none;
|
||||
width: 100%;
|
||||
line-height: 28px;
|
||||
}
|
||||
.flatpickr-weekwrapper span.flatpickr-day,
|
||||
.flatpickr-weekwrapper span.flatpickr-day:hover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
color: rgba(255,255,255,0.3);
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
border: none;
|
||||
}
|
||||
.flatpickr-innerContainer {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.flatpickr-rContainer {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.flatpickr-time {
|
||||
text-align: center;
|
||||
outline: 0;
|
||||
display: block;
|
||||
height: 0;
|
||||
line-height: 40px;
|
||||
max-height: 40px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-time:after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper {
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
width: 40%;
|
||||
height: 40px;
|
||||
float: left;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: rgba(255,255,255,0.95);
|
||||
}
|
||||
.flatpickr-time .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: rgba(255,255,255,0.95);
|
||||
}
|
||||
.flatpickr-time.hasSeconds .numInputWrapper {
|
||||
width: 26%;
|
||||
}
|
||||
.flatpickr-time.time24hr .numInputWrapper {
|
||||
width: 49%;
|
||||
}
|
||||
.flatpickr-time input {
|
||||
background: transparent;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: inherit;
|
||||
line-height: inherit;
|
||||
color: rgba(255,255,255,0.95);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.flatpickr-time input.flatpickr-hour {
|
||||
font-weight: bold;
|
||||
}
|
||||
.flatpickr-time input.flatpickr-minute,
|
||||
.flatpickr-time input.flatpickr-second {
|
||||
font-weight: 400;
|
||||
}
|
||||
.flatpickr-time input:focus {
|
||||
outline: 0;
|
||||
border: 0;
|
||||
}
|
||||
.flatpickr-time .flatpickr-time-separator,
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
height: inherit;
|
||||
float: left;
|
||||
line-height: inherit;
|
||||
color: rgba(255,255,255,0.95);
|
||||
font-weight: bold;
|
||||
width: 2%;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-align-self: center;
|
||||
-ms-flex-item-align: center;
|
||||
align-self: center;
|
||||
}
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
outline: 0;
|
||||
width: 18%;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
.flatpickr-time input:hover,
|
||||
.flatpickr-time .flatpickr-am-pm:hover,
|
||||
.flatpickr-time input:focus,
|
||||
.flatpickr-time .flatpickr-am-pm:focus {
|
||||
background: #6a7395;
|
||||
}
|
||||
.flatpickr-input[readonly] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@-webkit-keyframes fpFadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -20px, 0);
|
||||
transform: translate3d(0, -20px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@keyframes fpFadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -20px, 0);
|
||||
transform: translate3d(0, -20px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
798
netbox/project-static/flatpickr-4.6.3/themes/light.css
Normal file
798
netbox/project-static/flatpickr-4.6.3/themes/light.css
Normal file
@@ -0,0 +1,798 @@
|
||||
.flatpickr-calendar {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
text-align: center;
|
||||
visibility: hidden;
|
||||
padding: 0;
|
||||
-webkit-animation: none;
|
||||
animation: none;
|
||||
direction: ltr;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
border-radius: 5px;
|
||||
position: absolute;
|
||||
width: 307.875px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-ms-touch-action: manipulation;
|
||||
touch-action: manipulation;
|
||||
-webkit-box-shadow: 0 3px 13px rgba(0,0,0,0.08);
|
||||
box-shadow: 0 3px 13px rgba(0,0,0,0.08);
|
||||
}
|
||||
.flatpickr-calendar.open,
|
||||
.flatpickr-calendar.inline {
|
||||
opacity: 1;
|
||||
max-height: 640px;
|
||||
visibility: visible;
|
||||
}
|
||||
.flatpickr-calendar.open {
|
||||
display: inline-block;
|
||||
z-index: 99999;
|
||||
}
|
||||
.flatpickr-calendar.animate.open {
|
||||
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.flatpickr-calendar.inline {
|
||||
display: block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
.flatpickr-calendar.static {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
}
|
||||
.flatpickr-calendar.static.open {
|
||||
z-index: 999;
|
||||
display: block;
|
||||
}
|
||||
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
|
||||
-webkit-box-shadow: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
|
||||
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
}
|
||||
.flatpickr-calendar .hasWeeks .dayContainer,
|
||||
.flatpickr-calendar .hasTime .dayContainer {
|
||||
border-bottom: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.flatpickr-calendar .hasWeeks .dayContainer {
|
||||
border-left: 0;
|
||||
}
|
||||
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
|
||||
height: 40px;
|
||||
border-top: 1px solid #eceef1;
|
||||
}
|
||||
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-innerContainer {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
|
||||
border: 1px solid #eceef1;
|
||||
}
|
||||
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
|
||||
height: auto;
|
||||
}
|
||||
.flatpickr-calendar:before,
|
||||
.flatpickr-calendar:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
border: solid transparent;
|
||||
content: '';
|
||||
height: 0;
|
||||
width: 0;
|
||||
left: 22px;
|
||||
}
|
||||
.flatpickr-calendar.rightMost:before,
|
||||
.flatpickr-calendar.rightMost:after {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
}
|
||||
.flatpickr-calendar:before {
|
||||
border-width: 5px;
|
||||
margin: 0 -5px;
|
||||
}
|
||||
.flatpickr-calendar:after {
|
||||
border-width: 4px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:before,
|
||||
.flatpickr-calendar.arrowTop:after {
|
||||
bottom: 100%;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:before {
|
||||
border-bottom-color: #eceef1;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:after {
|
||||
border-bottom-color: #eceef1;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:before,
|
||||
.flatpickr-calendar.arrowBottom:after {
|
||||
top: 100%;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:before {
|
||||
border-top-color: #eceef1;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:after {
|
||||
border-top-color: #eceef1;
|
||||
}
|
||||
.flatpickr-calendar:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.flatpickr-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.flatpickr-months {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-months .flatpickr-month {
|
||||
border-radius: 5px 5px 0 0;
|
||||
background: #eceef1;
|
||||
color: #5a6171;
|
||||
fill: #5a6171;
|
||||
height: 34px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 34px;
|
||||
padding: 10px;
|
||||
z-index: 3;
|
||||
color: #5a6171;
|
||||
fill: #5a6171;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
|
||||
display: none;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month i,
|
||||
.flatpickr-months .flatpickr-next-month i {
|
||||
position: relative;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
*/
|
||||
left: 0;
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
/*
|
||||
*/
|
||||
}
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
*/
|
||||
right: 0;
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
/*
|
||||
*/
|
||||
}
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
.flatpickr-months .flatpickr-prev-month:hover,
|
||||
.flatpickr-months .flatpickr-next-month:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month:hover svg,
|
||||
.flatpickr-months .flatpickr-next-month:hover svg {
|
||||
fill: #f64747;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month svg,
|
||||
.flatpickr-months .flatpickr-next-month svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month svg path,
|
||||
.flatpickr-months .flatpickr-next-month svg path {
|
||||
-webkit-transition: fill 0.1s;
|
||||
transition: fill 0.1s;
|
||||
fill: inherit;
|
||||
}
|
||||
.numInputWrapper {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
.numInputWrapper input,
|
||||
.numInputWrapper span {
|
||||
display: inline-block;
|
||||
}
|
||||
.numInputWrapper input {
|
||||
width: 100%;
|
||||
}
|
||||
.numInputWrapper input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
.numInputWrapper input::-webkit-outer-spin-button,
|
||||
.numInputWrapper input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.numInputWrapper span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 14px;
|
||||
padding: 0 4px 0 2px;
|
||||
height: 50%;
|
||||
line-height: 50%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(72,72,72,0.15);
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.numInputWrapper span:hover {
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
.numInputWrapper span:active {
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.numInputWrapper span:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.numInputWrapper span.arrowUp {
|
||||
top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.numInputWrapper span.arrowUp:after {
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid rgba(72,72,72,0.6);
|
||||
top: 26%;
|
||||
}
|
||||
.numInputWrapper span.arrowDown {
|
||||
top: 50%;
|
||||
}
|
||||
.numInputWrapper span.arrowDown:after {
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(72,72,72,0.6);
|
||||
top: 40%;
|
||||
}
|
||||
.numInputWrapper span svg {
|
||||
width: inherit;
|
||||
height: auto;
|
||||
}
|
||||
.numInputWrapper span svg path {
|
||||
fill: rgba(90,97,113,0.5);
|
||||
}
|
||||
.numInputWrapper:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
.numInputWrapper:hover span {
|
||||
opacity: 1;
|
||||
}
|
||||
.flatpickr-current-month {
|
||||
font-size: 135%;
|
||||
line-height: inherit;
|
||||
font-weight: 300;
|
||||
color: inherit;
|
||||
position: absolute;
|
||||
width: 75%;
|
||||
left: 12.5%;
|
||||
padding: 7.48px 0 0 0;
|
||||
line-height: 1;
|
||||
height: 34px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
-webkit-transform: translate3d(0px, 0px, 0px);
|
||||
transform: translate3d(0px, 0px, 0px);
|
||||
}
|
||||
.flatpickr-current-month span.cur-month {
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
display: inline-block;
|
||||
margin-left: 0.5ch;
|
||||
padding: 0;
|
||||
}
|
||||
.flatpickr-current-month span.cur-month:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper {
|
||||
width: 6ch;
|
||||
width: 7ch\0;
|
||||
display: inline-block;
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: #5a6171;
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: #5a6171;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year {
|
||||
background: transparent;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
padding: 0 0 0 0.5ch;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 300;
|
||||
line-height: inherit;
|
||||
height: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
vertical-align: initial;
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year[disabled],
|
||||
.flatpickr-current-month input.cur-year[disabled]:hover {
|
||||
font-size: 100%;
|
||||
color: rgba(90,97,113,0.5);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months {
|
||||
appearance: menulist;
|
||||
background: #eceef1;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 300;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
margin: -1px 0 0 0;
|
||||
outline: none;
|
||||
padding: 0 0 0 0.5ch;
|
||||
position: relative;
|
||||
vertical-align: initial;
|
||||
-webkit-box-sizing: border-box;
|
||||
-webkit-appearance: menulist;
|
||||
-moz-appearance: menulist;
|
||||
width: auto;
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months:active {
|
||||
outline: none;
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
|
||||
background-color: #eceef1;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
.flatpickr-weekdays {
|
||||
background: #eceef1;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
}
|
||||
.flatpickr-weekdays .flatpickr-weekdaycontainer {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
span.flatpickr-weekday {
|
||||
cursor: default;
|
||||
font-size: 90%;
|
||||
background: #eceef1;
|
||||
color: #5a6171;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
display: block;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.dayContainer,
|
||||
.flatpickr-weeks {
|
||||
padding: 1px 0 0 0;
|
||||
}
|
||||
.flatpickr-days {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: start;
|
||||
-webkit-align-items: flex-start;
|
||||
-ms-flex-align: start;
|
||||
align-items: flex-start;
|
||||
width: 307.875px;
|
||||
border-left: 1px solid #eceef1;
|
||||
border-right: 1px solid #eceef1;
|
||||
}
|
||||
.flatpickr-days:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.dayContainer {
|
||||
padding: 0;
|
||||
outline: 0;
|
||||
text-align: left;
|
||||
width: 307.875px;
|
||||
min-width: 307.875px;
|
||||
max-width: 307.875px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
display: -ms-flexbox;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
-ms-flex-wrap: wrap;
|
||||
-ms-flex-pack: justify;
|
||||
-webkit-justify-content: space-around;
|
||||
justify-content: space-around;
|
||||
-webkit-transform: translate3d(0px, 0px, 0px);
|
||||
transform: translate3d(0px, 0px, 0px);
|
||||
opacity: 1;
|
||||
}
|
||||
.dayContainer + .dayContainer {
|
||||
-webkit-box-shadow: -1px 0 0 #eceef1;
|
||||
box-shadow: -1px 0 0 #eceef1;
|
||||
}
|
||||
.flatpickr-day {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 150px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: #484848;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
width: 14.2857143%;
|
||||
-webkit-flex-basis: 14.2857143%;
|
||||
-ms-flex-preferred-size: 14.2857143%;
|
||||
flex-basis: 14.2857143%;
|
||||
max-width: 39px;
|
||||
height: 39px;
|
||||
line-height: 39px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.flatpickr-day.inRange,
|
||||
.flatpickr-day.prevMonthDay.inRange,
|
||||
.flatpickr-day.nextMonthDay.inRange,
|
||||
.flatpickr-day.today.inRange,
|
||||
.flatpickr-day.prevMonthDay.today.inRange,
|
||||
.flatpickr-day.nextMonthDay.today.inRange,
|
||||
.flatpickr-day:hover,
|
||||
.flatpickr-day.prevMonthDay:hover,
|
||||
.flatpickr-day.nextMonthDay:hover,
|
||||
.flatpickr-day:focus,
|
||||
.flatpickr-day.prevMonthDay:focus,
|
||||
.flatpickr-day.nextMonthDay:focus {
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
background: #e2e2e2;
|
||||
border-color: #e2e2e2;
|
||||
}
|
||||
.flatpickr-day.today {
|
||||
border-color: #bbb;
|
||||
}
|
||||
.flatpickr-day.today:hover,
|
||||
.flatpickr-day.today:focus {
|
||||
border-color: #bbb;
|
||||
background: #bbb;
|
||||
color: #fff;
|
||||
}
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange,
|
||||
.flatpickr-day.selected.inRange,
|
||||
.flatpickr-day.startRange.inRange,
|
||||
.flatpickr-day.endRange.inRange,
|
||||
.flatpickr-day.selected:focus,
|
||||
.flatpickr-day.startRange:focus,
|
||||
.flatpickr-day.endRange:focus,
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.startRange:hover,
|
||||
.flatpickr-day.endRange:hover,
|
||||
.flatpickr-day.selected.prevMonthDay,
|
||||
.flatpickr-day.startRange.prevMonthDay,
|
||||
.flatpickr-day.endRange.prevMonthDay,
|
||||
.flatpickr-day.selected.nextMonthDay,
|
||||
.flatpickr-day.startRange.nextMonthDay,
|
||||
.flatpickr-day.endRange.nextMonthDay {
|
||||
background: #ff5a5f;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
border-color: #ff5a5f;
|
||||
}
|
||||
.flatpickr-day.selected.startRange,
|
||||
.flatpickr-day.startRange.startRange,
|
||||
.flatpickr-day.endRange.startRange {
|
||||
border-radius: 50px 0 0 50px;
|
||||
}
|
||||
.flatpickr-day.selected.endRange,
|
||||
.flatpickr-day.startRange.endRange,
|
||||
.flatpickr-day.endRange.endRange {
|
||||
border-radius: 0 50px 50px 0;
|
||||
}
|
||||
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
|
||||
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
|
||||
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
|
||||
-webkit-box-shadow: -10px 0 0 #ff5a5f;
|
||||
box-shadow: -10px 0 0 #ff5a5f;
|
||||
}
|
||||
.flatpickr-day.selected.startRange.endRange,
|
||||
.flatpickr-day.startRange.startRange.endRange,
|
||||
.flatpickr-day.endRange.startRange.endRange {
|
||||
border-radius: 50px;
|
||||
}
|
||||
.flatpickr-day.inRange {
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: -5px 0 0 #e2e2e2, 5px 0 0 #e2e2e2;
|
||||
box-shadow: -5px 0 0 #e2e2e2, 5px 0 0 #e2e2e2;
|
||||
}
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.flatpickr-disabled:hover,
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay,
|
||||
.flatpickr-day.notAllowed,
|
||||
.flatpickr-day.notAllowed.prevMonthDay,
|
||||
.flatpickr-day.notAllowed.nextMonthDay {
|
||||
color: rgba(72,72,72,0.3);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.flatpickr-disabled:hover {
|
||||
cursor: not-allowed;
|
||||
color: rgba(72,72,72,0.1);
|
||||
}
|
||||
.flatpickr-day.week.selected {
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: -5px 0 0 #ff5a5f, 5px 0 0 #ff5a5f;
|
||||
box-shadow: -5px 0 0 #ff5a5f, 5px 0 0 #ff5a5f;
|
||||
}
|
||||
.flatpickr-day.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
.rangeMode .flatpickr-day {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.flatpickr-weekwrapper {
|
||||
float: left;
|
||||
}
|
||||
.flatpickr-weekwrapper .flatpickr-weeks {
|
||||
padding: 0 12px;
|
||||
border-left: 1px solid #eceef1;
|
||||
}
|
||||
.flatpickr-weekwrapper .flatpickr-weekday {
|
||||
float: none;
|
||||
width: 100%;
|
||||
line-height: 28px;
|
||||
}
|
||||
.flatpickr-weekwrapper span.flatpickr-day,
|
||||
.flatpickr-weekwrapper span.flatpickr-day:hover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
color: rgba(72,72,72,0.3);
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
border: none;
|
||||
}
|
||||
.flatpickr-innerContainer {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eceef1;
|
||||
}
|
||||
.flatpickr-rContainer {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.flatpickr-time {
|
||||
text-align: center;
|
||||
outline: 0;
|
||||
display: block;
|
||||
height: 0;
|
||||
line-height: 40px;
|
||||
max-height: 40px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 0 0 5px 5px;
|
||||
}
|
||||
.flatpickr-time:after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper {
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
width: 40%;
|
||||
height: 40px;
|
||||
float: left;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: #484848;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: #484848;
|
||||
}
|
||||
.flatpickr-time.hasSeconds .numInputWrapper {
|
||||
width: 26%;
|
||||
}
|
||||
.flatpickr-time.time24hr .numInputWrapper {
|
||||
width: 49%;
|
||||
}
|
||||
.flatpickr-time input {
|
||||
background: transparent;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: inherit;
|
||||
line-height: inherit;
|
||||
color: #484848;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.flatpickr-time input.flatpickr-hour {
|
||||
font-weight: bold;
|
||||
}
|
||||
.flatpickr-time input.flatpickr-minute,
|
||||
.flatpickr-time input.flatpickr-second {
|
||||
font-weight: 400;
|
||||
}
|
||||
.flatpickr-time input:focus {
|
||||
outline: 0;
|
||||
border: 0;
|
||||
}
|
||||
.flatpickr-time .flatpickr-time-separator,
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
height: inherit;
|
||||
float: left;
|
||||
line-height: inherit;
|
||||
color: #484848;
|
||||
font-weight: bold;
|
||||
width: 2%;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-align-self: center;
|
||||
-ms-flex-item-align: center;
|
||||
align-self: center;
|
||||
}
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
outline: 0;
|
||||
width: 18%;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
.flatpickr-time input:hover,
|
||||
.flatpickr-time .flatpickr-am-pm:hover,
|
||||
.flatpickr-time input:focus,
|
||||
.flatpickr-time .flatpickr-am-pm:focus {
|
||||
background: #eaeaea;
|
||||
}
|
||||
.flatpickr-input[readonly] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@-webkit-keyframes fpFadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -20px, 0);
|
||||
transform: translate3d(0, -20px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@keyframes fpFadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -20px, 0);
|
||||
transform: translate3d(0, -20px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
span.flatpickr-day.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
20
netbox/project-static/img/netbox_icon.svg
Normal file
20
netbox/project-static/img/netbox_icon.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
|
||||
<g fill="#9cc8f8" stroke="#9cc8f8">
|
||||
<circle cx="37" cy="284" r="23"/>
|
||||
<circle cx="101" cy="37" r="23"/>
|
||||
<circle cx="101" cy="220" r="23"/>
|
||||
<circle cx="284" cy="220" r="23"/>
|
||||
<rect x="93" y="37" width="16" height="180"/>
|
||||
<rect x="101" y="212" width="180" height="16"/>
|
||||
<rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
|
||||
</g>
|
||||
<g fill="#1685fc" stroke="#1685fc">
|
||||
<circle cx="284" cy="37" r="23"/>
|
||||
<circle cx="37" cy="101" r="23"/>
|
||||
<circle cx="220" cy="101" r="23"/>
|
||||
<circle cx="220" cy="284" r="23"/>
|
||||
<rect x="37" y="93" width="180" height="16"/>
|
||||
<rect x="212" y="101" width="16" height="180"/>
|
||||
<rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 835 B |
21
netbox/project-static/img/netbox_logo.svg
Normal file
21
netbox/project-static/img/netbox_logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 320">
|
||||
<g fill="#9cc8f8" stroke="#9cc8f8">
|
||||
<circle cx="37" cy="284" r="23"/>
|
||||
<circle cx="101" cy="37" r="23"/>
|
||||
<circle cx="101" cy="220" r="23"/>
|
||||
<circle cx="284" cy="220" r="23"/>
|
||||
<rect x="93" y="37" width="16" height="180"/>
|
||||
<rect x="101" y="212" width="180" height="16"/>
|
||||
<rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
|
||||
</g>
|
||||
<g fill="#1685fc" stroke="#1685fc">
|
||||
<circle cx="284" cy="37" r="23"/>
|
||||
<circle cx="37" cy="101" r="23"/>
|
||||
<circle cx="220" cy="101" r="23"/>
|
||||
<circle cx="220" cy="284" r="23"/>
|
||||
<rect x="37" y="93" width="180" height="16"/>
|
||||
<rect x="212" y="101" width="16" height="180"/>
|
||||
<rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
|
||||
<path transform="translate(380, 8)" d="M13.60 200L13.60 104L36.40 104L36.40 119.40L36.80 119.40Q40.20 112.20 47.20 106.90Q54.20 101.60 66.20 101.60L66.20 101.60Q75.80 101.60 82.50 104.80Q89.20 108 93.40 113.20Q97.60 118.40 99.40 125.20Q101.20 132 101.20 139.40L101.20 139.40L101.20 200L77.20 200L77.20 151.40Q77.20 147.40 76.80 142.50Q76.40 137.60 74.70 133.30Q73 129 69.40 126.10Q65.80 123.20 59.60 123.20L59.60 123.20Q53.60 123.20 49.50 125.20Q45.40 127.20 42.70 130.60Q40 134 38.80 138.40Q37.60 142.80 37.60 147.60L37.60 147.60L37.60 200L13.60 200ZM224.80 160.40L151.60 160.40Q152.80 171.20 160 177.20Q167.20 183.20 177.40 183.20L177.40 183.20Q186.40 183.20 192.50 179.50Q198.60 175.80 203.20 170.20L203.20 170.20L220.40 183.20Q212 193.60 201.60 198Q191.20 202.40 179.80 202.40L179.80 202.40Q169 202.40 159.40 198.80Q149.80 195.20 142.80 188.60Q135.80 182 131.70 172.70Q127.60 163.40 127.60 152L127.60 152Q127.60 140.60 131.70 131.30Q135.80 122 142.80 115.40Q149.80 108.80 159.40 105.20Q169 101.60 179.80 101.60L179.80 101.60Q189.80 101.60 198.10 105.10Q206.40 108.60 212.30 115.20Q218.20 121.80 221.50 131.50Q224.80 141.20 224.80 153.80L224.80 153.80L224.80 160.40ZM151.60 142.40L200.80 142.40Q200.60 131.80 194.20 125.70Q187.80 119.60 176.40 119.60L176.40 119.60Q165.60 119.60 159.30 125.80Q153 132 151.60 142.40L151.60 142.40ZM259.80 124.40L240.00 124.40L240.00 104L259.80 104L259.80 76.20L283.80 76.20L283.80 104L310.20 104L310.20 124.40L283.80 124.40L283.80 166.40Q283.80 173.60 286.50 177.80Q289.20 182 297.20 182L297.20 182Q300.40 182 304.20 181.30Q308 180.60 310.20 179L310.20 179L310.20 199.20Q306.40 201 300.90 201.70Q295.40 202.40 291.20 202.40L291.20 202.40Q281.60 202.40 275.50 200.30Q269.40 198.20 265.90 193.90Q262.40 189.60 261.10 183.20Q259.80 176.80 259.80 168.40L259.80 168.40L259.80 124.40ZM333.20 200L333.20 48.80L357.20 48.80L357.20 116.20L357.80 116.20Q359.60 113.80 362.40 111.30Q365.20 108.80 369.20 106.60Q373.20 104.40 378.40 103Q383.60 101.60 390.40 101.60L390.40 101.60Q400.60 101.60 409.20 105.50Q417.80 109.40 423.90 116.20Q430 123 433.40 132.20Q436.80 141.40 436.80 152L436.80 152Q436.80 162.60 433.60 171.80Q430.40 181 424.20 187.80Q418 194.60 409.20 198.50Q400.40 202.40 389.40 202.40L389.40 202.40Q379.20 202.40 370.40 198.40Q361.60 194.40 356.40 185.60L356.40 185.60L356 185.60L356 200L333.20 200ZM412.80 152L412.80 152Q412.80 146.40 410.90 141.20Q409 136 405.30 132Q401.60 128 396.40 125.60Q391.20 123.20 384.60 123.20L384.60 123.20Q378 123.20 372.80 125.60Q367.60 128 363.90 132Q360.20 136 358.30 141.20Q356.40 146.40 356.40 152L356.40 152Q356.40 157.60 358.30 162.80Q360.20 168 363.90 172Q367.60 176 372.80 178.40Q378 180.80 384.60 180.80L384.60 180.80Q391.20 180.80 396.40 178.40Q401.60 176 405.30 172Q409 168 410.90 162.80Q412.80 157.60 412.80 152ZM458.40 152L458.40 152Q458.40 140.60 462.50 131.30Q466.60 122 473.60 115.40Q480.60 108.80 490.20 105.20Q499.80 101.60 510.60 101.60L510.60 101.60Q521.40 101.60 531 105.20Q540.60 108.80 547.60 115.40Q554.60 122 558.70 131.30Q562.80 140.60 562.80 152L562.80 152Q562.80 163.40 558.70 172.70Q554.60 182 547.60 188.60Q540.60 195.20 531 198.80Q521.40 202.40 510.60 202.40L510.60 202.40Q499.80 202.40 490.20 198.80Q480.60 195.20 473.60 188.60Q466.60 182 462.50 172.70Q458.40 163.40 458.40 152ZM482.40 152L482.40 152Q482.40 157.60 484.30 162.80Q486.20 168 489.90 172Q493.60 176 498.80 178.40Q504 180.80 510.60 180.80L510.60 180.80Q517.20 180.80 522.40 178.40Q527.60 176 531.30 172Q535 168 536.90 162.80Q538.80 157.60 538.80 152L538.80 152Q538.80 146.40 536.90 141.20Q535 136 531.30 132Q527.60 128 522.40 125.60Q517.20 123.20 510.60 123.20L510.60 123.20Q504 123.20 498.80 125.60Q493.60 128 489.90 132Q486.20 136 484.30 141.20Q482.40 146.40 482.40 152ZM575.40 200L614 148.40L580.80 104L610 104L629.20 132.80L650 104L677.40 104L644.60 148.40L683.20 200L654 200L629 165.60L603.80 200L575.40 200Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -7,7 +7,7 @@ $(document).ready(function() {
|
||||
|
||||
// "Toggle" checkbox for object lists (PK column)
|
||||
$('input:checkbox.toggle').click(function() {
|
||||
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
$(this).closest('table').find('input:checkbox[name=pk]:visible').prop('checked', $(this).prop('checked'));
|
||||
|
||||
// Show the "select all" box if present
|
||||
if ($(this).is(':checked')) {
|
||||
@@ -103,14 +103,16 @@ $(document).ready(function() {
|
||||
placeholder: "---------",
|
||||
theme: "bootstrap",
|
||||
templateResult: colorPickerClassCopy,
|
||||
templateSelection: colorPickerClassCopy
|
||||
templateSelection: colorPickerClassCopy,
|
||||
width: "off"
|
||||
});
|
||||
|
||||
// Static choice selection
|
||||
$('.netbox-select2-static').select2({
|
||||
allowClear: true,
|
||||
placeholder: "---------",
|
||||
theme: "bootstrap"
|
||||
theme: "bootstrap",
|
||||
width: "off"
|
||||
});
|
||||
|
||||
// API backed selection
|
||||
@@ -120,6 +122,7 @@ $(document).ready(function() {
|
||||
allowClear: true,
|
||||
placeholder: "---------",
|
||||
theme: "bootstrap",
|
||||
width: "off",
|
||||
ajax: {
|
||||
delay: 500,
|
||||
|
||||
@@ -184,7 +187,15 @@ $(document).ready(function() {
|
||||
$.each(element.attributes, function(index, attr){
|
||||
if (attr.name.includes("data-additional-query-param-")){
|
||||
var param_name = attr.name.split("data-additional-query-param-")[1];
|
||||
parameters[param_name] = attr.value;
|
||||
if (param_name in parameters) {
|
||||
if (Array.isArray(parameters[param_name])) {
|
||||
parameters[param_name].push(attr.value)
|
||||
} else {
|
||||
parameters[param_name] = [parameters[param_name], attr.value]
|
||||
}
|
||||
} else {
|
||||
parameters[param_name] = attr.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -251,6 +262,24 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Flatpickr selectors
|
||||
$('.date-picker').flatpickr({
|
||||
allowInput: true
|
||||
});
|
||||
$('.datetime-picker').flatpickr({
|
||||
allowInput: true,
|
||||
enableSeconds: true,
|
||||
enableTime: true,
|
||||
time_24hr: true
|
||||
});
|
||||
$('.time-picker').flatpickr({
|
||||
allowInput: true,
|
||||
enableSeconds: true,
|
||||
enableTime: true,
|
||||
noCalendar: true,
|
||||
time_24hr: true
|
||||
});
|
||||
|
||||
// API backed tags
|
||||
var tags = $('#id_tags');
|
||||
if (tags.length > 0 && tags.val().length > 0){
|
||||
@@ -273,7 +302,8 @@ $(document).ready(function() {
|
||||
multiple: true,
|
||||
allowClear: true,
|
||||
placeholder: "Tags",
|
||||
|
||||
theme: "bootstrap",
|
||||
width: "off",
|
||||
ajax: {
|
||||
delay: 250,
|
||||
url: netbox_api_path + "extras/tags/",
|
||||
@@ -353,4 +383,58 @@ $(document).ready(function() {
|
||||
});
|
||||
$('select#id_mode').trigger('change');
|
||||
}
|
||||
|
||||
// Scroll up an offset equal to the first nav element if a hash is present
|
||||
// Cannot use '#navbar' because it is not always visible, like in small windows
|
||||
function headerOffsetScroll() {
|
||||
if (window.location.hash) {
|
||||
// Short wait needed to allow the page to scroll to the element
|
||||
setTimeout(function() {
|
||||
window.scrollBy(0, -$('nav').height())
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Account for the header height when hash-scrolling
|
||||
window.addEventListener('load', headerOffsetScroll);
|
||||
window.addEventListener('hashchange', headerOffsetScroll);
|
||||
|
||||
// Offset between the preview window and the window edges
|
||||
const IMAGE_PREVIEW_OFFSET_X = 20;
|
||||
const IMAGE_PREVIEW_OFFSET_Y = 10;
|
||||
|
||||
// Preview an image attachment when the link is hovered over
|
||||
$('a.image-preview').on('mouseover', function(e) {
|
||||
// Twice the offset to account for all sides of the picture
|
||||
var maxWidth = window.innerWidth - (e.clientX + (IMAGE_PREVIEW_OFFSET_X * 2));
|
||||
var maxHeight = window.innerHeight - (e.clientY + (IMAGE_PREVIEW_OFFSET_Y * 2));
|
||||
var img = $('<img>').attr('id', 'image-preview-window').css({
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
maxWidth: maxWidth + 'px',
|
||||
maxHeight: maxHeight + 'px',
|
||||
left: e.pageX + IMAGE_PREVIEW_OFFSET_X + 'px',
|
||||
top: e.pageY + IMAGE_PREVIEW_OFFSET_Y + 'px',
|
||||
boxShadow: '0 0px 12px 3px rgba(0, 0, 0, 0.4)',
|
||||
});
|
||||
|
||||
// Remove any existing preview windows and add the current one
|
||||
$('#image-preview-window').remove();
|
||||
$('body').append(img);
|
||||
|
||||
// Once loaded, show the preview if the image is indeed an image
|
||||
img.on('load', function(e) {
|
||||
if (e.target.complete && e.target.naturalWidth) {
|
||||
$('#image-preview-window').fadeIn('fast');
|
||||
}
|
||||
});
|
||||
|
||||
// Begin loading
|
||||
img.attr('src', e.target.href);
|
||||
});
|
||||
|
||||
// Fade the image out; it will be deleted when another one is previewed
|
||||
$('a.image-preview').on('mouseout', function() {
|
||||
$('#image-preview-window').fadeOut('fast');
|
||||
});
|
||||
});
|
||||
|
||||
30
netbox/project-static/js/interface_toggles.js
Normal file
30
netbox/project-static/js/interface_toggles.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Toggle the display of IP addresses under interfaces
|
||||
$('button.toggle-ips').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
if (selected) {
|
||||
$('#interfaces_table tr.ipaddresses').hide();
|
||||
} else {
|
||||
$('#interfaces_table tr.ipaddresses').show();
|
||||
}
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||
return false;
|
||||
});
|
||||
|
||||
// Inteface filtering
|
||||
$('input.interface-filter').on('input', function() {
|
||||
var filter = new RegExp(this.value);
|
||||
|
||||
for (interface of $(this).closest('form').find('tbody > tr')) {
|
||||
// Slice off 'interface_' at the start of the ID
|
||||
if (filter && filter.test(interface.id.slice(10))) {
|
||||
// Match the toggle in case the filter now matches the interface
|
||||
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
|
||||
$(interface).show();
|
||||
} else {
|
||||
// Uncheck to prevent actions from including it when it doesn't match
|
||||
$(interface).find('input:checkbox[name=pk]').prop('checked', false);
|
||||
$(interface).hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2,11 +2,17 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
|
||||
from .models import Secret, SecretRole
|
||||
|
||||
|
||||
__all__ = (
|
||||
'SecretFilter',
|
||||
'SecretRoleFilter',
|
||||
)
|
||||
|
||||
|
||||
class SecretRoleFilter(NameSlugSearchFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -14,7 +20,7 @@ class SecretRoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SecretFilter(CustomFieldFilterSet):
|
||||
class SecretFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
92
netbox/secrets/tests/test_filters.py
Normal file
92
netbox/secrets/tests/test_filters.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from secrets.filters import *
|
||||
from secrets.models import Secret, SecretRole
|
||||
|
||||
|
||||
class SecretRoleTestCase(TestCase):
|
||||
queryset = SecretRole.objects.all()
|
||||
filterset = SecretRoleFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
roles = (
|
||||
SecretRole(name='Secret Role 1', slug='secret-role-1'),
|
||||
SecretRole(name='Secret Role 2', slug='secret-role-2'),
|
||||
SecretRole(name='Secret Role 3', slug='secret-role-3'),
|
||||
)
|
||||
SecretRole.objects.bulk_create(roles)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Secret Role 1', 'Secret Role 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['secret-role-1', 'secret-role-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class SecretTestCase(TestCase):
|
||||
queryset = Secret.objects.all()
|
||||
filterset = SecretFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
devices = (
|
||||
Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
|
||||
Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
|
||||
Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
roles = (
|
||||
SecretRole(name='Secret Role 1', slug='secret-role-1'),
|
||||
SecretRole(name='Secret Role 2', slug='secret-role-2'),
|
||||
SecretRole(name='Secret Role 3', slug='secret-role-3'),
|
||||
)
|
||||
SecretRole.objects.bulk_create(roles)
|
||||
|
||||
secrets = (
|
||||
Secret(device=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'),
|
||||
Secret(device=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'),
|
||||
Secret(device=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'),
|
||||
)
|
||||
# Must call save() to encrypt Secrets
|
||||
for s in secrets:
|
||||
s.save()
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Secret 1', 'Secret 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
roles = SecretRole.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'role': [roles[0].slug, roles[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2-4.0.5/css/select2.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'flatpickr-4.6.3/themes/light.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
|
||||
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
|
||||
<meta charset="UTF-8">
|
||||
@@ -69,6 +70,7 @@
|
||||
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"></script>
|
||||
<script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
|
||||
<script src="{% static 'flatpickr-4.6.3/flatpickr.min.js' %}"></script>
|
||||
<script src="{% static 'clipboard-2.0.4.min.js' %}"></script>
|
||||
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -125,58 +125,7 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Circuits</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<th>Circuit ID</th>
|
||||
<th>Type</th>
|
||||
<th>Tenant</th>
|
||||
<th>A Side</th>
|
||||
<th>Z Side</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
{% for c in circuits %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if c.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=c.tenant.slug %}">{{ c.tenant }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.termination_a %}
|
||||
<a href="{% url 'dcim:site' slug=c.termination_a.site.slug %}">{{ c.termination_a.site }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.termination_z %}
|
||||
<a href="{% url 'dcim:site' slug=c.termination_z.site.slug %}">{{ c.termination_z.site }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.description %}
|
||||
{{ c.description }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted">None</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% include 'inc/table.html' with table=circuits_table %}
|
||||
{% if perms.circuits.add_circuit %}
|
||||
<div class="panel-footer text-right noprint">
|
||||
<a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
|
||||
@@ -185,6 +134,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/modal.html' with modal_name='graphs' %}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
<div class="col-md-4 col-md-offset-1 text-center">
|
||||
<h4>Near End</h4>
|
||||
</div>
|
||||
<div class="col-md-4 col-md-offset-3 text-center">
|
||||
<div class="col-md-3 text-center">
|
||||
{% if total_length %}<h5>Total length: {{ total_length|floatformat:"-2" }} Meters<h5>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<h4>Far End</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -556,6 +556,9 @@
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-2 pull-right noprint">
|
||||
<input class="form-control interface-filter" type="text" placeholder="Filter" title="RegEx-enabled" style="height: 23px" />
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
@@ -900,19 +903,8 @@ function toggleConnection(elem) {
|
||||
$(".cable-toggle").click(function() {
|
||||
return toggleConnection($(this));
|
||||
});
|
||||
// Toggle the display of IP addresses under interfaces
|
||||
$('button.toggle-ips').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
if (selected) {
|
||||
$('#interfaces_table tr.ipaddresses').hide();
|
||||
} else {
|
||||
$('#interfaces_table tr.ipaddresses').show();
|
||||
}
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
|
||||
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
|
||||
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
<td class="text-nowrap">
|
||||
{% if iface.cable %}
|
||||
<a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a>
|
||||
{% if iface.cable.color %}
|
||||
<span class="inline-color-block" style="background-color: #{{ iface.cable.color }}"> </span>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
@@ -112,7 +112,9 @@
|
||||
{% if utilization %}
|
||||
<td>
|
||||
{{ utilization.allocated }}VA / {{ powerfeed.available_power }}VA
|
||||
{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
|
||||
{% if powerfeed.available_power > 0 %}
|
||||
{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">N/A</td>
|
||||
@@ -121,6 +123,18 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body rendered-markdown">
|
||||
{% if powerfeed.comments %}
|
||||
{{ powerfeed.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
|
||||
@@ -251,25 +251,28 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Rack Groups</strong>
|
||||
</div>
|
||||
{% if rack_groups %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for rg in rack_groups %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
|
||||
<td>{{ rg.rack_count }}</td>
|
||||
<td class="text-right noprint">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="panel-body text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for rg in rack_groups %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
|
||||
<td>{{ rg.rack_count }}</td>
|
||||
<td class="text-right noprint">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-folder-o"></i> All racks</td>
|
||||
<td>{{ stats.rack_count }}</td>
|
||||
<td class="text-right noprint">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ site.slug }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
{% if configcontext.roles.all %}
|
||||
<ul>
|
||||
{% for role in configcontext.roles.all %}
|
||||
<li><a href="{{ role.get_absolute_url }}">{{ role }}</a></li>
|
||||
<li><a href="{% url 'dcim:device_list' %}?role={{ role.slug }}">{{ role }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<tr{% if not attachment.size %} class="danger"{% endif %}>
|
||||
<td>
|
||||
<i class="fa fa-image"></i>
|
||||
<a href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
</td>
|
||||
<td>{{ attachment.size|filesizeformat }}</td>
|
||||
<td>{{ attachment.created }}</td>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="{% url 'home' %}">
|
||||
<img src="{% static 'img/netbox_logo.png' %}" />
|
||||
<img src="{% static 'img/netbox_logo.svg' %}" height="30" />
|
||||
</a>
|
||||
</div>
|
||||
<div id="navbar" class="navbar-collapse collapse">
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
</div>
|
||||
<h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=aggregate %}
|
||||
{% include 'ipam/inc/toggle_available.html' %}
|
||||
<div class="pull-right noprint">
|
||||
{% custom_links aggregate %}
|
||||
</div>
|
||||
|
||||
9
netbox/templates/ipam/inc/toggle_available.html
Normal file
9
netbox/templates/ipam/inc/toggle_available.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% load helpers %}
|
||||
{% if show_available is not None %}
|
||||
<div class="pull-right">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ request.path }}{% querystring request show_available='true' %}" class="btn btn-default{% if show_available %} active disabled{% endif %}">Show available</a>
|
||||
<a href="{{ request.path }}{% querystring request show_available='false' %}" class="btn btn-default{% if not show_available %} active disabled{% endif %}">Hide available</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -160,7 +160,7 @@
|
||||
{% if duplicate_ips_table.rows %}
|
||||
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
|
||||
{% endif %}
|
||||
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default noprint' %}
|
||||
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Select IP Address</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.vrf %}
|
||||
{% render_field form.address %}
|
||||
{% render_field form.vrf_id %}
|
||||
{% render_field form.q %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</div>
|
||||
<h1>{% block title %}{{ prefix }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=prefix %}
|
||||
{% include 'ipam/inc/toggle_available.html' %}
|
||||
<div class="pull-right noprint">
|
||||
{% custom_links prefix %}
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
<li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:profile' %}">Profile</a>
|
||||
</li>
|
||||
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:change_password' %}">Change Password</a>
|
||||
</li>
|
||||
{% if not request.user.ldap_username %}
|
||||
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:change_password' %}">Change Password</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:token_list' %}">API Tokens</a>
|
||||
</li>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right noprint">
|
||||
<a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
|
||||
{% if perms.users.change_token %}
|
||||
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
|
||||
{% endif %}
|
||||
@@ -17,7 +18,8 @@
|
||||
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<i class="fa fa-key"></i> {{ token.key }}
|
||||
<i class="fa fa-key"></i>
|
||||
<span id="token_{{ token.pk }}">{{ token.key }}</span>
|
||||
{% if token.is_expired %}
|
||||
<span class="label label-danger">Expired</span>
|
||||
{% endif %}
|
||||
@@ -66,3 +68,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
new ClipboardJS('.copy-token');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load custom_links %}
|
||||
{% load static %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block header %}
|
||||
@@ -253,6 +254,9 @@
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-2 pull-right noprint">
|
||||
<input class="form-control interface-filter" type="text" placeholder="Filter" title="RegEx-enabled" style="height: 23px" />
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
@@ -312,18 +316,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
// Toggle the display of IP addresses under interfaces
|
||||
$('button.toggle-ips').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
if (selected) {
|
||||
$('#interfaces_table tr.ipaddresses').hide();
|
||||
} else {
|
||||
$('#interfaces_table tr.ipaddresses').show();
|
||||
}
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
|
||||
from .models import Tenant, TenantGroup
|
||||
|
||||
|
||||
__all__ = (
|
||||
'TenantFilter',
|
||||
'TenantGroupFilter',
|
||||
)
|
||||
|
||||
|
||||
class TenantGroupFilter(NameSlugSearchFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -13,7 +19,7 @@ class TenantGroupFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class TenantFilter(CustomFieldFilterSet):
|
||||
class TenantFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
||||
74
netbox/tenancy/tests/test_filters.py
Normal file
74
netbox/tenancy/tests/test_filters.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from tenancy.filters import *
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
|
||||
class TenantGroupTestCase(TestCase):
|
||||
queryset = TenantGroup.objects.all()
|
||||
filterset = TenantGroupFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
|
||||
)
|
||||
TenantGroup.objects.bulk_create(groups)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Tenant Group 1', 'Tenant Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['tenant-group-1', 'tenant-group-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class TenantTestCase(TestCase):
|
||||
queryset = Tenant.objects.all()
|
||||
filterset = TenantFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
|
||||
)
|
||||
TenantGroup.objects.bulk_create(groups)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1', group=groups[0]),
|
||||
Tenant(name='Tenant 2', slug='tenant-2', group=groups[1]),
|
||||
Tenant(name='Tenant 3', slug='tenant-3', group=groups[2]),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Tenant 1', 'Tenant 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['tenant-1', 'tenant-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_group(self):
|
||||
group = TenantGroup.objects.all()[:2]
|
||||
params = {'group_id': [group[0].pk, group[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [group[0].slug, group[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
|
||||
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
from .models import Token
|
||||
|
||||
|
||||
@@ -29,6 +29,6 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
|
||||
fields = [
|
||||
'key', 'write_enabled', 'expires', 'description',
|
||||
]
|
||||
help_texts = {
|
||||
'expires': 'YYYY-MM-DD [HH:MM:SS]'
|
||||
widgets = {
|
||||
'expires': DateTimePicker(),
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
||||
template_name = 'users/change_password.html'
|
||||
|
||||
def get(self, request):
|
||||
# LDAP users cannot change their password here
|
||||
if getattr(request.user, 'ldap_username', None):
|
||||
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
|
||||
return redirect('user:profile')
|
||||
|
||||
form = PasswordChangeForm(user=request.user)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
|
||||
@@ -60,8 +60,16 @@ def parse_alphanumeric_range(string):
|
||||
for n in list(range(int(begin), int(end) + 1)):
|
||||
values.append(n)
|
||||
else:
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
# Value-based
|
||||
if begin == end:
|
||||
values.append(begin)
|
||||
# Range-based
|
||||
else:
|
||||
# Not a valid range (more than a single character)
|
||||
if not len(begin) == len(end) == 1:
|
||||
raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range))
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
return values
|
||||
|
||||
|
||||
@@ -285,6 +293,8 @@ class APISelect(SelectWithDisabled):
|
||||
name of the query param and the value if the query param's value.
|
||||
:param null_option: If true, include the static null option in the selection list.
|
||||
"""
|
||||
# Only preload the selected option(s); new options are dynamically displayed and added via the API
|
||||
template_name = 'widgets/select_api.html'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -362,6 +372,36 @@ class APISelectMultiple(APISelect, forms.SelectMultiple):
|
||||
self.attrs['data-multiple'] = 1
|
||||
|
||||
|
||||
class DatePicker(forms.TextInput):
|
||||
"""
|
||||
Date picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'date-picker'
|
||||
self.attrs['placeholder'] = 'YYYY-MM-DD'
|
||||
|
||||
|
||||
class DateTimePicker(forms.TextInput):
|
||||
"""
|
||||
DateTime picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'datetime-picker'
|
||||
self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
|
||||
|
||||
|
||||
class TimePicker(forms.TextInput):
|
||||
"""
|
||||
Time picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'time-picker'
|
||||
self.attrs['placeholder'] = 'hh:mm:ss'
|
||||
|
||||
|
||||
#
|
||||
# Form fields
|
||||
#
|
||||
@@ -449,6 +489,7 @@ class ExpandableNameField(forms.CharField):
|
||||
'Mixed cases and types within a single range are not supported.<br />' \
|
||||
'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
|
||||
'<li><code>e[0-3][a-d,f]</code></li>' \
|
||||
'<li><code>[xe,ge]-0/0/0</code></li>' \
|
||||
'<li><code>e[0-3,a-d,f]</code></li></ul>'
|
||||
|
||||
def to_python(self, value):
|
||||
@@ -671,16 +712,22 @@ class ChainedFieldsMixin(forms.BaseForm):
|
||||
else:
|
||||
break
|
||||
|
||||
# Limit field queryset by chained field values
|
||||
if filters_dict:
|
||||
field.queryset = field.queryset.filter(**filters_dict)
|
||||
# Editing an existing instance; limit field to its current value
|
||||
elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
|
||||
obj = getattr(self.instance, field_name)
|
||||
if obj is not None:
|
||||
field.queryset = field.queryset.filter(pk=obj.pk)
|
||||
else:
|
||||
field.queryset = field.queryset.none()
|
||||
elif not self.is_bound:
|
||||
# Creating a new instance with no bound data; nullify queryset
|
||||
elif not self.data.get(field_name):
|
||||
field.queryset = field.queryset.none()
|
||||
# Creating a new instance with bound data; limit queryset to the specified value
|
||||
else:
|
||||
field.queryset = field.queryset.filter(pk=self.data.get(field_name))
|
||||
|
||||
|
||||
class ReturnURLForm(forms.Form):
|
||||
|
||||
9
netbox/utilities/templates/widgets/select_api.html
Normal file
9
netbox/utilities/templates/widgets/select_api.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
|
||||
{% for group_name, group_choices, group_index in widget.optgroups %}
|
||||
{% if group_name %}<optgroup label="{{ group_name }}">{% endif %}
|
||||
{% for option in group_choices %}
|
||||
{% if option.attrs.selected or option.value == "null" %}{% include option.template_name with widget=option %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if group_name %}</optgroup>{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -1,7 +1,13 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from extras.constants import CF_TYPE_TEXT
|
||||
from extras.models import CustomField
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
@@ -117,3 +123,26 @@ class WritableNestedSerializerTest(APITestCase):
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(VLAN.objects.count(), 0)
|
||||
|
||||
|
||||
class APIDocsTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
# Populate a CustomField to activate CustomFieldSerializer
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
self.cf_text = CustomField(type=CF_TYPE_TEXT, name='test')
|
||||
self.cf_text.save()
|
||||
self.cf_text.obj_type.set([content_type])
|
||||
self.cf_text.save()
|
||||
|
||||
def test_api_docs(self):
|
||||
|
||||
url = reverse('api_docs')
|
||||
params = {
|
||||
"format": "openapi",
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
283
netbox/utilities/tests/test_forms.py
Normal file
283
netbox/utilities/tests/test_forms.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from django import forms
|
||||
from django.test import TestCase
|
||||
|
||||
from utilities.forms import *
|
||||
|
||||
|
||||
class ExpandIPAddress(TestCase):
|
||||
"""
|
||||
Validate the operation of expand_ipaddress_pattern().
|
||||
"""
|
||||
def test_ipv4_range(self):
|
||||
input = '1.2.3.[9-10]/32'
|
||||
output = sorted([
|
||||
'1.2.3.9/32',
|
||||
'1.2.3.10/32',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
|
||||
|
||||
def test_ipv4_set(self):
|
||||
input = '1.2.3.[4,44]/32'
|
||||
output = sorted([
|
||||
'1.2.3.4/32',
|
||||
'1.2.3.44/32',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
|
||||
|
||||
def test_ipv4_multiple_ranges(self):
|
||||
input = '1.[9-10].3.[9-11]/32'
|
||||
output = sorted([
|
||||
'1.9.3.9/32',
|
||||
'1.9.3.10/32',
|
||||
'1.9.3.11/32',
|
||||
'1.10.3.9/32',
|
||||
'1.10.3.10/32',
|
||||
'1.10.3.11/32',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
|
||||
|
||||
def test_ipv4_multiple_sets(self):
|
||||
input = '1.[2,22].3.[4,44]/32'
|
||||
output = sorted([
|
||||
'1.2.3.4/32',
|
||||
'1.2.3.44/32',
|
||||
'1.22.3.4/32',
|
||||
'1.22.3.44/32',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
|
||||
|
||||
def test_ipv4_set_and_range(self):
|
||||
input = '1.[2,22].3.[9-11]/32'
|
||||
output = sorted([
|
||||
'1.2.3.9/32',
|
||||
'1.2.3.10/32',
|
||||
'1.2.3.11/32',
|
||||
'1.22.3.9/32',
|
||||
'1.22.3.10/32',
|
||||
'1.22.3.11/32',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
|
||||
|
||||
def test_ipv6_range(self):
|
||||
input = 'fec::abcd:[9-b]/64'
|
||||
output = sorted([
|
||||
'fec::abcd:9/64',
|
||||
'fec::abcd:a/64',
|
||||
'fec::abcd:b/64',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
|
||||
|
||||
def test_ipv6_range_multichar_field(self):
|
||||
input = 'fec::abcd:[f-11]/64'
|
||||
output = sorted([
|
||||
'fec::abcd:f/64',
|
||||
'fec::abcd:10/64',
|
||||
'fec::abcd:11/64',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
|
||||
|
||||
def test_ipv6_set(self):
|
||||
input = 'fec::abcd:[9,ab]/64'
|
||||
output = sorted([
|
||||
'fec::abcd:9/64',
|
||||
'fec::abcd:ab/64',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
|
||||
|
||||
def test_ipv6_multiple_ranges(self):
|
||||
input = 'fec::[1-2]bcd:[9-b]/64'
|
||||
output = sorted([
|
||||
'fec::1bcd:9/64',
|
||||
'fec::1bcd:a/64',
|
||||
'fec::1bcd:b/64',
|
||||
'fec::2bcd:9/64',
|
||||
'fec::2bcd:a/64',
|
||||
'fec::2bcd:b/64',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
|
||||
|
||||
def test_ipv6_multiple_sets(self):
|
||||
input = 'fec::[a,f]bcd:[9,ab]/64'
|
||||
output = sorted([
|
||||
'fec::abcd:9/64',
|
||||
'fec::abcd:ab/64',
|
||||
'fec::fbcd:9/64',
|
||||
'fec::fbcd:ab/64',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
|
||||
|
||||
def test_ipv6_set_and_range(self):
|
||||
input = 'fec::[dead,beaf]:[9-b]/64'
|
||||
output = sorted([
|
||||
'fec::dead:9/64',
|
||||
'fec::dead:a/64',
|
||||
'fec::dead:b/64',
|
||||
'fec::beaf:9/64',
|
||||
'fec::beaf:a/64',
|
||||
'fec::beaf:b/64',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
|
||||
|
||||
def test_invalid_address_family(self):
|
||||
with self.assertRaisesRegex(Exception, 'Invalid IP address family: 5'):
|
||||
sorted(expand_ipaddress_pattern(None, 5))
|
||||
|
||||
def test_invalid_non_pattern(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.4/32', 4))
|
||||
|
||||
def test_invalid_range(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[4-]/32', 4))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[-4]/32', 4))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[4--5]/32', 4))
|
||||
|
||||
def test_invalid_range_bounds(self):
|
||||
self.assertEqual(sorted(expand_ipaddress_pattern('1.2.3.[4-3]/32', 6)), [])
|
||||
|
||||
def test_invalid_set(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[4]/32', 4))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[4,]/32', 4))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[,4]/32', 4))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_ipaddress_pattern('1.2.3.[4,,5]/32', 4))
|
||||
|
||||
|
||||
class ExpandAlphanumeric(TestCase):
|
||||
"""
|
||||
Validate the operation of expand_alphanumeric_pattern().
|
||||
"""
|
||||
def test_range_numberic(self):
|
||||
input = 'r[9-11]a'
|
||||
output = sorted([
|
||||
'r9a',
|
||||
'r10a',
|
||||
'r11a',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_range_alpha(self):
|
||||
input = '[r-t]1a'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
's1a',
|
||||
't1a',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set(self):
|
||||
input = '[r,t]1a'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
't1a',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set_multichar(self):
|
||||
input = '[ra,tb]1a'
|
||||
output = sorted([
|
||||
'ra1a',
|
||||
'tb1a',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_multiple_ranges(self):
|
||||
input = '[r-t]1[a-b]'
|
||||
output = sorted([
|
||||
'r1a',
|
||||
'r1b',
|
||||
's1a',
|
||||
's1b',
|
||||
't1a',
|
||||
't1b',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_multiple_sets(self):
|
||||
input = '[ra,tb]1[ax,by]'
|
||||
output = sorted([
|
||||
'ra1ax',
|
||||
'ra1by',
|
||||
'tb1ax',
|
||||
'tb1by',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_set_and_range(self):
|
||||
input = '[ra,tb]1[a-c]'
|
||||
output = sorted([
|
||||
'ra1a',
|
||||
'ra1b',
|
||||
'ra1c',
|
||||
'tb1a',
|
||||
'tb1b',
|
||||
'tb1c',
|
||||
])
|
||||
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
|
||||
|
||||
def test_invalid_non_pattern(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r9a'))
|
||||
|
||||
def test_invalid_range(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[8-]a'))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[-8]a'))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[8--9]a'))
|
||||
|
||||
def test_invalid_range_alphanumeric(self):
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-a]a')), [])
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern('r[a-9]a')), [])
|
||||
|
||||
def test_invalid_range_bounds(self):
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-8]a')), [])
|
||||
self.assertEqual(sorted(expand_alphanumeric_pattern('r[b-a]a')), [])
|
||||
|
||||
def test_invalid_range_len(self):
|
||||
with self.assertRaises(forms.ValidationError):
|
||||
sorted(expand_alphanumeric_pattern('r[a-bb]a'))
|
||||
|
||||
def test_invalid_set(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[a]a'))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[a,]a'))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[,a]a'))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
sorted(expand_alphanumeric_pattern('r[a,,b]a'))
|
||||
@@ -4,6 +4,7 @@ from collections import OrderedDict
|
||||
|
||||
from django.core.serializers import serialize
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from jinja2 import Environment
|
||||
|
||||
from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER
|
||||
|
||||
@@ -174,3 +175,10 @@ def to_meters(length, unit):
|
||||
if unit == LENGTH_UNIT_INCH:
|
||||
return length * 0.3048 * 12
|
||||
raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit))
|
||||
|
||||
|
||||
def render_jinja2(template_code, context):
|
||||
"""
|
||||
Render a Jinja2 template with the provided context. Return the rendered content.
|
||||
"""
|
||||
return Environment().from_string(source=template_code).render(**context)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
from netaddr import EUI
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import DeviceRole, Interface, Platform, Region, Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilter
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
@@ -13,6 +11,15 @@ from .constants import *
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ClusterFilter',
|
||||
'ClusterGroupFilter',
|
||||
'ClusterTypeFilter',
|
||||
'InterfaceFilter',
|
||||
'VirtualMachineFilter',
|
||||
)
|
||||
|
||||
|
||||
class ClusterTypeFilter(NameSlugSearchFilterSet):
|
||||
|
||||
class Meta:
|
||||
@@ -27,7 +34,7 @@ class ClusterGroupFilter(NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class ClusterFilter(CustomFieldFilterSet):
|
||||
class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -36,6 +43,27 @@ class ClusterFilter(CustomFieldFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
label='Parent group (ID)',
|
||||
@@ -56,16 +84,6 @@ class ClusterFilter(CustomFieldFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Cluster type (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
@@ -81,7 +99,7 @@ class ClusterFilter(CustomFieldFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
class VirtualMachineFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -197,8 +215,7 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Virtual machine',
|
||||
)
|
||||
mac_address = django_filters.CharFilter(
|
||||
method='_mac_address',
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
label='MAC address',
|
||||
)
|
||||
|
||||
@@ -206,16 +223,6 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
model = Interface
|
||||
fields = ['id', 'name', 'enabled', 'mtu']
|
||||
|
||||
def _mac_address(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
try:
|
||||
mac = EUI(value.strip())
|
||||
return queryset.filter(mac_address=mac)
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
|
||||
@@ -173,6 +173,29 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
|
||||
class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Cluster
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field='slug',
|
||||
null_option=True,
|
||||
)
|
||||
)
|
||||
type = FilterChoiceField(
|
||||
queryset=ClusterType.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -193,17 +216,6 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
null_option=True,
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --',
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field='slug',
|
||||
null_option=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
@@ -563,7 +575,9 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/dcim/regions/',
|
||||
value_field="slug",
|
||||
null_option=True,
|
||||
filter_for={
|
||||
'site': 'region'
|
||||
}
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
|
||||
373
netbox/virtualization/tests/test_filters.py
Normal file
373
netbox/virtualization/tests/test_filters.py
Normal file
@@ -0,0 +1,373 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Interface, Platform, Region, Site
|
||||
from virtualization.constants import *
|
||||
from virtualization.filters import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
class ClusterTypeTestCase(TestCase):
|
||||
queryset = ClusterType.objects.all()
|
||||
filterset = ClusterTypeFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
|
||||
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
|
||||
)
|
||||
ClusterType.objects.bulk_create(cluster_types)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Cluster Type 1', 'Cluster Type 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['cluster-type-1', 'cluster-type-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ClusterGroupTestCase(TestCase):
|
||||
queryset = ClusterGroup.objects.all()
|
||||
filterset = ClusterGroupFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
cluster_groups = (
|
||||
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
|
||||
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
|
||||
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
|
||||
)
|
||||
ClusterGroup.objects.bulk_create(cluster_groups)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Cluster Group 1', 'Cluster Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['cluster-group-1', 'cluster-group-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ClusterTestCase(TestCase):
|
||||
queryset = Cluster.objects.all()
|
||||
filterset = ClusterFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
|
||||
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
|
||||
)
|
||||
ClusterType.objects.bulk_create(cluster_types)
|
||||
|
||||
cluster_groups = (
|
||||
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
|
||||
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
|
||||
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
|
||||
)
|
||||
ClusterGroup.objects.bulk_create(cluster_groups)
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Cluster 1', 'Cluster 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_group(self):
|
||||
groups = ClusterGroup.objects.all()[:2]
|
||||
params = {'group_id': [groups[0].pk, groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [groups[0].slug, groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
types = ClusterType.objects.all()[:2]
|
||||
params = {'type_id': [types[0].pk, types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'type': [types[0].slug, types[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class VirtualMachineTestCase(TestCase):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
filterset = VirtualMachineFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
|
||||
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
|
||||
)
|
||||
ClusterType.objects.bulk_create(cluster_types)
|
||||
|
||||
cluster_groups = (
|
||||
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
|
||||
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
|
||||
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
|
||||
)
|
||||
ClusterGroup.objects.bulk_create(cluster_groups)
|
||||
|
||||
regions = (
|
||||
Region(name='Test Region 1', slug='test-region-1'),
|
||||
Region(name='Test Region 2', slug='test-region-2'),
|
||||
Region(name='Test Region 3', slug='test-region-3'),
|
||||
)
|
||||
# Can't use bulk_create for models with MPTT fields
|
||||
for r in regions:
|
||||
r.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
platforms = (
|
||||
Platform(name='Platform 1', slug='platform-1'),
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
Platform(name='Platform 3', slug='platform-3'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(roles)
|
||||
|
||||
vms = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], status=DEVICE_STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], status=DEVICE_STATUS_STAGED, vcpus=2, memory=2, disk=2),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], status=DEVICE_STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(vms)
|
||||
|
||||
interfaces = (
|
||||
Interface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
|
||||
Interface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
|
||||
Interface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_vcpus(self):
|
||||
params = {'vcpus': [1, 2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_memory(self):
|
||||
params = {'memory': [1, 2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_disk(self):
|
||||
params = {'disk': [1, 2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_id__in(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id__in': ','.join([str(id) for id in id_list])}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [DEVICE_STATUS_ACTIVE, DEVICE_STATUS_STAGED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cluster_group(self):
|
||||
groups = ClusterGroup.objects.all()[:2]
|
||||
params = {'cluster_group_id': [groups[0].pk, groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cluster_group': [groups[0].slug, groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cluster_type(self):
|
||||
types = ClusterType.objects.all()[:2]
|
||||
params = {'cluster_type_id': [types[0].pk, types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cluster_type': [types[0].slug, types[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cluster(self):
|
||||
clusters = Cluster.objects.all()[:2]
|
||||
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
# TODO: 'cluster' should match on name
|
||||
# params = {'cluster': [clusters[0].name, clusters[1].name]}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'region': [regions[0].slug, regions[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
roles = DeviceRole.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'role': [roles[0].slug, roles[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_platform(self):
|
||||
platforms = Platform.objects.all()[:2]
|
||||
params = {'platform_id': [platforms[0].pk, platforms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'platform': [platforms[0].slug, platforms[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mac_address(self):
|
||||
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_local_context_data(self):
|
||||
params = {'local_context_data': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'local_context_data': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase):
|
||||
queryset = Interface.objects.all()
|
||||
filterset = InterfaceFilter
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
|
||||
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
|
||||
)
|
||||
ClusterType.objects.bulk_create(cluster_types)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_types[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[2]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
vms = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(vms)
|
||||
|
||||
interfaces = (
|
||||
Interface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'),
|
||||
Interface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'),
|
||||
Interface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
def test_id(self):
|
||||
id_list = self.queryset.values_list('id', flat=True)[:2]
|
||||
params = {'id': [str(id) for id in id_list]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Interface 1', 'Interface 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_assigned_to_interface(self):
|
||||
params = {'enabled': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_mtu(self):
|
||||
params = {'mtu': [100, 200]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_virtual_machine(self):
|
||||
vms = VirtualMachine.objects.all()[:2]
|
||||
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mac_address(self):
|
||||
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -9,6 +9,24 @@
|
||||
|
||||
exec 1>&2
|
||||
|
||||
EXIT=0
|
||||
RED='\033[0;31m'
|
||||
NOCOLOR='\033[0m'
|
||||
|
||||
echo "Validating PEP8 compliance..."
|
||||
pycodestyle --ignore=W504,E501 netbox/
|
||||
if [ $? != 0 ]; then
|
||||
EXIT=1
|
||||
fi
|
||||
|
||||
echo "Checking for missing migrations..."
|
||||
python netbox/manage.py makemigrations --dry-run --check
|
||||
if [ $? != 0 ]; then
|
||||
EXIT=1
|
||||
fi
|
||||
|
||||
if [ $EXIT != 0 ]; then
|
||||
printf "${RED}COMMIT FAILED${NOCOLOR}\n"
|
||||
fi
|
||||
|
||||
exit $EXIT
|
||||
|
||||
Reference in New Issue
Block a user