mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-20 00:56:59 +01:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e9685d30 | ||
|
|
d2dcc51430 | ||
|
|
214c1d5a50 | ||
|
|
38be0b4976 | ||
|
|
b86847c57e | ||
|
|
8ba5d03280 | ||
|
|
879ffd648b | ||
|
|
030c573037 | ||
|
|
713e79c1a9 | ||
|
|
383cdb5340 | ||
|
|
7b3f6f1c67 | ||
|
|
9cb29f48a0 | ||
|
|
5e29679968 | ||
|
|
84f3ab90df | ||
|
|
34db2eb611 | ||
|
|
16d8981a3f | ||
|
|
e67c965180 | ||
|
|
b0abfee35b | ||
|
|
1e8ee5e59b | ||
|
|
cc0830bf28 | ||
|
|
e3e005e327 | ||
|
|
42afd80e82 | ||
|
|
0b2862be54 | ||
|
|
8d703ffb36 | ||
|
|
6f24a938d9 | ||
|
|
574b57eadb | ||
|
|
aa05097fca | ||
|
|
de58f53f9f | ||
|
|
e738ff2fa7 | ||
|
|
25f501fb12 | ||
|
|
baf045aed6 | ||
|
|
e813dda275 | ||
|
|
ca131f12db | ||
|
|
ca72b07947 | ||
|
|
13d8957cf1 | ||
|
|
ca11b74c8e | ||
|
|
2ba6a6fc45 | ||
|
|
a6e79a1d61 | ||
|
|
1f4263aa6d | ||
|
|
147a4cbfb0 | ||
|
|
ab0a2abc54 | ||
|
|
57abbf1058 | ||
|
|
2a95e1bf71 | ||
|
|
bd957612c6 | ||
|
|
908e6a7a38 | ||
|
|
4493c31216 | ||
|
|
7a813349f3 | ||
|
|
ad7b8a9ac8 | ||
|
|
a226f06b1b | ||
|
|
b55c85b2af | ||
|
|
0d1d14bcd6 | ||
|
|
8c1a01d5ab | ||
|
|
cf8fdacfa3 | ||
|
|
2c1745ce28 | ||
|
|
950ce94653 | ||
|
|
851f8a1585 | ||
|
|
d40d1638af | ||
|
|
26ceeb61ef | ||
|
|
a39a9c9b56 | ||
|
|
45988b9818 | ||
|
|
7234b3bbf8 | ||
|
|
513ecd7e26 | ||
|
|
e12314ba60 | ||
|
|
9226302742 | ||
|
|
0e8c6ee522 | ||
|
|
a9c1c8968e | ||
|
|
6a15c2ae86 | ||
|
|
752de0d9c0 | ||
|
|
49617a595d | ||
|
|
2a293d5d02 | ||
|
|
9d99ede024 | ||
|
|
4a13ee6f40 | ||
|
|
2ba840c72c | ||
|
|
46cd55151d | ||
|
|
4d9691c8e5 | ||
|
|
f1687ef53d | ||
|
|
2fb55374b9 | ||
|
|
312246fec2 | ||
|
|
27c0e6dd5e | ||
|
|
0d7986e082 | ||
|
|
94300b221e | ||
|
|
a1110b07de | ||
|
|
a3069239e9 | ||
|
|
69f083428d | ||
|
|
113358f2de | ||
|
|
caa2813d0d | ||
|
|
481046c8b8 | ||
|
|
83f70dc28c | ||
|
|
8ede7a9297 | ||
|
|
ddff193786 | ||
|
|
774dff07ee | ||
|
|
4b14b31853 | ||
|
|
b0addfbe13 | ||
|
|
593874b45f | ||
|
|
b207f28402 | ||
|
|
7bdde47473 | ||
|
|
a2eb0d80d2 | ||
|
|
6f94198934 | ||
|
|
707e51d855 | ||
|
|
528df76747 | ||
|
|
662c896480 | ||
|
|
29eb2383d6 | ||
|
|
9772c5705f | ||
|
|
d2fe59ae8f | ||
|
|
d5e5cdda23 | ||
|
|
f63dcb1f08 | ||
|
|
6f66b27507 | ||
|
|
909d127c27 | ||
|
|
20ef18f98f | ||
|
|
a33e47780b | ||
|
|
691c66d2f5 | ||
|
|
14d87a3584 | ||
|
|
d743dc160a | ||
|
|
2b263b054c | ||
|
|
b95e8350d2 | ||
|
|
5235866d05 | ||
|
|
093a86bc38 | ||
|
|
5b87232f59 | ||
|
|
679bbd3e76 | ||
|
|
515b6bf71a | ||
|
|
9c389d9dcb | ||
|
|
f1e4273a23 | ||
|
|
4618cc2b22 | ||
|
|
1909f0c733 | ||
|
|
840ea36f70 | ||
|
|
a8cdb3895b | ||
|
|
349733c6dd | ||
|
|
1c09ffdd1f | ||
|
|
c4c6fa6042 | ||
|
|
86da6c6c14 | ||
|
|
7b7b01a26b | ||
|
|
415313ac2f | ||
|
|
7db2b9d091 | ||
|
|
8036d1e5a5 | ||
|
|
65c9339687 | ||
|
|
3090981335 | ||
|
|
4f36885c5e | ||
|
|
db2993035d | ||
|
|
bf05bc2986 | ||
|
|
88b230f0e4 | ||
|
|
deb53d771d | ||
|
|
b44ec35ade |
8
.gitattributes
vendored
8
.gitattributes
vendored
@@ -1,5 +1,5 @@
|
||||
*.sh text eol=lf
|
||||
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
|
||||
*.min.* binary
|
||||
*.map binary
|
||||
*.pack.js binary
|
||||
# Treat compiled JS/CSS files as binary, as they're not meant to be human-readable
|
||||
netbox/project-static/dist/*.css binary
|
||||
netbox/project-static/dist/*.js binary
|
||||
netbox/project-static/dist/*.js.map binary
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -17,7 +17,7 @@ body:
|
||||
What version of NetBox are you currently running? (If you don't have access to the most
|
||||
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
|
||||
before opening a bug report to see if your issue has already been addressed.)
|
||||
placeholder: v3.0.0
|
||||
placeholder: v3.0.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.0.0
|
||||
placeholder: v3.0.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -30,8 +30,10 @@ body:
|
||||
attributes:
|
||||
label: Proposed functionality
|
||||
description: >
|
||||
Describe in detail the new feature or behavior you'd like to propose. Include any specific
|
||||
changes to work flows, data models, or the user interface.
|
||||
Describe in detail the new feature or behavior you are proposing. Include any specific changes
|
||||
to work flows, data models, and/or the user interface. The more detail you provide here, the
|
||||
greater chance your proposal has of being discussed. Feature requests which don't include an
|
||||
actionable implementation plan will be rejected.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -58,6 +58,9 @@ jobs:
|
||||
|
||||
- name: Check UI ESLint, TypeScript, and Prettier Compliance
|
||||
run: yarn --cwd netbox/project-static validate
|
||||
|
||||
- name: Validate Static Asset Integrity
|
||||
run: scripts/verify-bundles.sh
|
||||
|
||||
- name: Run tests
|
||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,16 +1,13 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/netbox/project-static/.cache
|
||||
/netbox/project-static/node_modules
|
||||
/netbox/project-static/docs/*
|
||||
!/netbox/project-static/docs/.info
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/project-static/.cache
|
||||
/netbox/project-static/node_modules
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
|
||||
When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable.
|
||||
|
||||
!!! warning
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized environment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
|
||||
|
||||
{!docs/models/users/objectpermission.md!}
|
||||
{!models/users/objectpermission.md!}
|
||||
|
||||
### Example Constraint Definitions
|
||||
|
||||
|
||||
@@ -71,14 +71,3 @@ To extract the saved archive into a new installation, run the following from the
|
||||
```no-highlight
|
||||
tar -xf netbox_media.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
|
||||
|
||||
```no-highlight
|
||||
# source /opt/netbox/venv/bin/activate
|
||||
(venv) # python3 manage.py invalidate all
|
||||
```
|
||||
|
||||
@@ -490,6 +490,14 @@ NetBox can be configured to support remote user authentication by inferring user
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_GROUP_SYNC_ENABLED
|
||||
|
||||
Default: `False`
|
||||
|
||||
NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_HEADER
|
||||
|
||||
Default: `'HTTP_REMOTE_USER'`
|
||||
@@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_GROUP_HEADER
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_GROUP'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_SUPERUSER_GROUPS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_SUPERUSERS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_STAFF_GROUPS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_STAFF_USERS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_GROUP_SEPARATOR
|
||||
|
||||
Default: `|` (Pipe)
|
||||
|
||||
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
## RELEASE_CHECK_URL
|
||||
|
||||
Default: None (disabled)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Circuits
|
||||
|
||||
{!docs/models/circuits/provider.md!}
|
||||
{!docs/models/circuits/providernetwork.md!}
|
||||
{!models/circuits/provider.md!}
|
||||
{!models/circuits/providernetwork.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/circuits/circuit.md!}
|
||||
{!docs/models/circuits/circuittype.md!}
|
||||
{!docs/models/circuits/circuittermination.md!}
|
||||
{!models/circuits/circuit.md!}
|
||||
{!models/circuits/circuittype.md!}
|
||||
{!models/circuits/circuittermination.md!}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Device Types
|
||||
|
||||
{!docs/models/dcim/devicetype.md!}
|
||||
{!docs/models/dcim/manufacturer.md!}
|
||||
{!models/dcim/devicetype.md!}
|
||||
{!models/dcim/manufacturer.md!}
|
||||
|
||||
---
|
||||
|
||||
@@ -30,11 +30,11 @@ Once component templates have been created, every new device that you create as
|
||||
!!! note
|
||||
Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices.
|
||||
|
||||
{!docs/models/dcim/consoleporttemplate.md!}
|
||||
{!docs/models/dcim/consoleserverporttemplate.md!}
|
||||
{!docs/models/dcim/powerporttemplate.md!}
|
||||
{!docs/models/dcim/poweroutlettemplate.md!}
|
||||
{!docs/models/dcim/interfacetemplate.md!}
|
||||
{!docs/models/dcim/frontporttemplate.md!}
|
||||
{!docs/models/dcim/rearporttemplate.md!}
|
||||
{!docs/models/dcim/devicebaytemplate.md!}
|
||||
{!models/dcim/consoleporttemplate.md!}
|
||||
{!models/dcim/consoleserverporttemplate.md!}
|
||||
{!models/dcim/powerporttemplate.md!}
|
||||
{!models/dcim/poweroutlettemplate.md!}
|
||||
{!models/dcim/interfacetemplate.md!}
|
||||
{!models/dcim/frontporttemplate.md!}
|
||||
{!models/dcim/rearporttemplate.md!}
|
||||
{!models/dcim/devicebaytemplate.md!}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Devices and Cabling
|
||||
|
||||
{!docs/models/dcim/device.md!}
|
||||
{!docs/models/dcim/devicerole.md!}
|
||||
{!docs/models/dcim/platform.md!}
|
||||
{!models/dcim/device.md!}
|
||||
{!models/dcim/devicerole.md!}
|
||||
{!models/dcim/platform.md!}
|
||||
|
||||
---
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
|
||||
Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources.
|
||||
|
||||
{!docs/models/dcim/consoleport.md!}
|
||||
{!docs/models/dcim/consoleserverport.md!}
|
||||
{!docs/models/dcim/powerport.md!}
|
||||
{!docs/models/dcim/poweroutlet.md!}
|
||||
{!docs/models/dcim/interface.md!}
|
||||
{!docs/models/dcim/frontport.md!}
|
||||
{!docs/models/dcim/rearport.md!}
|
||||
{!docs/models/dcim/devicebay.md!}
|
||||
{!docs/models/dcim/inventoryitem.md!}
|
||||
{!models/dcim/consoleport.md!}
|
||||
{!models/dcim/consoleserverport.md!}
|
||||
{!models/dcim/powerport.md!}
|
||||
{!models/dcim/poweroutlet.md!}
|
||||
{!models/dcim/interface.md!}
|
||||
{!models/dcim/frontport.md!}
|
||||
{!models/dcim/rearport.md!}
|
||||
{!models/dcim/devicebay.md!}
|
||||
{!models/dcim/inventoryitem.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/dcim/virtualchassis.md!}
|
||||
{!models/dcim/virtualchassis.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/dcim/cable.md!}
|
||||
{!models/dcim/cable.md!}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# IP Address Management
|
||||
|
||||
{!docs/models/ipam/aggregate.md!}
|
||||
{!docs/models/ipam/rir.md!}
|
||||
{!models/ipam/aggregate.md!}
|
||||
{!models/ipam/rir.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/ipam/prefix.md!}
|
||||
{!docs/models/ipam/role.md!}
|
||||
{!models/ipam/prefix.md!}
|
||||
{!models/ipam/role.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/ipam/iprange.md!}
|
||||
{!docs/models/ipam/ipaddress.md!}
|
||||
{!models/ipam/iprange.md!}
|
||||
{!models/ipam/ipaddress.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/ipam/vrf.md!}
|
||||
{!docs/models/ipam/routetarget.md!}
|
||||
{!models/ipam/vrf.md!}
|
||||
{!models/ipam/routetarget.md!}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Power Tracking
|
||||
|
||||
{!docs/models/dcim/powerpanel.md!}
|
||||
{!docs/models/dcim/powerfeed.md!}
|
||||
{!models/dcim/powerpanel.md!}
|
||||
{!models/dcim/powerfeed.md!}
|
||||
|
||||
# Example Power Topology
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Service Mapping
|
||||
|
||||
{!docs/models/ipam/service.md!}
|
||||
{!models/ipam/service.md!}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Sites and Racks
|
||||
|
||||
{!docs/models/dcim/region.md!}
|
||||
{!docs/models/dcim/sitegroup.md!}
|
||||
{!docs/models/dcim/site.md!}
|
||||
{!docs/models/dcim/location.md!}
|
||||
{!models/dcim/region.md!}
|
||||
{!models/dcim/sitegroup.md!}
|
||||
{!models/dcim/site.md!}
|
||||
{!models/dcim/location.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/dcim/rack.md!}
|
||||
{!docs/models/dcim/rackrole.md!}
|
||||
{!docs/models/dcim/rackreservation.md!}
|
||||
{!models/dcim/rack.md!}
|
||||
{!models/dcim/rackrole.md!}
|
||||
{!models/dcim/rackreservation.md!}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Tenancy Assignment
|
||||
|
||||
{!docs/models/tenancy/tenant.md!}
|
||||
{!docs/models/tenancy/tenantgroup.md!}
|
||||
{!models/tenancy/tenant.md!}
|
||||
{!models/tenancy/tenantgroup.md!}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Virtualization
|
||||
|
||||
{!docs/models/virtualization/cluster.md!}
|
||||
{!docs/models/virtualization/clustertype.md!}
|
||||
{!docs/models/virtualization/clustergroup.md!}
|
||||
{!models/virtualization/cluster.md!}
|
||||
{!models/virtualization/clustertype.md!}
|
||||
{!models/virtualization/clustergroup.md!}
|
||||
|
||||
---
|
||||
|
||||
{!docs/models/virtualization/virtualmachine.md!}
|
||||
{!docs/models/virtualization/vminterface.md!}
|
||||
{!models/virtualization/virtualmachine.md!}
|
||||
{!models/virtualization/vminterface.md!}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# VLAN Management
|
||||
|
||||
{!docs/models/ipam/vlan.md!}
|
||||
{!docs/models/ipam/vlangroup.md!}
|
||||
{!models/ipam/vlan.md!}
|
||||
{!models/ipam/vlangroup.md!}
|
||||
|
||||
@@ -226,7 +226,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ class Foo(models.Model):
|
||||
|
||||
## 3. Update relevant querysets
|
||||
|
||||
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
|
||||
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retrieving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
|
||||
|
||||
## 4. Update API serializer
|
||||
|
||||
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
|
||||
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
|
||||
|
||||
## 5. Add field to forms
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
http://netbox/graphql/ \
|
||||
--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}'
|
||||
--data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}'
|
||||
```
|
||||
|
||||
The response will include the requested data formatted as JSON:
|
||||
@@ -45,7 +45,7 @@ NetBox provides both a singular and plural query field for each object type:
|
||||
* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`.
|
||||
* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters.
|
||||
|
||||
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of fitlers) to fetch all devices.
|
||||
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
|
||||
|
||||
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
|
||||
|
||||
@@ -54,7 +54,7 @@ For more detail on constructing GraphQL queries, see the [Graphene documentation
|
||||
The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
|
||||
|
||||
```
|
||||
{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"}
|
||||
{"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -10,7 +10,6 @@ NetBox is an infrastructure resource modeling (IRM) application designed to empo
|
||||
* **Connections** - Network, console, and power connections among devices
|
||||
* **Virtualization** - Virtual machines and clusters
|
||||
* **Data circuits** - Long-haul communications circuits and providers
|
||||
* **Secrets** - Encrypted storage of sensitive credentials
|
||||
|
||||
## What NetBox Is Not
|
||||
|
||||
|
||||
@@ -70,19 +70,22 @@ If `git` is not already installed, install it:
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
|
||||
|
||||
```no-highlight
|
||||
sudo git clone -b master https://github.com/netbox-community/netbox.git .
|
||||
sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git .
|
||||
```
|
||||
|
||||
!!! note
|
||||
The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument.
|
||||
|
||||
The `git clone` command should generate output similar to the following:
|
||||
|
||||
```
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
remote: Compressing objects: 100% (150/150), done.
|
||||
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
|
||||
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
|
||||
Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
remote: Enumerating objects: 996, done.
|
||||
remote: Counting objects: 100% (996/996), done.
|
||||
remote: Compressing objects: 100% (935/935), done.
|
||||
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
|
||||
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
|
||||
Resolving deltas: 100% (148/148), done.
|
||||
```
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -14,7 +14,7 @@ While the provided configuration should suffice for most initial installations,
|
||||
|
||||
## systemd Setup
|
||||
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
|
||||
|
||||
```no-highlight
|
||||
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
||||
|
||||
@@ -13,7 +13,7 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ A cable may be traced from either of its endpoints by clicking the "trace" butto
|
||||
|
||||
In the example below, three individual cables comprise a path between devices A and D:
|
||||
|
||||

|
||||

|
||||
|
||||
Traced from Interface 1 on Device A, NetBox will show the following path:
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ A platform defines the type of software running on a device or virtual machine.
|
||||
|
||||
Platforms may optionally be limited by manufacturer: If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
||||
|
||||
The platform model is also used to indicate which [NAPALM](../../additional-features/napalm.md) driver and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
|
||||
The platform model is also used to indicate which NAPALM driver (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
|
||||
|
||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||
|
||||
@@ -17,12 +17,12 @@ However, keep in mind that each piece of functionality is entirely optional. For
|
||||
|
||||
## Initial Setup
|
||||
|
||||
## Plugin Structure
|
||||
### Plugin Structure
|
||||
|
||||
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
|
||||
|
||||
```no-highlight
|
||||
plugin_name/
|
||||
project-name/
|
||||
- plugin_name/
|
||||
- templates/
|
||||
- plugin_name/
|
||||
@@ -38,13 +38,13 @@ plugin_name/
|
||||
- setup.py
|
||||
```
|
||||
|
||||
The top level is the project root. Immediately within the root should exist several items:
|
||||
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
|
||||
|
||||
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
|
||||
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
|
||||
* The plugin source directory, with the same name as your plugin.
|
||||
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
|
||||
|
||||
The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
|
||||
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
|
||||
|
||||
### Create setup.py
|
||||
|
||||
@@ -118,6 +118,21 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
|
||||
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||
|
||||
### Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
```shell
|
||||
python3 -m venv /path/to/my/venv
|
||||
```
|
||||
|
||||
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
|
||||
|
||||
```shell
|
||||
cd $VENV/lib/python3.7/site-packages/
|
||||
echo /opt/netbox/netbox > netbox.pth
|
||||
```
|
||||
|
||||
### Install the Plugin for Development
|
||||
|
||||
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
|
||||
@@ -218,7 +233,7 @@ NetBox provides a base template to ensure a consistent user experience, which pl
|
||||
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
|
||||
|
||||
```jinja2
|
||||
{% extends 'base.html' %}
|
||||
{% extends 'base/layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
# NetBox v3.0
|
||||
|
||||
## v3.0.3 (2021-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5775](https://github.com/netbox-community/netbox/issues/5775) - Enable synchronization of groups for remote authentication backend
|
||||
* [#6387](https://github.com/netbox-community/netbox/issues/6387) - Add xDSL interface type
|
||||
* [#6988](https://github.com/netbox-community/netbox/issues/6988) - Order tenants alphabetically without regard to group assignment
|
||||
* [#7032](https://github.com/netbox-community/netbox/issues/7032) - Add URM port types
|
||||
* [#7087](https://github.com/netbox-community/netbox/issues/7087) - Add `local_context_data` filter for virtual machines list
|
||||
* [#7208](https://github.com/netbox-community/netbox/issues/7208) - Add navigation breadcrumbs for custom scripts & reports
|
||||
* [#7210](https://github.com/netbox-community/netbox/issues/7210) - Add search/filter forms for all organizational models
|
||||
* [#7239](https://github.com/netbox-community/netbox/issues/7239) - Redirect global search to filtered object list when an object type is selected
|
||||
* [#7284](https://github.com/netbox-community/netbox/issues/7284) - Include comments field in table/export for all appropriate models
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7167](https://github.com/netbox-community/netbox/issues/7167) - Ensure consistent font size when using monospace formatting
|
||||
* [#7226](https://github.com/netbox-community/netbox/issues/7226) - Exempt GraphQL API requests from CSRF inspection
|
||||
* [#7228](https://github.com/netbox-community/netbox/issues/7228) - Improve temperature conversions under device status
|
||||
* [#7248](https://github.com/netbox-community/netbox/issues/7248) - Fix global search results section links
|
||||
* [#7266](https://github.com/netbox-community/netbox/issues/7266) - Tweak font color for form field placeholder text
|
||||
* [#7273](https://github.com/netbox-community/netbox/issues/7273) - Fix natural ordering of device components in UI form fields
|
||||
* [#7279](https://github.com/netbox-community/netbox/issues/7279) - Fix exception when tracing cable with no associated path
|
||||
* [#7282](https://github.com/netbox-community/netbox/issues/7282) - Fix KeyError exception when `INSECURE_SKIP_TLS_VERIFY` is true
|
||||
* [#7298](https://github.com/netbox-community/netbox/issues/7298) - Restore missing object names from applied object list filters
|
||||
* [#7301](https://github.com/netbox-community/netbox/issues/7301) - Fix exception when deleting a large number of child prefixes
|
||||
|
||||
---
|
||||
|
||||
## v3.0.2 (2021-09-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7131](https://github.com/netbox-community/netbox/issues/7131) - Fix issue where Site fields were hidden when editing a VLAN group
|
||||
* [#7148](https://github.com/netbox-community/netbox/issues/7148) - Fix issue where static query parameters with multiple values were not queried properly
|
||||
* [#7153](https://github.com/netbox-community/netbox/issues/7153) - Allow clearing of assigned device type images
|
||||
* [#7162](https://github.com/netbox-community/netbox/issues/7162) - Ensure consistent treatment of `BASE_PATH` for UI-driven API requests
|
||||
* [#7164](https://github.com/netbox-community/netbox/issues/7164) - Fix styling of "decommissioned" label for circuits
|
||||
* [#7169](https://github.com/netbox-community/netbox/issues/7169) - Fix CSV import file upload
|
||||
* [#7176](https://github.com/netbox-community/netbox/issues/7176) - Fix issue where query parameters were duplicated across different forms of the same type
|
||||
* [#7179](https://github.com/netbox-community/netbox/issues/7179) - Prevent obscuring "connect" pop-up for interfaces under device view
|
||||
* [#7188](https://github.com/netbox-community/netbox/issues/7188) - Fix issue where select fields with `null_option` did not render or send the null option
|
||||
* [#7189](https://github.com/netbox-community/netbox/issues/7189) - Set connection factory for django-redis when Sentinel is in use
|
||||
* [#7191](https://github.com/netbox-community/netbox/issues/7191) - Fix issue where API-backed multi-select elements cleared selected options when adding new options
|
||||
* [#7193](https://github.com/netbox-community/netbox/issues/7193) - Fix prefix (flat) template issue when viewing child prefixes with prefixes available
|
||||
* [#7205](https://github.com/netbox-community/netbox/issues/7205) - Fix issue where selected fields with `null_option` set were not added to applied filters
|
||||
* [#7209](https://github.com/netbox-community/netbox/issues/7209) - Allow unlimited API results when `MAX_PAGE_SIZE` is disabled
|
||||
|
||||
---
|
||||
|
||||
## v3.0.1 (2021-09-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device
|
||||
* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI
|
||||
* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM
|
||||
* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views
|
||||
* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name
|
||||
* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews
|
||||
* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data
|
||||
* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table
|
||||
* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute
|
||||
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface
|
||||
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission
|
||||
* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type
|
||||
* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables
|
||||
* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH`
|
||||
* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table
|
||||
* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match
|
||||
* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration
|
||||
* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination
|
||||
* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field
|
||||
* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab
|
||||
* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests
|
||||
* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects
|
||||
* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field
|
||||
* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select
|
||||
|
||||
---
|
||||
|
||||
## v3.0.0 (2021-08-30)
|
||||
|
||||
!!! warning "Existing Deployments Must Upgrade from v2.11"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
|
||||
|
||||
{!docs/models/users/token.md!}
|
||||
{!models/users/token.md!}
|
||||
|
||||
## Authenticating to the API
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ site_dir: netbox/project-static/docs
|
||||
site_url: https://netbox.readthedocs.io/
|
||||
repo_name: netbox-community/netbox
|
||||
repo_url: https://github.com/netbox-community/netbox
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
theme:
|
||||
name: material
|
||||
icon:
|
||||
@@ -24,13 +21,14 @@ extra:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/netbox-community/netbox
|
||||
- icon: fontawesome/brands/slack
|
||||
link: https://slack.netbox.dev
|
||||
link: https://netdev.chat/
|
||||
extra_css:
|
||||
- extra.css
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- attr_list
|
||||
- markdown_include.include:
|
||||
base_path: 'docs/'
|
||||
headingOffset: 1
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = 'circuits.apps.CircuitsConfig'
|
||||
|
||||
@@ -29,7 +29,7 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_PROVISIONING: 'primary',
|
||||
STATUS_OFFLINE: 'danger',
|
||||
STATUS_DECOMMISSIONED: 'default',
|
||||
STATUS_DECOMMISSIONED: 'secondary',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -266,6 +266,18 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
|
||||
}
|
||||
|
||||
|
||||
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = CircuitType
|
||||
field_groups = [
|
||||
['q'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
@@ -2,10 +2,18 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
|
||||
from .models import *
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CircuitTable',
|
||||
'CircuitTypeTable',
|
||||
'ProviderTable',
|
||||
'ProviderNetworkTable',
|
||||
)
|
||||
|
||||
|
||||
CIRCUITTERMINATION_LINK = """
|
||||
{% if value.site %}
|
||||
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
|
||||
@@ -28,6 +36,7 @@ class ProviderTable(BaseTable):
|
||||
accessor=Accessor('count_circuits'),
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
@@ -35,7 +44,8 @@ class ProviderTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'tags',
|
||||
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
|
||||
'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
@@ -52,13 +62,14 @@ class ProviderNetworkTable(BaseTable):
|
||||
provider = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='circuits:providernetwork_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ProviderNetwork
|
||||
fields = ('pk', 'name', 'provider', 'description', 'tags')
|
||||
fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
|
||||
default_columns = ('pk', 'name', 'provider', 'description')
|
||||
|
||||
|
||||
@@ -105,6 +116,7 @@ class CircuitTable(BaseTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
)
|
||||
@@ -113,7 +125,7 @@ class CircuitTable(BaseTable):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'tags',
|
||||
'commit_rate', 'description', 'comments', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||
|
||||
@@ -34,9 +34,7 @@ class ProviderView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
)
|
||||
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table.columns.hide('provider')
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
|
||||
paginate_table(circuits_table, request)
|
||||
|
||||
return {
|
||||
@@ -97,10 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
)
|
||||
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table.columns.hide('termination_a')
|
||||
circuits_table.columns.hide('termination_z')
|
||||
paginate_table(circuits_table, request)
|
||||
|
||||
return {
|
||||
@@ -144,6 +139,8 @@ class CircuitTypeListView(generic.ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
filterset = filtersets.CircuitTypeFilterSet
|
||||
filterset_form = forms.CircuitTypeFilterForm
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
@@ -151,12 +148,8 @@ class CircuitTypeView(generic.ObjectView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
type=instance
|
||||
)
|
||||
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table.columns.hide('type')
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
|
||||
paginate_table(circuits_table, request)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = 'dcim.apps.DCIMConfig'
|
||||
|
||||
@@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
from utilities.utils import count_related, decode_dict
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
|
||||
continue
|
||||
try:
|
||||
response[method] = getattr(d, method)()
|
||||
response[method] = decode_dict(getattr(d, method)())
|
||||
except NotImplementedError:
|
||||
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
|
||||
except Exception as e:
|
||||
|
||||
@@ -761,6 +761,9 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_T3 = 't3'
|
||||
TYPE_E3 = 'e3'
|
||||
|
||||
# ATM/DSL
|
||||
TYPE_XDSL = 'xdsl'
|
||||
|
||||
# Stacking
|
||||
TYPE_STACKWISE = 'cisco-stackwise'
|
||||
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
|
||||
@@ -885,6 +888,12 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_E3, 'E3 (34 Mbps)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'ATM',
|
||||
(
|
||||
(TYPE_XDSL, 'xDSL'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Stacking',
|
||||
(
|
||||
@@ -958,6 +967,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
TYPE_SN = 'sn'
|
||||
TYPE_URM_P2 = 'urm-p2'
|
||||
TYPE_URM_P4 = 'urm-p4'
|
||||
TYPE_URM_P8 = 'urm-p8'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
@@ -998,6 +1010,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_ST, 'ST'),
|
||||
(TYPE_CS, 'CS'),
|
||||
(TYPE_SN, 'SN'),
|
||||
(TYPE_URM_P2, 'URM-P2'),
|
||||
(TYPE_URM_P4, 'URM-P4'),
|
||||
(TYPE_URM_P8, 'URM-P8'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -23,10 +23,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
ClearableFileInput, ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField,
|
||||
CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
|
||||
JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple,
|
||||
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
@@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form):
|
||||
super().clean()
|
||||
|
||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||
tagged_vlans = self.cleaned_data.get('tagged_vlans')
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
|
||||
@@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form):
|
||||
self.cleaned_data['tagged_vlans'] = []
|
||||
|
||||
# Validate tagged VLANs; must be a global VLAN or in the same site
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
|
||||
valid_sites = [None, self.cleaned_data[parent_field].site]
|
||||
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
|
||||
|
||||
@@ -696,6 +696,18 @@ class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = ['color', 'description']
|
||||
|
||||
|
||||
class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = RackRole
|
||||
field_groups = [
|
||||
['q'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
@@ -1240,6 +1252,18 @@ class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = Manufacturer
|
||||
field_groups = [
|
||||
['q'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
@@ -1271,10 +1295,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
widgets = {
|
||||
'subdevice_role': StaticSelect(),
|
||||
'front_image': forms.ClearableFileInput(attrs={
|
||||
'front_image': ClearableFileInput(attrs={
|
||||
'accept': DEVICETYPE_IMAGE_FORMATS
|
||||
}),
|
||||
'rear_image': forms.ClearableFileInput(attrs={
|
||||
'rear_image': ClearableFileInput(attrs={
|
||||
'accept': DEVICETYPE_IMAGE_FORMATS
|
||||
})
|
||||
}
|
||||
@@ -2076,6 +2100,18 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = ['color', 'description']
|
||||
|
||||
|
||||
class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = DeviceRole
|
||||
field_groups = [
|
||||
['q'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Platforms
|
||||
#
|
||||
@@ -2202,9 +2238,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||
attrs={
|
||||
'disabled-indicator': 'device',
|
||||
'data-query-param-face': "[\"$face\"]",
|
||||
# The UI will not sort this element's options.
|
||||
'pre-sorted': ''
|
||||
'data-query-param-face': "[\"$face\"]"
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -4586,8 +4620,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
|
||||
color = ColorField(
|
||||
required=False
|
||||
)
|
||||
length = forms.IntegerField(
|
||||
min_value=1,
|
||||
length = forms.DecimalField(
|
||||
min_value=0,
|
||||
required=False
|
||||
)
|
||||
length_unit = forms.ChoiceField(
|
||||
|
||||
@@ -9,7 +9,7 @@ from dcim.models import (
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
TagColumn, ToggleColumn,
|
||||
MarkdownColumn, TagColumn, ToggleColumn,
|
||||
)
|
||||
from .template_code import (
|
||||
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
|
||||
@@ -18,6 +18,7 @@ from .template_code import (
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'BaseInterfaceTable',
|
||||
'ConsolePortTable',
|
||||
'ConsoleServerPortTable',
|
||||
'DeviceBayTable',
|
||||
@@ -187,6 +188,7 @@ class DeviceTable(BaseTable):
|
||||
vc_priority = tables.Column(
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
)
|
||||
@@ -196,7 +198,7 @@ class DeviceTable(BaseTable):
|
||||
fields = (
|
||||
'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
|
||||
@@ -5,7 +5,7 @@ from dcim.models import (
|
||||
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
)
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, TagColumn, ToggleColumn,
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -68,6 +68,7 @@ class DeviceTypeTable(BaseTable):
|
||||
url_params={'device_type_id': 'pk'},
|
||||
verbose_name='Instances'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:devicetype_list'
|
||||
)
|
||||
@@ -76,7 +77,7 @@ class DeviceTypeTable(BaseTable):
|
||||
model = DeviceType
|
||||
fields = (
|
||||
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'instance_count', 'tags',
|
||||
'comments', 'instance_count', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import PowerFeed, PowerPanel
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
|
||||
from .devices import CableTerminationTable
|
||||
|
||||
__all__ = (
|
||||
@@ -62,6 +62,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
available_power = tables.Column(
|
||||
verbose_name='Available power (VA)'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerfeed_list'
|
||||
)
|
||||
@@ -71,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
fields = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
|
||||
'tags',
|
||||
'comments', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
||||
@@ -4,13 +4,12 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn, UtilizationColumn,
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
|
||||
TagColumn, ToggleColumn, UtilizationColumn,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
'RackDetailTable',
|
||||
'RackReservationTable',
|
||||
'RackRoleTable',
|
||||
)
|
||||
@@ -56,17 +55,7 @@ class RackTable(BaseTable):
|
||||
template_code="{{ record.u_height }}U",
|
||||
verbose_name='Height'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'u_height',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
|
||||
|
||||
|
||||
class RackDetailTable(RackTable):
|
||||
comments = MarkdownColumn()
|
||||
device_count = LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'rack_id': 'pk'},
|
||||
@@ -84,10 +73,11 @@ class RackDetailTable(RackTable):
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
|
||||
class Meta(RackTable.Meta):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
|
||||
@@ -3,7 +3,7 @@ import django_tables2 as tables
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
|
||||
)
|
||||
from .template_code import LOCATION_ELEVATIONS
|
||||
|
||||
@@ -76,6 +76,7 @@ class SiteTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
)
|
||||
@@ -85,7 +86,7 @@ class SiteTable(BaseTable):
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
|
||||
'contact_email', 'tags',
|
||||
'contact_email', 'comments', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')
|
||||
|
||||
|
||||
@@ -131,8 +131,7 @@ class RegionView(generic.ObjectView):
|
||||
sites = Site.objects.restrict(request.user, 'view').filter(
|
||||
region=instance
|
||||
)
|
||||
sites_table = tables.SiteTable(sites)
|
||||
sites_table.columns.hide('region')
|
||||
sites_table = tables.SiteTable(sites, exclude=('region',))
|
||||
paginate_table(sites_table, request)
|
||||
|
||||
return {
|
||||
@@ -216,8 +215,7 @@ class SiteGroupView(generic.ObjectView):
|
||||
sites = Site.objects.restrict(request.user, 'view').filter(
|
||||
group=instance
|
||||
)
|
||||
sites_table = tables.SiteTable(sites)
|
||||
sites_table.columns.hide('group')
|
||||
sites_table = tables.SiteTable(sites, exclude=('group',))
|
||||
paginate_table(sites_table, request)
|
||||
|
||||
return {
|
||||
@@ -440,6 +438,8 @@ class RackRoleListView(generic.ObjectListView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
filterset = filtersets.RackRoleFilterSet
|
||||
filterset_form = forms.RackRoleFilterForm
|
||||
table = tables.RackRoleTable
|
||||
|
||||
|
||||
@@ -451,8 +451,7 @@ class RackRoleView(generic.ObjectView):
|
||||
role=instance
|
||||
)
|
||||
|
||||
racks_table = tables.RackTable(racks)
|
||||
racks_table.columns.hide('role')
|
||||
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
|
||||
paginate_table(racks_table, request)
|
||||
|
||||
return {
|
||||
@@ -503,7 +502,7 @@ class RackListView(generic.ObjectListView):
|
||||
)
|
||||
filterset = filtersets.RackFilterSet
|
||||
filterset_form = forms.RackFilterForm
|
||||
table = tables.RackDetailTable
|
||||
table = tables.RackTable
|
||||
|
||||
|
||||
class RackElevationListView(generic.ObjectListView):
|
||||
@@ -684,6 +683,8 @@ class ManufacturerListView(generic.ObjectListView):
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
filterset = filtersets.ManufacturerFilterSet
|
||||
filterset_form = forms.ManufacturerFilterForm
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@@ -700,8 +701,7 @@ class ManufacturerView(generic.ObjectView):
|
||||
manufacturer=instance
|
||||
)
|
||||
|
||||
devicetypes_table = tables.DeviceTypeTable(devicetypes)
|
||||
devicetypes_table.columns.hide('manufacturer')
|
||||
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
|
||||
paginate_table(devicetypes_table, request)
|
||||
|
||||
return {
|
||||
@@ -1149,6 +1149,8 @@ class DeviceRoleListView(generic.ObjectListView):
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
filterset = filtersets.DeviceRoleFilterSet
|
||||
filterset_form = forms.DeviceRoleFilterForm
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
|
||||
@@ -1159,9 +1161,7 @@ class DeviceRoleView(generic.ObjectView):
|
||||
devices = Device.objects.restrict(request.user, 'view').filter(
|
||||
device_role=instance
|
||||
)
|
||||
|
||||
devices_table = tables.DeviceTable(devices)
|
||||
devices_table.columns.hide('device_role')
|
||||
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
|
||||
paginate_table(devices_table, request)
|
||||
|
||||
return {
|
||||
@@ -1225,9 +1225,7 @@ class PlatformView(generic.ObjectView):
|
||||
devices = Device.objects.restrict(request.user, 'view').filter(
|
||||
platform=instance
|
||||
)
|
||||
|
||||
devices_table = tables.DeviceTable(devices)
|
||||
devices_table.columns.hide('platform')
|
||||
devices_table = tables.DeviceTable(devices, exclude=('platform',))
|
||||
paginate_table(devices_table, request)
|
||||
|
||||
return {
|
||||
@@ -1872,9 +1870,9 @@ class InterfaceView(generic.ObjectView):
|
||||
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
|
||||
child_interfaces_tables = tables.InterfaceTable(
|
||||
child_interfaces,
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
)
|
||||
child_interfaces_tables.columns.hide('device')
|
||||
|
||||
# Get assigned VLANs and annotate whether each is tagged or untagged
|
||||
vlans = []
|
||||
@@ -2411,6 +2409,12 @@ class PathTraceView(generic.ObjectView):
|
||||
else:
|
||||
path = related_paths.first()
|
||||
|
||||
# No paths found
|
||||
if path is None:
|
||||
return {
|
||||
'path': None
|
||||
}
|
||||
|
||||
# Get the total length of the cable and whether the length is definitive (fully defined)
|
||||
total_length, is_definitive = path.get_total_length() if path else (None, False)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = 'extras.apps.ExtrasConfig'
|
||||
|
||||
@@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
|
||||
CustomFieldTypeChoices.TYPE_DATE,
|
||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
|
||||
self.field_name = f'custom_field_data__{self.field_name}'
|
||||
|
||||
if custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
self.lookup_expr = 'has_key'
|
||||
elif custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
self.lookup_expr = 'icontains'
|
||||
|
||||
|
||||
@@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
#
|
||||
|
||||
class ContentTypeFilterSet(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ['id', 'app_label', 'model']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(app_label__icontains=value) |
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
26
netbox/extras/migrations/0062_clear_secrets_changelog.py
Normal file
26
netbox/extras/migrations/0062_clear_secrets_changelog.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clear_secrets_changelog(apps, schema_editor):
|
||||
"""
|
||||
Delete all ObjectChange records referencing a model within the old secrets app (pre-v3.0).
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
ObjectChange = apps.get_model('extras', 'ObjectChange')
|
||||
|
||||
content_type_ids = ContentType.objects.filter(app_label='secrets').values_list('id', flat=True)
|
||||
ObjectChange.objects.filter(changed_object_type__in=content_type_ids).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0061_extras_change_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=clear_secrets_changelog,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -3,10 +3,23 @@ from django.conf import settings
|
||||
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
|
||||
ToggleColumn,
|
||||
MarkdownColumn, ToggleColumn,
|
||||
)
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextTable',
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
'JournalEntryTable',
|
||||
'ObjectChangeTable',
|
||||
'ObjectJournalTable',
|
||||
'TaggedItemTable',
|
||||
'TagTable',
|
||||
'WebhookTable',
|
||||
)
|
||||
|
||||
CONFIGCONTEXT_ACTIONS = """
|
||||
{% if perms.extras.change_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
|
||||
@@ -232,6 +245,7 @@ class JournalEntryTable(ObjectJournalTable):
|
||||
orderable=False,
|
||||
verbose_name='Object'
|
||||
)
|
||||
comments = MarkdownColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = JournalEntry
|
||||
|
||||
@@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Selection filtering
|
||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
|
||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Multiselect filtering
|
||||
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
@@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
|
||||
'cf6': 'http://foo.example.com/',
|
||||
'cf7': 'http://foo.example.com/',
|
||||
'cf8': 'Foo',
|
||||
'cf9': ['A', 'B'],
|
||||
}),
|
||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||
'cf1': 200,
|
||||
@@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
|
||||
'cf6': 'http://bar.example.com/',
|
||||
'cf7': 'http://bar.example.com/',
|
||||
'cf8': 'Bar',
|
||||
'cf9': ['AA', 'B'],
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
])
|
||||
|
||||
def test_filter_integer(self):
|
||||
@@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
|
||||
|
||||
def test_filter_select(self):
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = 'ipam.apps.IPAMConfig'
|
||||
|
||||
@@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
children = MultiValueNumberFilter(
|
||||
field_name='_children'
|
||||
)
|
||||
mask_length = django_filters.NumberFilter(
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='net_mask_length'
|
||||
)
|
||||
|
||||
@@ -256,7 +256,17 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = ['is_private', 'description']
|
||||
|
||||
|
||||
class RIRFilterForm(BootstrapMixin, forms.Form):
|
||||
class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = RIR
|
||||
field_groups = [
|
||||
['q'],
|
||||
['is_private'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
is_private = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Private'),
|
||||
@@ -413,6 +423,18 @@ class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
model = Role
|
||||
field_groups = [
|
||||
['q'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
@@ -491,11 +513,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class PrefixCSVForm(CustomFieldModelCSVForm):
|
||||
vrf = CSVModelChoiceField(
|
||||
@@ -658,11 +675,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
||||
label=_('Address family'),
|
||||
widget=StaticSelect()
|
||||
)
|
||||
mask_length = forms.ChoiceField(
|
||||
mask_length = forms.MultipleChoiceField(
|
||||
required=False,
|
||||
choices=PREFIX_MASK_LENGTH_CHOICES,
|
||||
label=_('Mask length'),
|
||||
widget=StaticSelect()
|
||||
widget=StaticSelectMultiple()
|
||||
)
|
||||
vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -760,11 +777,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class IPRangeCSVForm(CustomFieldModelCSVForm):
|
||||
vrf = CSVModelChoiceField(
|
||||
@@ -1026,8 +1038,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Initialize primary_for_parent if IP address is already assigned
|
||||
if self.instance.pk and self.instance.assigned_object:
|
||||
parent = self.instance.assigned_object.parent_object
|
||||
@@ -1102,10 +1112,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'role': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
vrf = CSVModelChoiceField(
|
||||
@@ -1256,8 +1262,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
vrf_id = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label='VRF',
|
||||
empty_label='Global'
|
||||
label='VRF'
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -1477,11 +1482,12 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = ['site', 'description']
|
||||
|
||||
|
||||
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['region', 'sitegroup', 'site', 'location', 'rack']
|
||||
]
|
||||
model = VLANGroup
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
|
||||
@@ -825,9 +825,9 @@ class IPAddress(PrimaryModel):
|
||||
if self.pk:
|
||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if parent and getattr(self.assigned_object, attr) != parent:
|
||||
if parent and getattr(self.assigned_object, attr, None) != parent:
|
||||
# Check for a NAT relationship
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent:
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||
f"not assigned to it!"
|
||||
|
||||
4
netbox/ipam/tables/__init__.py
Normal file
4
netbox/ipam/tables/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .ip import *
|
||||
from .services import *
|
||||
from .vlans import *
|
||||
from .vrfs import *
|
||||
@@ -2,14 +2,23 @@ import django_tables2 as tables
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn, UtilizationColumn,
|
||||
)
|
||||
from virtualization.models import VMInterface
|
||||
from .models import *
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
'AggregateTable',
|
||||
'InterfaceIPAddressTable',
|
||||
'IPAddressAssignTable',
|
||||
'IPAddressTable',
|
||||
'IPRangeTable',
|
||||
'PrefixTable',
|
||||
'RIRTable',
|
||||
'RoleTable',
|
||||
)
|
||||
|
||||
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||
|
||||
@@ -25,6 +34,15 @@ PREFIX_LINK = """
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
"""
|
||||
|
||||
PREFIXFLAT_LINK = """
|
||||
{% load helpers %}
|
||||
{% if record.pk %}
|
||||
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
@@ -57,114 +75,6 @@ VRF_LINK = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VRF_TARGETS = """
|
||||
{% for rt in value.all %}
|
||||
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLAN_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
|
||||
{% elif perms.ipam.add_vlan %}
|
||||
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
|
||||
{% else %}
|
||||
{{ record.available }} VLAN{{ record.available|pluralize }} available
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLAN_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ADD_VLAN = """
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
{% if next_vid and perms.ipam.add_vlan %}
|
||||
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_TAGGED = """
|
||||
{% if record.untagged_vlan_id == object.pk %}
|
||||
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rd = tables.Column(
|
||||
verbose_name='RD'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
enforce_unique = BooleanColumn(
|
||||
verbose_name='Unique'
|
||||
)
|
||||
import_targets = tables.TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
export_targets = tables.TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
fields = (
|
||||
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Route targets
|
||||
#
|
||||
|
||||
class RouteTargetTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RouteTarget
|
||||
fields = ('pk', 'name', 'tenant', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'tenant', 'description')
|
||||
|
||||
|
||||
#
|
||||
# RIRs
|
||||
@@ -206,13 +116,6 @@ class AggregateTable(BaseTable):
|
||||
format="Y-m-d",
|
||||
verbose_name='Added'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Aggregate
|
||||
fields = ('pk', 'prefix', 'rir', 'tenant', 'date_added', 'description')
|
||||
|
||||
|
||||
class AggregateDetailTable(AggregateTable):
|
||||
child_count = tables.Column(
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
@@ -224,7 +127,8 @@ class AggregateDetailTable(AggregateTable):
|
||||
url_name='ipam:aggregate_list'
|
||||
)
|
||||
|
||||
class Meta(AggregateTable.Meta):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Aggregate
|
||||
fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
|
||||
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
|
||||
|
||||
@@ -281,10 +185,10 @@ class PrefixTable(BaseTable):
|
||||
template_code=PREFIX_LINK,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
prefix_flat = tables.Column(
|
||||
accessor=Accessor('prefix'),
|
||||
linkify=True,
|
||||
verbose_name='Prefix (Flat)'
|
||||
prefix_flat = tables.TemplateColumn(
|
||||
template_code=PREFIXFLAT_LINK,
|
||||
attrs={'td': {'class': 'text-nowrap'}},
|
||||
verbose_name='Prefix (Flat)',
|
||||
)
|
||||
depth = tables.Column(
|
||||
accessor=Accessor('_depth'),
|
||||
@@ -323,20 +227,6 @@ class PrefixTable(BaseTable):
|
||||
mark_utilized = BooleanColumn(
|
||||
verbose_name='Marked Utilized'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'prefix', 'prefix_flat', 'status', 'depth', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role',
|
||||
'is_pool', 'mark_utilized', 'description',
|
||||
)
|
||||
default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not record.pk else '',
|
||||
}
|
||||
|
||||
|
||||
class PrefixDetailTable(PrefixTable):
|
||||
utilization = PrefixUtilizationColumn(
|
||||
accessor='get_utilization',
|
||||
orderable=False
|
||||
@@ -345,7 +235,8 @@ class PrefixDetailTable(PrefixTable):
|
||||
url_name='ipam:prefix_list'
|
||||
)
|
||||
|
||||
class Meta(PrefixTable.Meta):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
|
||||
'is_pool', 'mark_utilized', 'description', 'tags',
|
||||
@@ -353,6 +244,9 @@ class PrefixDetailTable(PrefixTable):
|
||||
default_columns = (
|
||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not record.pk else '',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
@@ -418,25 +312,11 @@ class IPAddressTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Device/VM'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
|
||||
'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
|
||||
|
||||
class IPAddressDetailTable(IPAddressTable):
|
||||
nat_inside = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='NAT (Inside)'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
assigned = BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
@@ -445,14 +325,18 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
url_name='ipam:ipaddress_list'
|
||||
)
|
||||
|
||||
class Meta(IPAddressTable.Meta):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
|
||||
'description', 'tags',
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
|
||||
'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
|
||||
|
||||
class IPAddressAssignTable(BaseTable):
|
||||
@@ -492,173 +376,3 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(linkify=True)
|
||||
scope_type = ContentTypeColumn()
|
||||
scope = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
vlan_count = LinkedCountColumn(
|
||||
viewname='ipam:vlan_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='VLANs'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=VLANGroup,
|
||||
prepend_template=VLANGROUP_ADD_VLAN
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLANGroup
|
||||
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
vid = tables.TemplateColumn(
|
||||
template_code=VLAN_LINK,
|
||||
verbose_name='ID'
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'vid', 'name', 'site', 'group', 'tenant', 'status', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
|
||||
}
|
||||
|
||||
|
||||
class VLANDetailTable(VLANTable):
|
||||
prefixes = tables.TemplateColumn(
|
||||
template_code=VLAN_PREFIXES,
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vlan_list'
|
||||
)
|
||||
|
||||
class Meta(VLANTable.Meta):
|
||||
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMembersTable(BaseTable):
|
||||
"""
|
||||
Base table for Interface and VMInterface assignments
|
||||
"""
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Interface'
|
||||
)
|
||||
tagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_TAGGED,
|
||||
orderable=False
|
||||
)
|
||||
|
||||
|
||||
class VLANDevicesTable(VLANMembersTable):
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(Interface, buttons=['edit'])
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('device', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
virtual_machine = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(VMInterface, buttons=['edit'])
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = ('virtual_machine', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class InterfaceVLANTable(BaseTable):
|
||||
"""
|
||||
List VLANs assigned to a specific Interface.
|
||||
"""
|
||||
vid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='ID'
|
||||
)
|
||||
tagged = BooleanColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
accessor=Accessor('group__name'),
|
||||
verbose_name='Group'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn()
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
def __init__(self, interface, *args, **kwargs):
|
||||
self.interface = interface
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
ports = tables.TemplateColumn(
|
||||
template_code='{{ record.port_list }}',
|
||||
verbose_name='Ports'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Service
|
||||
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||
35
netbox/ipam/tables/services.py
Normal file
35
netbox/ipam/tables/services.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from utilities.tables import BaseTable, TagColumn, ToggleColumn
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
'ServiceTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
ports = tables.TemplateColumn(
|
||||
template_code='{{ record.port_list }}',
|
||||
verbose_name='Ports'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Service
|
||||
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||
203
netbox/ipam/tables/vlans.py
Normal file
203
netbox/ipam/tables/vlans.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn,
|
||||
)
|
||||
from virtualization.models import VMInterface
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
'InterfaceVLANTable',
|
||||
'VLANDevicesTable',
|
||||
'VLANGroupTable',
|
||||
'VLANMembersTable',
|
||||
'VLANTable',
|
||||
'VLANVirtualMachinesTable',
|
||||
)
|
||||
|
||||
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||
|
||||
VLAN_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
|
||||
{% elif perms.ipam.add_vlan %}
|
||||
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
|
||||
{% else %}
|
||||
{{ record.available }} VLAN{{ record.available|pluralize }} available
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLAN_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ADD_VLAN = """
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
{% if next_vid and perms.ipam.add_vlan %}
|
||||
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_TAGGED = """
|
||||
{% if record.untagged_vlan_id == object.pk %}
|
||||
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(linkify=True)
|
||||
scope_type = ContentTypeColumn()
|
||||
scope = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
vlan_count = LinkedCountColumn(
|
||||
viewname='ipam:vlan_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='VLANs'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=VLANGroup,
|
||||
prepend_template=VLANGROUP_ADD_VLAN
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLANGroup
|
||||
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
vid = tables.TemplateColumn(
|
||||
template_code=VLAN_LINK,
|
||||
verbose_name='ID'
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
)
|
||||
prefixes = tables.TemplateColumn(
|
||||
template_code=VLAN_PREFIXES,
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vlan_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
|
||||
}
|
||||
|
||||
|
||||
class VLANMembersTable(BaseTable):
|
||||
"""
|
||||
Base table for Interface and VMInterface assignments
|
||||
"""
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Interface'
|
||||
)
|
||||
tagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_TAGGED,
|
||||
orderable=False
|
||||
)
|
||||
|
||||
|
||||
class VLANDevicesTable(VLANMembersTable):
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(Interface, buttons=['edit'])
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('device', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
virtual_machine = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(VMInterface, buttons=['edit'])
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = ('virtual_machine', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class InterfaceVLANTable(BaseTable):
|
||||
"""
|
||||
List VLANs assigned to a specific Interface.
|
||||
"""
|
||||
vid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='ID'
|
||||
)
|
||||
tagged = BooleanColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
accessor=Accessor('group__name'),
|
||||
verbose_name='Group'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn()
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
def __init__(self, interface, *args, **kwargs):
|
||||
self.interface = interface
|
||||
super().__init__(*args, **kwargs)
|
||||
74
netbox/ipam/tables/vrfs.py
Normal file
74
netbox/ipam/tables/vrfs.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
'RouteTargetTable',
|
||||
'VRFTable',
|
||||
)
|
||||
|
||||
VRF_TARGETS = """
|
||||
{% for rt in value.all %}
|
||||
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rd = tables.Column(
|
||||
verbose_name='RD'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
enforce_unique = BooleanColumn(
|
||||
verbose_name='Unique'
|
||||
)
|
||||
import_targets = tables.TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
export_targets = tables.TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
fields = (
|
||||
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Route targets
|
||||
#
|
||||
|
||||
class RouteTargetTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RouteTarget
|
||||
fields = ('pk', 'name', 'tenant', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'tenant', 'description')
|
||||
@@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
params = {'mask_length': ['24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vrf(self):
|
||||
|
||||
@@ -155,9 +155,7 @@ class RIRView(generic.ObjectView):
|
||||
aggregates = Aggregate.objects.restrict(request.user, 'view').filter(
|
||||
rir=instance
|
||||
)
|
||||
|
||||
aggregates_table = tables.AggregateTable(aggregates)
|
||||
aggregates_table.columns.hide('rir')
|
||||
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
|
||||
paginate_table(aggregates_table, request)
|
||||
|
||||
return {
|
||||
@@ -207,7 +205,7 @@ class AggregateListView(generic.ObjectListView):
|
||||
)
|
||||
filterset = filtersets.AggregateFilterSet
|
||||
filterset_form = forms.AggregateFilterForm
|
||||
table = tables.AggregateDetailTable
|
||||
table = tables.AggregateTable
|
||||
|
||||
|
||||
class AggregateView(generic.ObjectView):
|
||||
@@ -227,7 +225,7 @@ class AggregateView(generic.ObjectView):
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.columns.show('pk')
|
||||
paginate_table(prefix_table, request)
|
||||
@@ -283,6 +281,8 @@ class RoleListView(generic.ObjectListView):
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
filterset = filtersets.RoleFilterSet
|
||||
filterset_form = forms.RoleFilterForm
|
||||
table = tables.RoleTable
|
||||
|
||||
|
||||
@@ -294,8 +294,7 @@ class RoleView(generic.ObjectView):
|
||||
role=instance
|
||||
)
|
||||
|
||||
prefixes_table = tables.PrefixTable(prefixes)
|
||||
prefixes_table.columns.hide('role')
|
||||
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
|
||||
paginate_table(prefixes_table, request)
|
||||
|
||||
return {
|
||||
@@ -338,7 +337,7 @@ class PrefixListView(generic.ObjectListView):
|
||||
queryset = Prefix.objects.all()
|
||||
filterset = filtersets.PrefixFilterSet
|
||||
filterset_form = forms.PrefixFilterForm
|
||||
table = tables.PrefixDetailTable
|
||||
table = tables.PrefixTable
|
||||
template_name = 'ipam/prefix_list.html'
|
||||
|
||||
|
||||
@@ -361,8 +360,11 @@ class PrefixView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
)
|
||||
parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
|
||||
parent_prefix_table.exclude = ('vrf',)
|
||||
parent_prefix_table = tables.PrefixTable(
|
||||
list(parent_prefixes),
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Duplicate prefixes table
|
||||
duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
@@ -372,8 +374,11 @@ class PrefixView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
)
|
||||
duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
|
||||
duplicate_prefix_table.exclude = ('vrf',)
|
||||
duplicate_prefix_table = tables.PrefixTable(
|
||||
list(duplicate_prefixes),
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return {
|
||||
'aggregate': aggregate,
|
||||
@@ -396,20 +401,26 @@ class PrefixPrefixesView(generic.ObjectView):
|
||||
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
|
||||
|
||||
table = tables.PrefixDetailTable(child_prefixes, user=request.user)
|
||||
table = tables.PrefixTable(child_prefixes, user=request.user, exclude=('utilization',))
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
table.columns.show('pk')
|
||||
paginate_table(table, request)
|
||||
|
||||
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'change': request.user.has_perm('ipam.change_prefix'),
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
|
||||
return {
|
||||
'first_available_prefix': instance.get_first_available_prefix(),
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'prefixes',
|
||||
'first_available_prefix': instance.get_first_available_prefix(),
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
'table_config_form': TableConfigForm(table=table),
|
||||
}
|
||||
|
||||
|
||||
@@ -421,15 +432,22 @@ class PrefixIPRangesView(generic.ObjectView):
|
||||
# Find all IPRanges belonging to this Prefix
|
||||
ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
|
||||
|
||||
table = tables.IPRangeTable(ip_ranges)
|
||||
table = tables.IPRangeTable(ip_ranges, user=request.user)
|
||||
if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
|
||||
table.columns.show('pk')
|
||||
paginate_table(table, request)
|
||||
|
||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'change': request.user.has_perm('ipam.change_iprange'),
|
||||
'delete': request.user.has_perm('ipam.delete_iprange'),
|
||||
}
|
||||
|
||||
return {
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'ip-ranges',
|
||||
}
|
||||
@@ -449,18 +467,25 @@ class PrefixIPAddressesView(generic.ObjectView):
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
|
||||
|
||||
table = tables.IPAddressTable(ipaddresses)
|
||||
table = tables.IPAddressTable(ipaddresses, user=request.user)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
table.columns.show('pk')
|
||||
paginate_table(table, request)
|
||||
|
||||
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
}
|
||||
|
||||
return {
|
||||
'first_available_ip': instance.get_first_available_ip(),
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'ip-addresses',
|
||||
'first_available_ip': instance.get_first_available_ip(),
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
}
|
||||
|
||||
@@ -579,7 +604,7 @@ class IPAddressListView(generic.ObjectListView):
|
||||
queryset = IPAddress.objects.all()
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressDetailTable
|
||||
table = tables.IPAddressTable
|
||||
|
||||
|
||||
class IPAddressView(generic.ObjectView):
|
||||
@@ -593,8 +618,11 @@ class IPAddressView(generic.ObjectView):
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
)
|
||||
parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
|
||||
parent_prefixes_table.exclude = ('vrf',)
|
||||
parent_prefixes_table = tables.PrefixTable(
|
||||
list(parent_prefixes),
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Duplicate IPs table
|
||||
duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
|
||||
@@ -745,11 +773,9 @@ class VLANGroupView(generic.ObjectView):
|
||||
vlans_count = vlans.count()
|
||||
vlans = add_available_vlans(vlans, vlan_group=instance)
|
||||
|
||||
vlans_table = tables.VLANDetailTable(vlans)
|
||||
vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes'))
|
||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
||||
vlans_table.columns.show('pk')
|
||||
vlans_table.columns.hide('site')
|
||||
vlans_table.columns.hide('group')
|
||||
paginate_table(vlans_table, request)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
@@ -806,7 +832,7 @@ class VLANListView(generic.ObjectListView):
|
||||
queryset = VLAN.objects.all()
|
||||
filterset = filtersets.VLANFilterSet
|
||||
filterset_form = forms.VLANFilterForm
|
||||
table = tables.VLANDetailTable
|
||||
table = tables.VLANTable
|
||||
|
||||
|
||||
class VLANView(generic.ObjectView):
|
||||
@@ -816,8 +842,7 @@ class VLANView(generic.ObjectView):
|
||||
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
|
||||
'vrf', 'site', 'role'
|
||||
)
|
||||
prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
|
||||
prefix_table.exclude = ('vlan',)
|
||||
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
|
||||
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
|
||||
@@ -34,7 +34,6 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return list(queryset[self.offset:])
|
||||
|
||||
def get_limit(self, request):
|
||||
|
||||
if self.limit_query_param:
|
||||
try:
|
||||
limit = int(request.query_params[self.limit_query_param])
|
||||
|
||||
@@ -2,14 +2,17 @@ import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Group, AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Q
|
||||
|
||||
from users.models import ObjectPermission
|
||||
from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class ObjectPermissionMixin():
|
||||
|
||||
@@ -101,38 +104,145 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
def create_unknown_user(self):
|
||||
return settings.REMOTE_AUTH_AUTO_CREATE_USER
|
||||
|
||||
def configure_user(self, request, user):
|
||||
def configure_groups(self, user, remote_groups):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
|
||||
# Assign default groups to the user
|
||||
group_list = []
|
||||
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
|
||||
for name in remote_groups:
|
||||
try:
|
||||
group_list.append(Group.objects.get(name=name))
|
||||
except Group.DoesNotExist:
|
||||
logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
if group_list:
|
||||
user.groups.add(*group_list)
|
||||
logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
|
||||
|
||||
# Assign default object permissions to the user
|
||||
permissions_list = []
|
||||
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
|
||||
try:
|
||||
object_type, action = resolve_permission_ct(permission_name)
|
||||
# TODO: Merge multiple actions into a single ObjectPermission per content type
|
||||
obj_perm = ObjectPermission(actions=[action], constraints=constraints)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(user)
|
||||
obj_perm.object_types.add(object_type)
|
||||
permissions_list.append(permission_name)
|
||||
except ValueError:
|
||||
logging.error(
|
||||
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
|
||||
"<app>.<action>_<model>. (Example: dcim.add_site)"
|
||||
)
|
||||
if permissions_list:
|
||||
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
|
||||
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
if group_list:
|
||||
user.groups.set(group_list)
|
||||
logger.debug(
|
||||
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
|
||||
else:
|
||||
user.groups.clear()
|
||||
logger.debug(f"Stripping user {user} from Groups")
|
||||
user.is_superuser = self._is_superuser(user)
|
||||
logger.debug(f"User {user} is Superuser: {user.is_superuser}")
|
||||
logger.debug(
|
||||
f"User {user} should be Superuser: {self._is_superuser(user)}")
|
||||
|
||||
user.is_staff = self._is_staff(user)
|
||||
logger.debug(f"User {user} is Staff: {user.is_staff}")
|
||||
logger.debug(f"User {user} should be Staff: {self._is_staff(user)}")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def authenticate(self, request, remote_user, remote_groups=None):
|
||||
"""
|
||||
The username passed as ``remote_user`` is considered trusted. Return
|
||||
the ``User`` object with the given username. Create a new ``User``
|
||||
object if ``create_unknown_user`` is ``True``.
|
||||
Return None if ``create_unknown_user`` is ``False`` and a ``User``
|
||||
object with the given username is not found in the database.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
logger.debug(
|
||||
f"trying to authenticate {remote_user} with groups {remote_groups}")
|
||||
if not remote_user:
|
||||
return
|
||||
user = None
|
||||
username = self.clean_username(remote_user)
|
||||
|
||||
# Note that this could be accomplished in one try-except clause, but
|
||||
# instead we use get_or_create when creating unknown users since it has
|
||||
# built-in safeguards for multiple threads.
|
||||
if self.create_unknown_user:
|
||||
user, created = UserModel._default_manager.get_or_create(**{
|
||||
UserModel.USERNAME_FIELD: username
|
||||
})
|
||||
if created:
|
||||
user = self.configure_user(request, user)
|
||||
else:
|
||||
try:
|
||||
user = UserModel._default_manager.get_by_natural_key(username)
|
||||
except UserModel.DoesNotExist:
|
||||
pass
|
||||
if self.user_can_authenticate(user):
|
||||
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
|
||||
if user is not None and not isinstance(user, AnonymousUser):
|
||||
return self.configure_groups(user, remote_groups)
|
||||
else:
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
|
||||
def _is_superuser(self, user):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
|
||||
logger.debug(f"Superuser Groups: {superuser_groups}")
|
||||
superusers = settings.REMOTE_AUTH_SUPERUSERS
|
||||
logger.debug(f"Superuser Users: {superusers}")
|
||||
user_groups = set()
|
||||
for g in user.groups.all():
|
||||
user_groups.add(g.name)
|
||||
logger.debug(f"User {user.username} is in Groups:{user_groups}")
|
||||
|
||||
result = user.username in superusers or (
|
||||
set(user_groups) & set(superuser_groups))
|
||||
logger.debug(f"User {user.username} in Superuser Users :{result}")
|
||||
return bool(result)
|
||||
|
||||
def _is_staff(self, user):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
|
||||
logger.debug(f"Superuser Groups: {staff_groups}")
|
||||
staff_users = settings.REMOTE_AUTH_STAFF_USERS
|
||||
logger.debug(f"Staff Users :{staff_users}")
|
||||
user_groups = set()
|
||||
for g in user.groups.all():
|
||||
user_groups.add(g.name)
|
||||
logger.debug(f"User {user.username} is in Groups:{user_groups}")
|
||||
result = user.username in staff_users or (
|
||||
set(user_groups) & set(staff_groups))
|
||||
logger.debug(f"User {user.username} in Staff Users :{result}")
|
||||
return bool(result)
|
||||
|
||||
def configure_user(self, request, user):
|
||||
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
|
||||
if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
|
||||
# Assign default groups to the user
|
||||
group_list = []
|
||||
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
|
||||
try:
|
||||
group_list.append(Group.objects.get(name=name))
|
||||
except Group.DoesNotExist:
|
||||
logging.error(
|
||||
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
if group_list:
|
||||
user.groups.add(*group_list)
|
||||
logger.debug(
|
||||
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
|
||||
|
||||
# Assign default object permissions to the user
|
||||
permissions_list = []
|
||||
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
|
||||
try:
|
||||
object_type, action = resolve_permission_ct(
|
||||
permission_name)
|
||||
# TODO: Merge multiple actions into a single ObjectPermission per content type
|
||||
obj_perm = ObjectPermission(
|
||||
actions=[action], constraints=constraints)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(user)
|
||||
obj_perm.object_types.add(object_type)
|
||||
permissions_list.append(permission_name)
|
||||
except ValueError:
|
||||
logging.error(
|
||||
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
|
||||
"<app>.<action>_<model>. (Example: dcim.add_site)"
|
||||
)
|
||||
if permissions_list:
|
||||
logger.debug(
|
||||
f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from tenancy.tables import TenantTable
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
|
||||
from virtualization.tables import ClusterTable, VirtualMachineTable
|
||||
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
SEARCH_TYPES = OrderedDict((
|
||||
@@ -130,7 +130,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': VirtualMachineFilterSet,
|
||||
'table': VirtualMachineDetailTable,
|
||||
'table': VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
# IPAM
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import uuid
|
||||
from urllib import parse
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import ProgrammingError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
@@ -16,6 +19,7 @@ class LoginRequiredMiddleware(object):
|
||||
"""
|
||||
If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -49,12 +53,65 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
|
||||
return settings.REMOTE_AUTH_HEADER
|
||||
|
||||
def process_request(self, request):
|
||||
|
||||
logger = logging.getLogger(
|
||||
'netbox.authentication.RemoteUserMiddleware')
|
||||
# Bypass middleware if remote authentication is not enabled
|
||||
if not settings.REMOTE_AUTH_ENABLED:
|
||||
return
|
||||
# AuthenticationMiddleware is required so that request.user exists.
|
||||
if not hasattr(request, 'user'):
|
||||
raise ImproperlyConfigured(
|
||||
"The Django remote user auth middleware requires the"
|
||||
" authentication middleware to be installed. Edit your"
|
||||
" MIDDLEWARE setting to insert"
|
||||
" 'django.contrib.auth.middleware.AuthenticationMiddleware'"
|
||||
" before the RemoteUserMiddleware class.")
|
||||
try:
|
||||
username = request.META[self.header]
|
||||
except KeyError:
|
||||
# If specified header doesn't exist then remove any existing
|
||||
# authenticated remote-user, or return (leaving request.user set to
|
||||
# AnonymousUser by the AuthenticationMiddleware).
|
||||
if self.force_logout_if_no_header and request.user.is_authenticated:
|
||||
self._remove_invalid_user(request)
|
||||
return
|
||||
# If the user is already authenticated and that user is the user we are
|
||||
# getting passed in the headers, then the correct user is already
|
||||
# persisted in the session and we don't need to continue.
|
||||
if request.user.is_authenticated:
|
||||
if request.user.get_username() == self.clean_username(username, request):
|
||||
return
|
||||
else:
|
||||
# An authenticated user is associated with the request, but
|
||||
# it does not match the authorized user in the header.
|
||||
self._remove_invalid_user(request)
|
||||
|
||||
return super().process_request(request)
|
||||
# We are seeing this user for the first time in this session, attempt
|
||||
# to authenticate the user.
|
||||
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
|
||||
logger.debug("Trying to sync Groups")
|
||||
user = auth.authenticate(
|
||||
request, remote_user=username, remote_groups=self._get_groups(request))
|
||||
else:
|
||||
user = auth.authenticate(request, remote_user=username)
|
||||
if user:
|
||||
# User is valid. Set request.user and persist user in the session
|
||||
# by logging the user in.
|
||||
request.user = user
|
||||
auth.login(request, user)
|
||||
|
||||
def _get_groups(self, request):
|
||||
logger = logging.getLogger(
|
||||
'netbox.authentication.RemoteUserMiddleware')
|
||||
|
||||
groups_string = request.META.get(
|
||||
settings.REMOTE_AUTH_GROUP_HEADER, None)
|
||||
if groups_string:
|
||||
groups = groups_string.split(settings.REMOTE_AUTH_GROUP_SEPARATOR)
|
||||
else:
|
||||
groups = []
|
||||
logger.debug(f"Groups are {groups}")
|
||||
return groups
|
||||
|
||||
|
||||
class ObjectChangeMiddleware(object):
|
||||
@@ -71,6 +128,7 @@ class ObjectChangeMiddleware(object):
|
||||
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
|
||||
object is recorded before it (and any related objects) are actually deleted from the database.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -90,6 +148,7 @@ class APIVersionMiddleware(object):
|
||||
"""
|
||||
If the request is for an API endpoint, include the API version as a response header.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -105,6 +164,7 @@ class ExceptionHandlingMiddleware(object):
|
||||
Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
|
||||
to the user.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -113,6 +173,10 @@ class ExceptionHandlingMiddleware(object):
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
|
||||
# Handle exceptions that occur from REST API requests
|
||||
if is_api_request(request):
|
||||
return rest_api_server_error(request)
|
||||
|
||||
# Don't catch exceptions when in debug mode
|
||||
if settings.DEBUG:
|
||||
return
|
||||
@@ -121,10 +185,6 @@ class ExceptionHandlingMiddleware(object):
|
||||
if isinstance(exception, Http404):
|
||||
return
|
||||
|
||||
# Handle exceptions that occur from REST API requests
|
||||
if is_api_request(request):
|
||||
return rest_api_server_error(request)
|
||||
|
||||
# Determine the type of exception. If it's a common issue, return a custom error page with instructions.
|
||||
custom_template = None
|
||||
if isinstance(exception, ProgrammingError):
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.0.0'
|
||||
VERSION = '3.0.3'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -120,6 +120,13 @@ REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS'
|
||||
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
|
||||
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
|
||||
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
|
||||
REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP')
|
||||
REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False)
|
||||
REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', [])
|
||||
REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
|
||||
REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
|
||||
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
|
||||
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
|
||||
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
@@ -250,10 +257,12 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
if CACHING_REDIS_SENTINELS:
|
||||
DJANGO_REDIS_CONNECTION_FACTORY = 'django_redis.pool.SentinelConnectionFactory'
|
||||
CACHES['default']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}'
|
||||
CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient'
|
||||
CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS
|
||||
if CACHING_REDIS_SKIP_TLS_VERIFY:
|
||||
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
|
||||
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False
|
||||
|
||||
|
||||
@@ -560,6 +569,10 @@ RQ_QUEUES = {
|
||||
#
|
||||
|
||||
# Pagination
|
||||
if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
|
||||
raise ImproperlyConfigured(
|
||||
f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
|
||||
)
|
||||
PER_PAGE_DEFAULTS = [
|
||||
25, 50, 100, 250, 500, 1000
|
||||
]
|
||||
|
||||
@@ -58,7 +58,8 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
|
||||
response = self.client.get(reverse('home'), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), self.user.pk, msg='Authentication failed')
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
@@ -78,7 +79,8 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
|
||||
response = self.client.get(reverse('home'), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), self.user.pk, msg='Authentication failed')
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
@@ -102,7 +104,8 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
|
||||
# Local user should have been automatically created
|
||||
new_user = User.objects.get(username='remoteuser2')
|
||||
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
@@ -121,7 +124,8 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
|
||||
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
|
||||
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
|
||||
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS, ['Group 1', 'Group 2'])
|
||||
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS,
|
||||
['Group 1', 'Group 2'])
|
||||
|
||||
# Create required groups
|
||||
groups = (
|
||||
@@ -135,7 +139,8 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
new_user = User.objects.get(username='remoteuser2')
|
||||
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertListEqual(
|
||||
[groups[0], groups[1]],
|
||||
list(new_user.groups.all())
|
||||
@@ -144,7 +149,8 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_USER=True,
|
||||
REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None},
|
||||
REMOTE_AUTH_DEFAULT_PERMISSIONS={
|
||||
'dcim.add_site': None, 'dcim.change_site': None},
|
||||
LOGIN_REQUIRED=True
|
||||
)
|
||||
def test_remote_auth_default_permissions(self):
|
||||
@@ -158,14 +164,102 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
|
||||
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
|
||||
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
|
||||
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None})
|
||||
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {
|
||||
'dcim.add_site': None, 'dcim.change_site': None})
|
||||
|
||||
response = self.client.get(reverse('home'), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
new_user = User.objects.get(username='remoteuser2')
|
||||
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertTrue(new_user.has_perms(
|
||||
['dcim.add_site', 'dcim.change_site']))
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_USER=True,
|
||||
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
|
||||
LOGIN_REQUIRED=True
|
||||
)
|
||||
def test_remote_auth_remote_groups_default(self):
|
||||
"""
|
||||
Test enabling remote authentication with group sync enabled with the default configuration.
|
||||
"""
|
||||
headers = {
|
||||
'HTTP_REMOTE_USER': 'remoteuser2',
|
||||
'HTTP_REMOTE_USER_GROUP': 'Group 1|Group 2',
|
||||
}
|
||||
|
||||
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
|
||||
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
|
||||
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
|
||||
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
|
||||
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER,
|
||||
'HTTP_REMOTE_USER_GROUP')
|
||||
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
|
||||
|
||||
# Create required groups
|
||||
groups = (
|
||||
Group(name='Group 1'),
|
||||
Group(name='Group 2'),
|
||||
Group(name='Group 3'),
|
||||
)
|
||||
Group.objects.bulk_create(groups)
|
||||
|
||||
response = self.client.get(reverse('home'), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
new_user = User.objects.get(username='remoteuser2')
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertListEqual(
|
||||
[groups[0], groups[1]],
|
||||
list(new_user.groups.all())
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_USER=True,
|
||||
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
|
||||
REMOTE_AUTH_HEADER='HTTP_FOO',
|
||||
REMOTE_AUTH_GROUP_HEADER='HTTP_BAR',
|
||||
LOGIN_REQUIRED=True
|
||||
)
|
||||
def test_remote_auth_remote_groups_custom_header(self):
|
||||
"""
|
||||
Test enabling remote authentication with group sync enabled with the default configuration.
|
||||
"""
|
||||
headers = {
|
||||
'HTTP_FOO': 'remoteuser2',
|
||||
'HTTP_BAR': 'Group 1|Group 2',
|
||||
}
|
||||
|
||||
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
|
||||
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
|
||||
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
|
||||
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_FOO')
|
||||
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, 'HTTP_BAR')
|
||||
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
|
||||
|
||||
# Create required groups
|
||||
groups = (
|
||||
Group(name='Group 1'),
|
||||
Group(name='Group 2'),
|
||||
Group(name='Group 3'),
|
||||
)
|
||||
Group.objects.bulk_create(groups)
|
||||
|
||||
response = self.client.get(reverse('home'), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
new_user = User.objects.get(username='remoteuser2')
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), new_user.pk, msg='Authentication failed')
|
||||
self.assertListEqual(
|
||||
[groups[0], groups[1]],
|
||||
list(new_user.groups.all())
|
||||
)
|
||||
|
||||
|
||||
class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
@@ -206,7 +300,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
def test_get_object(self):
|
||||
|
||||
# Attempt to retrieve object without permission
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -221,12 +316,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
|
||||
|
||||
# Retrieve permitted object
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Attempt to retrieve non-permitted object
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[3].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -292,7 +389,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Attempt to edit an object without permission
|
||||
data = {'site': self.sites[0].pk}
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -308,19 +406,22 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Attempt to edit a non-permitted object
|
||||
data = {'site': self.sites[0].pk}
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[3].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# Edit a permitted object
|
||||
data['status'] = 'reserved'
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Attempt to modify a permitted object to a non-permitted object
|
||||
data['site'] = self.sites[1].pk
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -328,7 +429,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
def test_delete_object(self):
|
||||
|
||||
# Attempt to delete an object without permission
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.delete(url, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -343,11 +445,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
|
||||
|
||||
# Attempt to delete a non-permitted object
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[3].pk})
|
||||
response = self.client.delete(url, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# Delete a permitted object
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
response = self.client.delete(url, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.urls import path, re_path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.static import serve
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.views import get_schema_view
|
||||
@@ -63,7 +64,7 @@ _patterns = [
|
||||
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
|
||||
|
||||
# GraphQL
|
||||
path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema), name='graphql'),
|
||||
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
|
||||
@@ -26,7 +26,6 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
||||
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
|
||||
from netbox.forms import SearchForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.tables import paginate_table
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
|
||||
|
||||
@@ -154,26 +153,18 @@ class HomeView(View):
|
||||
class SearchView(View):
|
||||
|
||||
def get(self, request):
|
||||
|
||||
# No query
|
||||
if 'q' not in request.GET:
|
||||
return render(request, 'search.html', {
|
||||
'form': SearchForm(),
|
||||
})
|
||||
|
||||
form = SearchForm(request.GET)
|
||||
results = []
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
# If an object type has been specified, redirect to the dedicated view for it
|
||||
if form.cleaned_data['obj_type']:
|
||||
# Searching for a single type of object
|
||||
obj_types = [form.cleaned_data['obj_type']]
|
||||
else:
|
||||
# Searching all object types
|
||||
obj_types = SEARCH_TYPES.keys()
|
||||
object_type = form.cleaned_data['obj_type']
|
||||
url = reverse(SEARCH_TYPES[object_type]['url'])
|
||||
return redirect(f"{url}?q={form.cleaned_data['q']}")
|
||||
|
||||
for obj_type in obj_types:
|
||||
for obj_type in SEARCH_TYPES.keys():
|
||||
|
||||
queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
|
||||
filterset = SEARCH_TYPES[obj_type]['filterset']
|
||||
|
||||
@@ -21,8 +21,7 @@ from extras.signals import clear_webhooks
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import (
|
||||
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, TableConfigForm,
|
||||
restrict_form_fields,
|
||||
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
|
||||
)
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.tables import paginate_table
|
||||
@@ -181,7 +180,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
'table': table,
|
||||
'permissions': permissions,
|
||||
'action_buttons': self.action_buttons,
|
||||
'table_config_form': TableConfigForm(table=table),
|
||||
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
|
||||
}
|
||||
context.update(self.extra_context())
|
||||
@@ -1012,10 +1010,10 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# Are we deleting *all* objects in the queryset or just a selected subset?
|
||||
if request.POST.get('_all'):
|
||||
qs = model.objects.all()
|
||||
if self.filterset is not None:
|
||||
pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
|
||||
else:
|
||||
pk_list = model.objects.values_list('pk', flat=True)
|
||||
qs = self.filterset(request.GET, qs).qs
|
||||
pk_list = qs.only('pk').values_list('pk', flat=True)
|
||||
else:
|
||||
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
|
||||
|
||||
|
||||
4
netbox/project-static/dist/config.js
vendored
4
netbox/project-static/dist/config.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/config.js.map
vendored
2
netbox/project-static/dist/config.js.map
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/jobs.js
vendored
4
netbox/project-static/dist/jobs.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/jobs.js.map
vendored
2
netbox/project-static/dist/jobs.js.map
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/lldp.js
vendored
4
netbox/project-static/dist/lldp.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/lldp.js.map
vendored
2
netbox/project-static/dist/lldp.js.map
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/status.js
vendored
4
netbox/project-static/dist/status.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/status.js.map
vendored
2
netbox/project-static/dist/status.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
import { Collapse, Modal, Tab, Toast, Tooltip } from 'bootstrap';
|
||||
import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
|
||||
import Masonry from 'masonry-layout';
|
||||
import { getElements } from './util';
|
||||
import { createElement, getElements } from './util';
|
||||
|
||||
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
||||
|
||||
@@ -8,6 +8,7 @@ type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
||||
// plugins).
|
||||
window.Collapse = Collapse;
|
||||
window.Modal = Modal;
|
||||
window.Popover = Popover;
|
||||
window.Toast = Toast;
|
||||
window.Tooltip = Tooltip;
|
||||
|
||||
@@ -156,13 +157,48 @@ function initSidebarAccordions(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize image preview popover, which shows a preview of an image from an image link with the
|
||||
* `.image-preview` class.
|
||||
*/
|
||||
function initImagePreview(): void {
|
||||
for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
|
||||
// Generate a max-width that's a quarter of the screen's width (note - the actual element
|
||||
// width will be slightly larger due to the popover body's padding).
|
||||
const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
|
||||
|
||||
// Create an image element that uses the linked image as its `src`.
|
||||
const image = createElement('img', { src: element.href });
|
||||
image.style.maxWidth = maxWidth;
|
||||
|
||||
// Create a container for the image.
|
||||
const content = createElement('div', null, null, [image]);
|
||||
|
||||
// Initialize the Bootstrap Popper instance.
|
||||
new Popover(element, {
|
||||
// Attach this custom class to the popover so that it styling can be controlled via CSS.
|
||||
customClass: 'image-preview-popover',
|
||||
trigger: 'hover',
|
||||
html: true,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable any defined Bootstrap Tooltips.
|
||||
*
|
||||
* @see https://getbootstrap.com/docs/5.0/components/tooltips
|
||||
*/
|
||||
export function initBootstrap(): void {
|
||||
for (const func of [initTooltips, initModals, initMasonry, initTabs, initSidebarAccordions]) {
|
||||
for (const func of [
|
||||
initTooltips,
|
||||
initModals,
|
||||
initMasonry,
|
||||
initTabs,
|
||||
initImagePreview,
|
||||
initSidebarAccordions,
|
||||
]) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
|
||||
* @param element Connection Toggle Button Element
|
||||
*/
|
||||
function toggleConnection(element: HTMLButtonElement): void {
|
||||
const id = element.getAttribute('data');
|
||||
const url = element.getAttribute('data-url');
|
||||
const connected = element.classList.contains('connected');
|
||||
const status = connected ? 'planned' : 'connected';
|
||||
|
||||
if (isTruthy(id)) {
|
||||
apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => {
|
||||
if (isTruthy(url)) {
|
||||
apiPatch(url, { status }).then(res => {
|
||||
if (hasError(res)) {
|
||||
// If the API responds with an error, show it to the user.
|
||||
createToast('danger', 'Error', res.error).show();
|
||||
|
||||
@@ -13,18 +13,26 @@ function initConfig(): void {
|
||||
.then(data => {
|
||||
if (hasError(data)) {
|
||||
createToast('danger', 'Error Fetching Device Config', data.error).show();
|
||||
console.error(data.error);
|
||||
return;
|
||||
} else if (hasError<Required<DeviceConfig['get_config']>>(data.get_config)) {
|
||||
createToast('danger', 'Error Fetching Device Config', data.get_config.error).show();
|
||||
console.error(data.get_config.error);
|
||||
return;
|
||||
} else {
|
||||
const configTypes = [
|
||||
'running',
|
||||
'startup',
|
||||
'candidate',
|
||||
] as (keyof DeviceConfig['get_config'])[];
|
||||
const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
|
||||
|
||||
for (const configType of configTypes) {
|
||||
const element = document.getElementById(`${configType}_config`);
|
||||
if (element !== null) {
|
||||
element.innerHTML = data.get_config[configType];
|
||||
const config = data.get_config[configType];
|
||||
if (typeof config === 'string') {
|
||||
// If the returned config is a string, set the element innerHTML as-is.
|
||||
element.innerHTML = config;
|
||||
} else {
|
||||
// If the returned config is an object (dict), convert it to JSON.
|
||||
element.innerHTML = JSON.stringify(data.get_config[configType], null, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { getElements, toggleVisibility } from '../util';
|
||||
|
||||
type ShowHideMap = {
|
||||
default: { hide: string[]; show: string[] };
|
||||
[k: string]: { hide: string[]; show: string[] };
|
||||
/**
|
||||
* Name of view to which this map should apply.
|
||||
*
|
||||
* @example vlangroup_edit
|
||||
*/
|
||||
[view: string]: {
|
||||
/**
|
||||
* Default layout.
|
||||
*/
|
||||
default: { hide: string[]; show: string[] };
|
||||
/**
|
||||
* Field name to layout mapping.
|
||||
*/
|
||||
[fieldName: string]: { hide: string[]; show: string[] };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -14,45 +27,47 @@ type ShowHideMap = {
|
||||
* showHideMap.region.show should be shown.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
region: {
|
||||
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region'],
|
||||
},
|
||||
'site group': {
|
||||
hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_sitegroup'],
|
||||
},
|
||||
site: {
|
||||
hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site'],
|
||||
},
|
||||
location: {
|
||||
hide: ['id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
|
||||
},
|
||||
rack: {
|
||||
hide: ['id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
|
||||
},
|
||||
'cluster group': {
|
||||
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'],
|
||||
show: ['id_clustergroup'],
|
||||
},
|
||||
cluster: {
|
||||
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
|
||||
show: ['id_clustergroup', 'id_cluster'],
|
||||
},
|
||||
default: {
|
||||
hide: [
|
||||
'id_region',
|
||||
'id_sitegroup',
|
||||
'id_site',
|
||||
'id_location',
|
||||
'id_rack',
|
||||
'id_clustergroup',
|
||||
'id_cluster',
|
||||
],
|
||||
show: [],
|
||||
vlangroup_edit: {
|
||||
region: {
|
||||
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region'],
|
||||
},
|
||||
'site group': {
|
||||
hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_sitegroup'],
|
||||
},
|
||||
site: {
|
||||
hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site'],
|
||||
},
|
||||
location: {
|
||||
hide: ['id_rack', 'id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
|
||||
},
|
||||
rack: {
|
||||
hide: ['id_clustergroup', 'id_cluster'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
|
||||
},
|
||||
'cluster group': {
|
||||
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'],
|
||||
show: ['id_clustergroup'],
|
||||
},
|
||||
cluster: {
|
||||
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
|
||||
show: ['id_clustergroup', 'id_cluster'],
|
||||
},
|
||||
default: {
|
||||
hide: [
|
||||
'id_region',
|
||||
'id_sitegroup',
|
||||
'id_site',
|
||||
'id_location',
|
||||
'id_rack',
|
||||
'id_clustergroup',
|
||||
'id_cluster',
|
||||
],
|
||||
show: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
/**
|
||||
@@ -76,11 +91,11 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
|
||||
/**
|
||||
* Handle changes to the Scope Type field.
|
||||
*/
|
||||
function handleScopeChange(element: HTMLSelectElement) {
|
||||
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
|
||||
// Scope type's innerText looks something like `DCIM > region`.
|
||||
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
|
||||
|
||||
for (const [scope, fields] of Object.entries(showHideMap)) {
|
||||
for (const [scope, fields] of Object.entries(showHideMap[view])) {
|
||||
// If the scope type ends with the specified scope, toggle its field visibility according to
|
||||
// the show/hide values.
|
||||
if (scopeType.endsWith(scope)) {
|
||||
@@ -94,7 +109,7 @@ function handleScopeChange(element: HTMLSelectElement) {
|
||||
break;
|
||||
} else {
|
||||
// Otherwise, hide all fields.
|
||||
for (const field of showHideMap.default.hide) {
|
||||
for (const field of showHideMap[view].default.hide) {
|
||||
toggleParentVisibility(`#${field}`, 'hide');
|
||||
}
|
||||
}
|
||||
@@ -105,8 +120,12 @@ function handleScopeChange(element: HTMLSelectElement) {
|
||||
* Initialize scope type select event listeners.
|
||||
*/
|
||||
export function initScopeSelector(): void {
|
||||
for (const element of getElements<HTMLSelectElement>('#id_scope_type')) {
|
||||
handleScopeChange(element);
|
||||
element.addEventListener('change', () => handleScopeChange(element));
|
||||
for (const view of Object.keys(showHideMap)) {
|
||||
for (const element of getElements<HTMLSelectElement>(
|
||||
`html[data-netbox-url-name="${view}"] #id_scope_type`,
|
||||
)) {
|
||||
handleScopeChange(view, element);
|
||||
element.addEventListener('change', () => handleScopeChange(view, element));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { all, getElement, resetSelect, toggleVisibility } from '../util';
|
||||
import { all, getElement, resetSelect, toggleVisibility as _toggleVisibility } from '../util';
|
||||
|
||||
/**
|
||||
* Get a select element's containing `.row` element.
|
||||
@@ -14,6 +14,38 @@ function fieldContainer(element: Nullable<HTMLSelectElement>): Nullable<HTMLElem
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of the select element's container and disable the select element itself.
|
||||
*
|
||||
* @param element Select element.
|
||||
* @param action 'show' or 'hide'
|
||||
*/
|
||||
function toggleVisibility<E extends Nullable<HTMLSelectElement>>(
|
||||
element: E,
|
||||
action: 'show' | 'hide',
|
||||
): void {
|
||||
// Find the select element's containing element.
|
||||
const parent = fieldContainer(element);
|
||||
if (element !== null && parent !== null) {
|
||||
// Toggle container visibility to visually remove it from the form.
|
||||
_toggleVisibility(parent, action);
|
||||
// Create a new event so that the APISelect instance properly handles the enable/disable
|
||||
// action.
|
||||
const event = new Event(`netbox.select.disabled.${element.name}`);
|
||||
switch (action) {
|
||||
case 'hide':
|
||||
// Disable the native select element and dispatch the event APISelect is listening for.
|
||||
element.disabled = true;
|
||||
element.dispatchEvent(event);
|
||||
break;
|
||||
case 'show':
|
||||
// Enable the native select element and dispatch the event APISelect is listening for.
|
||||
element.disabled = false;
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle element visibility when the mode field does not have a value.
|
||||
*/
|
||||
@@ -29,7 +61,7 @@ function handleModeNone(): void {
|
||||
resetSelect(untaggedVlan);
|
||||
resetSelect(taggedVlans);
|
||||
for (const element of elements) {
|
||||
toggleVisibility(fieldContainer(element), 'hide');
|
||||
toggleVisibility(element, 'hide');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,9 +78,9 @@ function handleModeAccess(): void {
|
||||
if (all(elements)) {
|
||||
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
||||
resetSelect(taggedVlans);
|
||||
toggleVisibility(fieldContainer(vlanGroup), 'show');
|
||||
toggleVisibility(fieldContainer(untaggedVlan), 'show');
|
||||
toggleVisibility(fieldContainer(taggedVlans), 'hide');
|
||||
toggleVisibility(vlanGroup, 'show');
|
||||
toggleVisibility(untaggedVlan, 'show');
|
||||
toggleVisibility(taggedVlans, 'hide');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +95,9 @@ function handleModeTagged(): void {
|
||||
];
|
||||
if (all(elements)) {
|
||||
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
||||
toggleVisibility(fieldContainer(taggedVlans), 'show');
|
||||
toggleVisibility(fieldContainer(vlanGroup), 'show');
|
||||
toggleVisibility(fieldContainer(untaggedVlan), 'show');
|
||||
toggleVisibility(taggedVlans, 'show');
|
||||
toggleVisibility(vlanGroup, 'show');
|
||||
toggleVisibility(untaggedVlan, 'show');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +113,9 @@ function handleModeTaggedAll(): void {
|
||||
if (all(elements)) {
|
||||
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
|
||||
resetSelect(taggedVlans);
|
||||
toggleVisibility(fieldContainer(vlanGroup), 'show');
|
||||
toggleVisibility(fieldContainer(untaggedVlan), 'show');
|
||||
toggleVisibility(fieldContainer(taggedVlans), 'hide');
|
||||
toggleVisibility(vlanGroup, 'show');
|
||||
toggleVisibility(untaggedVlan, 'show');
|
||||
toggleVisibility(taggedVlans, 'hide');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
netbox/project-static/src/global.d.ts
vendored
14
netbox/project-static/src/global.d.ts
vendored
@@ -17,6 +17,11 @@ interface Window {
|
||||
*/
|
||||
Modal: typeof import('bootstrap').Modal;
|
||||
|
||||
/**
|
||||
* Bootstrap Popover Instance.
|
||||
*/
|
||||
Popover: typeof import('bootstrap').Popover;
|
||||
|
||||
/**
|
||||
* Bootstrap Toast Instance.
|
||||
*/
|
||||
@@ -147,12 +152,15 @@ type LLDPNeighborDetail = {
|
||||
|
||||
type DeviceConfig = {
|
||||
get_config: {
|
||||
candidate: string;
|
||||
running: string;
|
||||
startup: string;
|
||||
candidate: string | Record<string, unknown>;
|
||||
running: string | Record<string, unknown>;
|
||||
startup: string | Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DeviceConfigType = Exclude<keyof DeviceConfig['get_config'], 'error'>;
|
||||
|
||||
type DeviceEnvironment = {
|
||||
cpu?: {
|
||||
[core: string]: { '%usage': number };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { apiGetBase, hasError, getNetboxData } from './util';
|
||||
let timeout: number = 1000;
|
||||
|
||||
interface JobInfo {
|
||||
id: Nullable<string>;
|
||||
url: Nullable<string>;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
@@ -23,15 +23,16 @@ function asyncTimeout(ms: number) {
|
||||
function getJobInfo(): JobInfo {
|
||||
let complete = false;
|
||||
|
||||
const id = getNetboxData('data-job-id');
|
||||
const jobComplete = getNetboxData('data-job-complete');
|
||||
// Determine the API URL for the job status
|
||||
const url = getNetboxData('data-job-url');
|
||||
|
||||
// Determine the job completion status, if present. If the job is not complete, the value will be
|
||||
// "None". Otherwise, it will be a stringified date.
|
||||
const jobComplete = getNetboxData('data-job-complete');
|
||||
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
|
||||
complete = true;
|
||||
}
|
||||
return { id, complete };
|
||||
return { url, complete };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,10 +60,10 @@ function updateLabel(status: JobStatus) {
|
||||
|
||||
/**
|
||||
* Recursively check the job's status.
|
||||
* @param id Job ID
|
||||
* @param url API URL for job result
|
||||
*/
|
||||
async function checkJobStatus(id: string) {
|
||||
const res = await apiGetBase<APIJobResult>(`/api/extras/job-results/${id}/`);
|
||||
async function checkJobStatus(url: string) {
|
||||
const res = await apiGetBase<APIJobResult>(url);
|
||||
if (hasError(res)) {
|
||||
// If the response is an API error, display an error message and stop checking for job status.
|
||||
const toast = createToast('danger', 'Error', res.error);
|
||||
@@ -82,17 +83,17 @@ async function checkJobStatus(id: string) {
|
||||
if (timeout < 10000) {
|
||||
timeout += 1000;
|
||||
}
|
||||
await Promise.all([checkJobStatus(id), asyncTimeout(timeout)]);
|
||||
await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initJobs() {
|
||||
const { id, complete } = getJobInfo();
|
||||
const { url, complete } = getJobInfo();
|
||||
|
||||
if (id !== null && !complete) {
|
||||
if (url !== null && !complete) {
|
||||
// If there is a job ID and it is not completed, check for the job's status.
|
||||
Promise.resolve(checkJobStatus(id));
|
||||
Promise.resolve(checkJobStatus(url));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import SlimSelect from 'slim-select';
|
||||
import { createToast } from '../../bs';
|
||||
import { hasUrl, hasExclusions, isTrigger } from '../util';
|
||||
import { DynamicParamsMap } from './dynamicParams';
|
||||
import { isStaticParams } from './types';
|
||||
import { isStaticParams, isOption } from './types';
|
||||
import {
|
||||
hasMore,
|
||||
isTruthy,
|
||||
@@ -23,7 +23,7 @@ import type { Option } from 'slim-select/dist/data';
|
||||
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
|
||||
|
||||
// Empty placeholder option.
|
||||
const PLACEHOLDER = {
|
||||
const EMPTY_PLACEHOLDER = {
|
||||
value: '',
|
||||
text: '',
|
||||
placeholder: true,
|
||||
@@ -52,6 +52,18 @@ export class APISelect {
|
||||
*/
|
||||
public readonly placeholder: string;
|
||||
|
||||
/**
|
||||
* Empty/placeholder option. Display text is optionally overridden via the `data-empty-option`
|
||||
* attribute.
|
||||
*/
|
||||
public readonly emptyOption: Option;
|
||||
|
||||
/**
|
||||
* Null option. When `data-null-option` attribute is a string, the value is used to created an
|
||||
* option of type `{text: '<value from data-null-option>': 'null'}`.
|
||||
*/
|
||||
public readonly nullOption: Nullable<Option> = null;
|
||||
|
||||
/**
|
||||
* Event that will initiate the API call to NetBox to load option data. By default, the trigger
|
||||
* is `'load'`, so data will be fetched when the element renders on the page.
|
||||
@@ -137,18 +149,6 @@ export class APISelect {
|
||||
*/
|
||||
private more: Nullable<string> = null;
|
||||
|
||||
/**
|
||||
* This element's options come from the server pre-sorted and should not be sorted client-side.
|
||||
* Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element, or
|
||||
* by existence of specific fields such as `_depth`.
|
||||
*/
|
||||
private preSorted: boolean = false;
|
||||
|
||||
/**
|
||||
* This instance's available options.
|
||||
*/
|
||||
private _options: Option[] = [PLACEHOLDER];
|
||||
|
||||
/**
|
||||
* Array of options values which should be considered disabled or static.
|
||||
*/
|
||||
@@ -164,10 +164,6 @@ export class APISelect {
|
||||
this.base = base;
|
||||
this.name = base.name;
|
||||
|
||||
if (base.getAttribute('pre-sorted') !== null) {
|
||||
this.preSorted = true;
|
||||
}
|
||||
|
||||
if (hasUrl(base)) {
|
||||
const url = base.getAttribute('data-url') as string;
|
||||
this.url = url;
|
||||
@@ -181,6 +177,24 @@ export class APISelect {
|
||||
this.disabledOptions = this.getDisabledOptions();
|
||||
this.disabledAttributes = this.getDisabledAttributes();
|
||||
|
||||
const emptyOption = base.getAttribute('data-empty-option');
|
||||
if (isTruthy(emptyOption)) {
|
||||
this.emptyOption = {
|
||||
text: emptyOption,
|
||||
value: '',
|
||||
};
|
||||
} else {
|
||||
this.emptyOption = EMPTY_PLACEHOLDER;
|
||||
}
|
||||
|
||||
const nullOption = base.getAttribute('data-null-option');
|
||||
if (isTruthy(nullOption)) {
|
||||
this.nullOption = {
|
||||
text: nullOption,
|
||||
value: 'null',
|
||||
};
|
||||
}
|
||||
|
||||
this.slim = new SlimSelect({
|
||||
select: this.base,
|
||||
allowDeselect: true,
|
||||
@@ -265,38 +279,32 @@ export class APISelect {
|
||||
* This instance's available options.
|
||||
*/
|
||||
private get options(): Option[] {
|
||||
return this._options;
|
||||
return this.slim.data.data.filter(isOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort incoming options by label and apply the new options to both the SlimSelect instance and
|
||||
* this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
|
||||
* the options will *not* be sorted.
|
||||
* Apply new options to both the SlimSelect instance and this manager's state.
|
||||
*/
|
||||
private set options(optionsIn: Option[]) {
|
||||
let newOptions = optionsIn;
|
||||
if (!this.preSorted) {
|
||||
newOptions = optionsIn.sort((a, b) => (a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1));
|
||||
// Ensure null option is present, if it exists.
|
||||
if (this.nullOption !== null) {
|
||||
newOptions = [this.nullOption, ...newOptions];
|
||||
}
|
||||
// Deduplicate options each time they're set.
|
||||
let deduplicated = uniqueByProperty(newOptions, 'value');
|
||||
const deduplicated = uniqueByProperty(newOptions, 'value');
|
||||
// Determine if the new options have a placeholder.
|
||||
const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined';
|
||||
// Get the placeholder index (note: if there is no placeholder, the index will be `-1`).
|
||||
const placeholderIdx = deduplicated.findIndex(o => o.value === '');
|
||||
|
||||
if (hasPlaceholder && placeholderIdx < 0) {
|
||||
// If there is a placeholder but it is not the first element (due to sorting or other merge
|
||||
// issues), remove it from the options array and place it in front.
|
||||
deduplicated.splice(placeholderIdx);
|
||||
deduplicated = [PLACEHOLDER, ...deduplicated];
|
||||
if (hasPlaceholder && placeholderIdx >= 0) {
|
||||
// If there is an existing placeholder, replace it.
|
||||
deduplicated[placeholderIdx] = this.emptyOption;
|
||||
} else {
|
||||
// If there is not a placeholder, add one to the front.
|
||||
deduplicated.unshift(this.emptyOption);
|
||||
}
|
||||
if (!hasPlaceholder) {
|
||||
// If there is no placeholder, add one to the front of the array.
|
||||
deduplicated = [PLACEHOLDER, ...deduplicated];
|
||||
}
|
||||
|
||||
this._options = deduplicated;
|
||||
this.slim.setData(deduplicated);
|
||||
}
|
||||
|
||||
@@ -304,7 +312,7 @@ export class APISelect {
|
||||
* Remove all options and reset back to the generic placeholder.
|
||||
*/
|
||||
private resetOptions(): void {
|
||||
this.options = [PLACEHOLDER];
|
||||
this.options = [this.emptyOption];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +328,7 @@ export class APISelect {
|
||||
this.slim.slim.multiSelected.container.setAttribute('disabled', '');
|
||||
}
|
||||
}
|
||||
this.slim.disable();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,6 +344,7 @@ export class APISelect {
|
||||
this.slim.slim.multiSelected.container.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
this.slim.enable();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,7 +356,12 @@ export class APISelect {
|
||||
const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
|
||||
|
||||
// Query the API when the input value changes or a value is pasted.
|
||||
this.slim.slim.search.input.addEventListener('keyup', event => fetcher(event));
|
||||
this.slim.slim.search.input.addEventListener('keyup', event => {
|
||||
// Only search when necessary keys are pressed.
|
||||
if (!event.key.match(/^(Arrow|Enter|Tab).*/)) {
|
||||
return fetcher(event);
|
||||
}
|
||||
});
|
||||
this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
|
||||
|
||||
// Watch every scroll event to determine if the scroll position is at bottom.
|
||||
@@ -357,6 +372,11 @@ export class APISelect {
|
||||
this.fetchOptions(this.more, 'merge'),
|
||||
);
|
||||
|
||||
// When the base select element is disabled or enabled, properly disable/enable this instance.
|
||||
this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
|
||||
this.handleDisableEnable(event),
|
||||
);
|
||||
|
||||
// Create a unique iterator of all possible form fields which, when changed, should cause this
|
||||
// element to update its API query.
|
||||
// const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
|
||||
@@ -389,6 +409,19 @@ export class APISelect {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all options from the native select element that are already selected and do not contain
|
||||
* placeholder values.
|
||||
*/
|
||||
private getPreselectedOptions(): HTMLOptionElement[] {
|
||||
return Array.from(this.base.options)
|
||||
.filter(option => option.selected)
|
||||
.filter(option => {
|
||||
if (option.value === '---------' || option.innerText === '---------') return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a valid API response and add results to this instance's options.
|
||||
*
|
||||
@@ -398,24 +431,27 @@ export class APISelect {
|
||||
data: APIAnswer<APIObjectBase>,
|
||||
action: ApplyMethod = 'merge',
|
||||
): Promise<void> {
|
||||
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
|
||||
// existing object. When we fetch options from the API later, we can set any of the options
|
||||
// contained in this array to `selected`.
|
||||
const selectOptions = Array.from(this.base.options)
|
||||
.filter(option => option.selected)
|
||||
.map(option => option.getAttribute('value'))
|
||||
.filter(isTruthy);
|
||||
// Get all already-selected options.
|
||||
const preSelected = this.getPreselectedOptions();
|
||||
|
||||
// Get the values of all already-selected options.
|
||||
const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
|
||||
|
||||
// Build SlimSelect options from all already-selected options.
|
||||
const preSelectedOptions = preSelected.map(option => ({
|
||||
value: option.value,
|
||||
text: option.innerText,
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})) as Option[];
|
||||
|
||||
let options = [] as Option[];
|
||||
|
||||
for (const result of data.results) {
|
||||
let text = result.display;
|
||||
|
||||
if (typeof result._depth === 'number') {
|
||||
if (typeof result._depth === 'number' && result._depth > 0) {
|
||||
// If the object has a `_depth` property, indent its display text.
|
||||
if (!this.preSorted) {
|
||||
this.preSorted = true;
|
||||
}
|
||||
text = `<span class="depth">${'─'.repeat(result._depth)} </span>${text}`;
|
||||
}
|
||||
const data = {} as Record<string, string>;
|
||||
@@ -441,12 +477,12 @@ export class APISelect {
|
||||
}
|
||||
|
||||
// Set option to disabled if it is contained within the disabled array.
|
||||
if (selectOptions.some(option => this.disabledOptions.includes(option))) {
|
||||
if (selectedValues.some(option => this.disabledOptions.includes(option))) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
// Set pre-selected options.
|
||||
if (selectOptions.includes(value)) {
|
||||
if (selectedValues.includes(value)) {
|
||||
selected = true;
|
||||
// If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
|
||||
// the rest of the form, resulting in that field's value being deleting from the object.
|
||||
@@ -469,7 +505,8 @@ export class APISelect {
|
||||
this.options = [...this.options, ...options];
|
||||
break;
|
||||
case 'replace':
|
||||
this.options = options;
|
||||
this.options = [...preSelectedOptions, ...options];
|
||||
break;
|
||||
}
|
||||
|
||||
if (hasMore(data)) {
|
||||
@@ -507,7 +544,7 @@ export class APISelect {
|
||||
*/
|
||||
private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
|
||||
if (this.queryUrl.includes(`{{`)) {
|
||||
this.options = [PLACEHOLDER];
|
||||
this.resetOptions();
|
||||
return;
|
||||
}
|
||||
await this.fetchOptions(this.queryUrl, action);
|
||||
@@ -558,6 +595,23 @@ export class APISelect {
|
||||
Promise.all([this.loadData()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler to be dispatched when the base select element is disabled or enabled. When that
|
||||
* occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
|
||||
* desired action.
|
||||
*
|
||||
* @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
|
||||
*/
|
||||
private handleDisableEnable(event: Event): void {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
|
||||
if (target.disabled === true) {
|
||||
this.disable();
|
||||
} else if (target.disabled === false) {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the API returns an error, show it to the user and reset this element's available options.
|
||||
*
|
||||
@@ -715,7 +769,7 @@ export class APISelect {
|
||||
private getPlaceholder(): string {
|
||||
let placeholder = this.name;
|
||||
if (this.base.id) {
|
||||
const label = document.querySelector(`label[for=${this.base.id}]`) as HTMLLabelElement;
|
||||
const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
|
||||
// Set the placeholder text to the label value, if it exists.
|
||||
if (label !== null) {
|
||||
placeholder = `Select ${label.innerText.trim()}`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Stringifiable } from 'query-string';
|
||||
import type { Option, Optgroup } from 'slim-select/dist/data';
|
||||
|
||||
/**
|
||||
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
|
||||
@@ -187,3 +188,12 @@ export function isStaticParams(value: unknown): value is DataStaticParam[] {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to determine if a SlimSelect `dataObject` is an `Option`.
|
||||
*
|
||||
* @param data Option or Option Group
|
||||
*/
|
||||
export function isOption(data: Option | Optgroup): data is Option {
|
||||
return !('options' in data);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getElements } from '../util';
|
||||
export function initStaticSelect(): void {
|
||||
for (const select of getElements<HTMLSelectElement>('.netbox-static-select')) {
|
||||
if (select !== null) {
|
||||
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
||||
const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
|
||||
|
||||
let placeholder;
|
||||
if (label !== null) {
|
||||
|
||||
@@ -53,8 +53,8 @@ function removeColumns(event: Event): void {
|
||||
/**
|
||||
* Submit form configuration to the NetBox API.
|
||||
*/
|
||||
async function submitFormConfig(formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
|
||||
return await apiPatch<APIUserConfig>('/api/users/config/', formConfig);
|
||||
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
|
||||
return await apiPatch<APIUserConfig>(url, formConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +66,18 @@ function handleSubmit(event: Event): void {
|
||||
|
||||
const element = event.currentTarget as HTMLFormElement;
|
||||
|
||||
// Get the API URL for submitting the form
|
||||
const url = element.getAttribute('data-url');
|
||||
if (url == null) {
|
||||
const toast = createToast(
|
||||
'danger',
|
||||
'Error Updating Table Configuration',
|
||||
'No API path defined for configuration form.'
|
||||
);
|
||||
toast.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all the selected options from any select element in the form.
|
||||
const options = getSelectedOptions(element);
|
||||
|
||||
@@ -83,7 +95,7 @@ function handleSubmit(event: Event): void {
|
||||
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), formData);
|
||||
|
||||
// Submit the resulting object to the API to update the user's preferences for this table.
|
||||
submitFormConfig(data).then(res => {
|
||||
submitFormConfig(url, data).then(res => {
|
||||
if (hasError(res)) {
|
||||
const toast = createToast('danger', 'Error Updating Table Configuration', res.error);
|
||||
toast.show();
|
||||
|
||||
@@ -11,14 +11,16 @@ type InferredProps<
|
||||
// Element name.
|
||||
T extends keyof HTMLElementTagNameMap,
|
||||
// Element type.
|
||||
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T]
|
||||
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
|
||||
> = Partial<Record<keyof E, E[keyof E]>>;
|
||||
|
||||
export function isApiError(data: Record<string, unknown>): data is APIError {
|
||||
return 'error' in data && 'exception' in data;
|
||||
}
|
||||
|
||||
export function hasError(data: Record<string, unknown>): data is ErrorBase {
|
||||
export function hasError<E extends ErrorBase = ErrorBase>(
|
||||
data: Record<string, unknown>,
|
||||
): data is E {
|
||||
return 'error' in data;
|
||||
}
|
||||
|
||||
@@ -94,7 +96,7 @@ export function isElement(obj: Element | null | undefined): obj is Element {
|
||||
/**
|
||||
* Retrieve the CSRF token from cookie storage.
|
||||
*/
|
||||
export function getCsrfToken(): string {
|
||||
function getCsrfToken(): string {
|
||||
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
|
||||
if (typeof csrfToken === 'undefined') {
|
||||
throw new Error('Invalid or missing CSRF token');
|
||||
@@ -367,8 +369,13 @@ export function createElement<
|
||||
// Element props.
|
||||
P extends InferredProps<T>,
|
||||
// Child element type.
|
||||
C extends HTMLElement = HTMLElement
|
||||
>(tag: T, properties: P | null, classes: string[], children: C[] = []): HTMLElementTagNameMap[T] {
|
||||
C extends HTMLElement = HTMLElement,
|
||||
>(
|
||||
tag: T,
|
||||
properties: P | null,
|
||||
classes: Nullable<string[]> = null,
|
||||
children: C[] = [],
|
||||
): HTMLElementTagNameMap[T] {
|
||||
// Create the base element.
|
||||
const element = document.createElement<T>(tag);
|
||||
|
||||
@@ -384,7 +391,9 @@ export function createElement<
|
||||
}
|
||||
|
||||
// Add each CSS class to the element's class list.
|
||||
element.classList.add(...classes);
|
||||
if (classes !== null && classes.length > 0) {
|
||||
element.classList.add(...classes);
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
// Add each child element to the base element.
|
||||
@@ -400,7 +409,7 @@ export function createElement<
|
||||
* @returns Degrees in Fahrenheit.
|
||||
*/
|
||||
export function cToF(celsius: number): number {
|
||||
return celsius * (9 / 5) + 32;
|
||||
return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -956,6 +956,11 @@ div.card-overlay {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the max-width from image preview popovers as this is controlled on the image element.
|
||||
.popover.image-preview-popover {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
#django-messages {
|
||||
position: fixed;
|
||||
right: $spacer;
|
||||
@@ -966,7 +971,7 @@ div.card-overlay {
|
||||
// Page-specific styles.
|
||||
html {
|
||||
// Shade the home page content background-color.
|
||||
&[data-netbox-path='/'] {
|
||||
&[data-netbox-url-name='home'] {
|
||||
.content-container,
|
||||
.search {
|
||||
background-color: $gray-100 !important;
|
||||
@@ -980,7 +985,7 @@ html {
|
||||
}
|
||||
|
||||
// Don't show the django-messages toasts on the login screen in favor of the alert component.
|
||||
&[data-netbox-path*='/login'] {
|
||||
&[data-netbox-url-name='login'] {
|
||||
#django-messages {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user