Compare commits

..

92 Commits

Author SHA1 Message Date
jeremystretch
7cffa6ebcc Release v3.2.0-beta2 2022-03-09 12:56:20 -05:00
Jeremy Stretch
f559ceeb7f Merge pull request #8833 from netbox-community/8823-api-serializers
Closes #8823: Add plugin support for REST API components
2022-03-09 12:31:01 -05:00
jeremystretch
e36ae4f0f7 Document support for NetBoxModelSerializer, NetBoxModelViewSet 2022-03-09 11:52:14 -05:00
jeremystretch
bbdeae0ed9 Move CustomFieldModelViewSet functionality into NetBoxModelViewSet 2022-03-09 11:44:55 -05:00
jeremystretch
efd5a73a18 Refactor API views 2022-03-09 11:27:42 -05:00
jeremystretch
a11abf87ec Refactor API serializers 2022-03-09 10:59:22 -05:00
jeremystretch
28f7b411ed Revise plugins documentation 2022-03-08 15:44:35 -05:00
jeremystretch
655bc49fad Clean up form select widgets 2022-03-07 15:11:57 -05:00
jeremystretch
18c9ee2f9d Closes #8804: Include module type count on manufacturer view 2022-03-07 13:44:27 -05:00
jeremystretch
07f2cdac58 Fixes #8810: Enable filtering modules by type 2022-03-07 13:40:49 -05:00
jeremystretch
06781beb81 Fixes #8815: Fix display of custom object fields in table columns 2022-03-07 13:30:52 -05:00
jeremystretch
cd29293dd6 Merge v3.1.9 2022-03-07 10:55:30 -05:00
jeremystretch
1fdc7a9163 Merge branch 'master' into develop 2022-03-07 10:49:06 -05:00
Jeremy Stretch
6807db4967 Merge pull request #8818 from netbox-community/fix-tzdata
Add tzdata dependency
2022-03-07 10:48:12 -05:00
jeremystretch
b0ea416d6d Add tzdata dependency 2022-03-07 10:38:05 -05:00
jeremystretch
c515218760 PRVB 2022-03-07 10:07:07 -05:00
Jeremy Stretch
8053ea0a22 Merge pull request #8814 from netbox-community/develop
Release v3.1.9
2022-03-07 09:59:16 -05:00
jeremystretch
a5603c9953 Release v3.1.9 2022-03-07 09:47:31 -05:00
Jeremy Stretch
bffe63a233 Merge pull request #8793 from seros1521/fix_8715
Fixes #8715: eliminates duplicates when used in many-to-many field constraints
2022-03-07 09:27:14 -05:00
jeremystretch
2cfbfe473e Fixes #8807: Correct REST API URL for FHRP group assignments 2022-03-07 09:02:47 -05:00
jeremystretch
3c78c100b5 Fixes #8808: Fix members count under FHRP group list 2022-03-07 09:00:00 -05:00
jeremystretch
2451b0a5b1 Clean up search results layout 2022-03-07 08:50:58 -05:00
Jeremy Stretch
85e9438ff7 Merge pull request #8734 from emersonfelipesp/add_pluginfooter_block
Closes #8733: Add {% block pluginfooter %} to 'base/layout.html' template
2022-03-04 16:07:54 -05:00
jeremystretch
81610ba86e Fixes #8724: Fix exception during device import with invalid device type 2022-03-04 13:45:59 -05:00
jeremystretch
6423b386d2 Closes #8758: Allow empty string substitution when renaming objects in bulk 2022-03-04 13:30:32 -05:00
jeremystretch
5c48d116eb Closes #8664: Show assigned ASNs/sites under list views 2022-03-04 13:20:17 -05:00
seros1521
90257e9dee Fixes #8715: eliminates duplicates when used in many-to-many field constraints
When using permissions that use tags, a user may receive multiple permissions
of the same type if multiple tags are assigned to the device. This causes the
RestrictedQuerySet class to generate a query similar to this:

>>> dcim.models.Device.objects.filter(Q(tags__name='tag1')|Q(tags__name='tag2'))
<ConfigContextModelQuerySet [<Device: device1>, <Device: device1>]>

This query returns the same object twice if both tags are assigned to it. This
is due to the use of the django-taggit library. The library's documentation
describes this behavior as expected and suggests using an explicit distinct()
call in queries to avoid duplicates.

However, the use of DISTINCT in queries has a global side effect -
deduplication of responses, which may or may not be acceptable behavior
(depending on further use). Since it is not known how RestrictedQuerySet will
be used in the rest of the code, it was decided to dedupe using a subquery.
2022-03-04 14:37:05 +07:00
jeremystretch
a740203444 Fixes #8792: Fix creation of circuit terminations via UI 2022-03-03 16:42:48 -05:00
jeremystretch
25dc9cc14a Fixes #7891: Fix display of form validation failures during device component creation 2022-03-03 16:18:24 -05:00
jeremystretch
d4d2af46ac Refactor tables modules 2022-03-03 15:16:23 -05:00
jeremystretch
64acfc3187 #8787: Fix toggling of PK table column 2022-03-03 14:09:32 -05:00
jeremystretch
62a1d4b3e0 Fixes #8763: Fix inventory item component assignment 2022-03-03 11:52:36 -05:00
jeremystretch
e6072a51f8 Closes #8765: Display and enable bulk clearing of user's table preferences 2022-03-03 10:03:43 -05:00
jeremystretch
21e3159711 Hide table checkboxes when no bulk actions are enabled 2022-03-02 16:13:59 -05:00
jeremystretch
dadd8cb93a Support the direct use of TagFilter 2022-03-02 12:06:07 -05:00
jeremystretch
5f8af6ad66 Closes #8779: Enable the use of ChoiceSet by plugins 2022-03-02 11:43:28 -05:00
Jeremy Stretch
3436905744 Merge pull request #8771 from jasonyates/8770-documentation
Updating mkdocs to automatically adjust theme
2022-03-02 08:38:04 -05:00
Jason Yates
e3258bcf5a Updating mkdocs to automatically adjust theme
Automatically adjusts documentation theme between default/slate based on users preference for dark mode.
2022-03-02 08:45:22 +00:00
jeremystretch
2b6e0405a5 Closes #8736: Add PC and UPC fiber end faces for LC/SC/LSH port types 2022-03-01 11:43:00 -05:00
jeremystretch
7f752d9102 Closes #8762: Link to rack elevations list from site view 2022-03-01 11:32:17 -05:00
jeremystretch
df430394b0 Closes #8766: Add SCTP to service protocols list 2022-03-01 11:07:19 -05:00
Jeremy Stretch
638d89e73b Merge pull request #8767 from peteeckel/fix/plugin-api-endpoint
Correct the endpoint for plugin API view names
2022-03-01 10:11:49 -05:00
jeremystretch
1ab51ca04e Announce 2022 community survey 2022-03-01 09:29:58 -05:00
jeremystretch
cb0386779c Announce 2022 community survey 2022-03-01 09:17:24 -05:00
Peter Eckel
4dc428d75b Closes #8764: Correct the endpoint for plugin API view names 2022-03-01 13:11:49 +01:00
jeremystretch
e7496c8840 Use get_viewname() to resolve view name under ObjectDeleteView 2022-02-28 15:20:41 -05:00
jeremystretch
d6c272cfd6 Fixes #8764: Correct view name resolution for dynamic form fields 2022-02-28 15:17:49 -05:00
jeremystretch
da6ed8ea11 Fixes #8761: Correct view name resolution under journal entry views 2022-02-28 12:10:22 -05:00
Emerson Pereira
28de330b50 Replace 'pluginfooter' block with 'footer' and 'footer_links' blocks
- 'footer' blocks represents the <footer> html tag
- 'footer_links' are the anchor tags inside nav
2022-02-26 01:13:11 -03:00
jeremystretch
7823fa05ef Fix changelog table column ordering 2022-02-25 14:19:50 -05:00
jeremystretch
fab4d95156 Merge branch 'develop' into feature 2022-02-25 14:06:45 -05:00
jeremystretch
06cb7f35f1 Update changelog 2022-02-25 13:26:02 -05:00
thatmattlove
796c5d785e Fix navbar-toggler-icon visibility in dark mode 2022-02-25 11:23:14 -07:00
thatmattlove
c88db77814 Fixes #8633: Recheck sidenav state on window resize
* Recheck sidenav state on window resize
* Remove `data-sidenav-pinned` attribute when hiding sidenav
* Remove `data-sidenav-hidden` attribute when showing sidenav
2022-02-25 11:23:14 -07:00
Jeremy Stretch
6fe0f4cd7d Merge pull request #8741 from djothi/develop
Closes #8594: Add description filter for all models with a description field
2022-02-25 13:18:40 -05:00
Jeremy Stretch
3bf90c3c38 Merge pull request #8735 from minitriga/issue_8629
Add description to tag table search function
2022-02-25 13:13:14 -05:00
Matt Love
992f3535b7 Merge pull request #8722 from stephanblanke/develop
Generalize scopeSelector js to allow easy reuse of existing layout configurations
2022-02-25 10:55:18 -07:00
Djothi Carpentier
06eacb5a5c Add description filter to WirelessLAN & WirelessLink 2022-02-25 18:15:33 +01:00
Djothi Carpentier
c0152ce52f Add description filter to VMInterface 2022-02-25 18:15:33 +01:00
Djothi Carpentier
5a60224d77 Add description filter for Token & ObjectPermission 2022-02-25 18:15:33 +01:00
Djothi Carpentier
c137fa2022 Add description filter for Tenant & ContactRole 2022-02-25 18:15:33 +01:00
Djothi Carpentier
879d01a750 Add description filter for VRF, RouteTarget, Aggregate, ASN, Role, Prefix, IPRange, VLAN & Service 2022-02-25 18:15:33 +01:00
Djothi Carpentier
6db878743c Add description filter for CustomField, ExportTemplate & Tag 2022-02-25 18:15:33 +01:00
Djothi Carpentier
08b90090f5 Add description filter for Site, RackRole, RackReservation & DeviceRole 2022-02-25 18:15:33 +01:00
Djothi Carpentier
42466d5fc4 Add description filter for ProviderNetwork, CircuitType, Circuit & CircuitTermination 2022-02-25 18:15:22 +01:00
jeremystretch
0953bba0a3 Closes #8747: Rename ObjectListView action_buttons to actions 2022-02-24 16:33:51 -05:00
Alex Gittings
4863591bc8 Fixes #8629; Add description to tag table search function 2022-02-24 10:02:21 +00:00
Emerson Pereira
c489501441 Add {% block pluginfooter %} to 'base/layout.html' template
Makes it easy to insert footer information into Netbox footer.
2022-02-24 00:38:11 -03:00
jeremystretch
6638f560f8 Fixes #8683: Fix ZoneInfoNotFoundError exception under Python 3.9+ 2022-02-23 14:16:12 -05:00
jeremystretch
a2fe23549b Closes #8667: Support position patterning when creating module bays & templates 2022-02-23 13:02:14 -05:00
Stephan Blanke
1a7438acfd Fixed code comments 2022-02-22 23:32:34 +01:00
Stephan Blanke
b1de85a44f Fixes #8710: Show/hide form elements based on scope selection 2022-02-22 23:27:11 +01:00
jeremystretch
6604ebfd01 Fix tag background color 2022-02-22 13:10:04 -05:00
jeremystretch
7872460162 Fixes #8682: Limit available VLANs by group min/max VIDs 2022-02-22 11:39:05 -05:00
jeremystretch
d9696ae34c Fixes #8714: Remove label from comments form field 2022-02-22 10:47:02 -05:00
jeremystretch
7c937bf8b8 Fixes #8692: Restore misisng tabs under cluster, VM views 2022-02-22 09:35:21 -05:00
jeremystretch
4913d7ee39 Fixes #8713: Restore missing "add" button on services list view 2022-02-22 09:05:31 -05:00
jeremystretch
2503a3e3ca Fixes #8717: Fix redirection after bulk edit/delete of prefixes from aggregate view 2022-02-22 09:02:31 -05:00
jeremystretch
5f92ed492b Update development docs 2022-02-18 15:35:08 -05:00
jeremystretch
bca0978434 Clean up component template creation for ModuleTypes 2022-02-18 11:56:49 -05:00
jeremystretch
e1f06ec862 Enable adding inventory items to components from device component list views 2022-02-18 10:09:51 -05:00
jeremystretch
a5c8bbf79e Closes #8684: Change custom link template context variable 'obj' to 'object' (backward-compatible) 2022-02-18 09:50:02 -05:00
jeremystretch
e8df373abf Change results tab name for clarity 2022-02-18 09:28:34 -05:00
jeremystretch
39b8eb0ae0 Fixes #8656: Fix migration error when upgrading from a v2.11 database 2022-02-17 14:14:23 -05:00
jeremystretch
eb02f6137e Fixes #8670: Fix filtering device components by installed module 2022-02-17 11:42:18 -05:00
jeremystretch
90ee689d5a Closes #8678: Validate minimum required Python version 2022-02-17 10:31:28 -05:00
jeremystretch
90558a2e80 Fixes #8671: Fix AttributeError when viewing console/power/interface connection lists 2022-02-16 16:45:56 -05:00
jeremystretch
b343035060 Fixes #8674: Fix rendering of tabbed content in documentation 2022-02-16 16:21:32 -05:00
jeremystretch
bcdd006dd5 Fixes #8661: Fix ValueError exception when trying to connect a cable 2022-02-16 09:27:16 -05:00
jeremystretch
92c4e5bfaf Fixes #8659: Fix display of multi-object custom fields after deleting related object 2022-02-16 09:07:01 -05:00
jeremystretch
fdbdf26afc Fixes #8655: Fix AttributeError when viewing cabled interfaces 2022-02-16 08:23:57 -05:00
Daniel Sheppard
6bbf168cec Fixes #8546 - Fix import to restrict bridge, parent, lag to device interfaces 2022-02-15 09:27:55 -06:00
151 changed files with 2862 additions and 2370 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.8
placeholder: v3.1.9
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.8
placeholder: v3.1.9
validations:
required: true
- type: dropdown

View File

@@ -2,6 +2,8 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div>
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is an infrastructure resource modeling (IRM) tool designed to empower

View File

@@ -117,3 +117,7 @@ svgwrite
# Tabular dataset library (for table-based exports)
# https://github.com/jazzband/tablib
tablib
# Timezone data (required by django-timezone-field on Python 3.9+)
# https://github.com/python/tzdata
tzdata

View File

@@ -11,17 +11,25 @@ Getting started with NetBox development is pretty straightforward, and should fe
### Fork the Repo
Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) You can then clone your GitHub fork locally for development:
Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) Click the "fork" button at top right (be sure that you've logged into GitHub first).
![GitHub fork button](../media/development/github_fork_button.png)
Copy the URL provided in the dialog box.
![GitHub fork dialog](../media/development/github_fork_dialog.png)
You can then clone your GitHub fork locally for development:
```no-highlight
$ git clone https://github.com/youruseraccount/netbox.git
$ git clone https://github.com/$username/netbox.git
Cloning into 'netbox'...
remote: Enumerating objects: 231, done.
remote: Counting objects: 100% (231/231), done.
remote: Compressing objects: 100% (147/147), done.
remote: Total 56705 (delta 134), reused 145 (delta 84), pack-reused 56474
Receiving objects: 100% (56705/56705), 27.96 MiB | 34.92 MiB/s, done.
Resolving deltas: 100% (44177/44177), done.
remote: Enumerating objects: 85949, done.
remote: Counting objects: 100% (4672/4672), done.
remote: Compressing objects: 100% (1224/1224), done.
remote: Total 85949 (delta 3538), reused 4332 (delta 3438), pack-reused 81277
Receiving objects: 100% (85949/85949), 55.16 MiB | 44.90 MiB/s, done.
Resolving deltas: 100% (68008/68008), done.
$ ls netbox/
base_requirements.txt contrib docs mkdocs.yml NOTICE requirements.txt upgrade.sh
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
@@ -33,7 +41,7 @@ The NetBox project utilizes three persistent git branches to track work:
* `develop` - All development on the upcoming stable release occurs here
* `feature` - Tracks work on an upcoming major release
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release.
For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0).
@@ -60,7 +68,7 @@ $ python3 -m venv ~/.venv/netbox
This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
!!! info "Where to Create Your Virtual Environments"
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please.
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. Also consider using [`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/en/stable/) to simplify the management of multiple venvs.
Once created, activate the virtual environment:
@@ -99,12 +107,13 @@ Within the `netbox/netbox/` directory, copy `configuration_example.py` to `confi
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command:
```no-highlight
$ python netbox/manage.py runserver
$ ./manage.py runserver
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
November 18, 2020 - 15:52:31
Django version 3.1, using settings 'netbox.settings'
February 18, 2022 - 20:29:57
Django version 4.0.2, using settings 'netbox.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
```
@@ -122,24 +131,36 @@ The demo data is provided in JSON format and loaded into an empty database using
## Running Tests
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository.
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command, which employs Python's [`unittest`](https://docs.python.org/3/library/unittest.html#module-unittest) library. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `netbox/` directory, not the root directory of the repository.
To avoid potential issues with your local configuration file, set the `NETBOX_CONFIGURATION` to point to the packaged test configuration at `netbox/configuration_testing.py`. This will handle things like ensuring that the dummy plugin is enabled for comprehensive testing.
```no-highlight
$ export NETBOX_CONFIGURATION=netbox.configuration_testing
$ cd netbox/
$ python manage.py test
```
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
In cases where you haven't made any changes to the database schema (which is typical), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
```no-highlight
$ python manage.py test --keepdb
```
You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests:
You can also reduce testing time by enabling parallel test execution with the `--parallel` flag. (By default, this will run as many parallel tests as you have processors. To avoid sluggishness, it's a good idea to specify a lower number of parallel tests.) This flag can be combined with `--keepdb`, although if you encounter any strange errors, try running the test suite again with parallelization disabled.
```no-highlight
$ python manage.py test --parallel <n>
```
Finally, it's possible to limit the run to a specific set of tests, specified by their Python path. For example, to run only IPAM and DCIM view tests:
```no-highlight
$ python manage.py test dcim.tests.test_views ipam.tests.test_views
```
This is handy for instances where just a few tests are failing and you want to re-run them individually.
## Submitting Pull Requests
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.

View File

@@ -8,7 +8,7 @@ Check `base_requirements.txt` for any dependencies pinned to a specific version,
### Link to the Release Notes Page
Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and add a summary of the major changes to `index.md`.
### Manually Perform a New Install

View File

@@ -1,5 +1,7 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
# What is NetBox?
NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -24,13 +24,14 @@ Custom links appear as buttons in the top right corner of the page. Numeric weig
The following context data is available within the template when rendering a custom link's text or URL.
| Variable | Description |
|----------|-------------|
| `obj` | The NetBox object being displayed |
| `debug` | A boolean indicating whether debugging is enabled |
| `request` | The current WSGI request |
| `user` | The current user (if authenticated) |
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
| Variable | Description |
|-----------|-------------------------------------------------------------------------------------------------------------------|
| `object` | The NetBox object being displayed |
| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 |
| `debug` | A boolean indicating whether debugging is enabled |
| `request` | The current WSGI request |
| `user` | The current user (if authenticated) |
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
## Conditional Rendering

View File

@@ -1,432 +0,0 @@
# Plugin Development
!!! info "Help Improve the NetBox Plugins Framework!"
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
Plugins can do a lot, including:
* Create Django models to store data in the database
* Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links
* Establish their own REST API endpoints
* Add custom request/response middleware
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
!!! warning
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
## Initial Setup
### 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
project-name/
- plugin_name/
- templates/
- plugin_name/
- *.html
- __init__.py
- middleware.py
- navigation.py
- signals.py
- template_content.py
- urls.py
- views.py
- README
- setup.py
```
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. This must be a valid Python package name (e.g. no spaces or hyphens).
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
`setup.py` is the [setup script](https://docs.python.org/3.7/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
```python
from setuptools import find_packages, setup
setup(
name='netbox-animal-sounds',
version='0.1',
description='An example NetBox plugin',
url='https://github.com/netbox-community/netbox-animal-sounds',
author='Jeremy Stretch',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
from extras.plugins import PluginConfig
class AnimalSoundsConfig(PluginConfig):
name = 'netbox_animal_sounds'
verbose_name = 'Animal Sounds'
description = 'An example plugin for development purposes'
version = '0.1'
author = 'Jeremy Stretch'
author_email = 'author@example.com'
base_url = 'animal-sounds'
required_settings = []
default_settings = {
'loud': False
}
config = AnimalSoundsConfig
```
NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors.
#### PluginConfig Attributes
| Name | Description |
| ---- | ----------- |
| `name` | Raw plugin name; same as the plugin's source directory |
| `verbose_name` | Human-friendly name for the plugin |
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
| `description` | Brief description of the plugin's purpose |
| `author` | Name of plugin's author |
| `author_email` | Author's public email address |
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values |
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
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`):
```no-highlight
$ python setup.py develop
```
## Database Models
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
Below is an example `models.py` file containing a model with two character fields:
```python
from django.db import models
class Animal(models.Model):
name = models.CharField(max_length=50)
sound = models.CharField(max_length=50)
def __str__(self):
return self.name
```
Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command.
!!! note
A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory.
```no-highlight
$ ./manage.py makemigrations netbox_animal_sounds
Migrations for 'netbox_animal_sounds':
/home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py
- Create model Animal
```
Next, we can apply the migration to the database with the `migrate` command:
```no-highlight
$ ./manage.py migrate netbox_animal_sounds
Operations to perform:
Apply all migrations: netbox_animal_sounds
Running migrations:
Applying netbox_animal_sounds.0001_initial... OK
```
For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
### Using the Django Admin Interface
Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below:
```python
from django.contrib import admin
from .models import Animal
@admin.register(Animal)
class AnimalAdmin(admin.ModelAdmin):
list_display = ('name', 'sound')
```
This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view.
![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png)
## Views
If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`:
```python
from django.shortcuts import render
from django.views.generic import View
from .models import Animal
class RandomAnimalView(View):
"""
Display a randomly-selected animal.
"""
def get(self, request):
animal = Animal.objects.order_by('?').first()
return render(request, 'netbox_animal_sounds/animal.html', {
'animal': animal,
})
```
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
### Extending the Base Template
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
* `title` - The page title
* `header` - The upper portion of the page
* `content` - The main page body
* `javascript` - A section at the end of the page for including Javascript code
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/layout.html' %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px">
{% if animal %}
The {{ animal.name|lower }} says
{% if config.loud %}
{{ animal.sound|upper }}!
{% else %}
{{ animal.sound }}
{% endif %}
{% else %}
No animals have been created yet!
{% endif %}
</h2>
{% endwith %}
{% endblock %}
```
The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block.
!!! note
Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of.
Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths.
```python
from django.urls import path
from . import views
urlpatterns = [
path('random/', views.RandomAnimalView.as_view(), name='random_animal'),
]
```
A URL pattern has three components:
* `route` - The unique portion of the URL dedicated to this view
* `view` - The view itself
* `name` - A short name used to identify the URL path internally
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
## REST API Endpoints
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple.
First, we'll create a serializer for our `Animal` model, in `api/serializers.py`:
```python
from rest_framework.serializers import ModelSerializer
from netbox_animal_sounds.models import Animal
class AnimalSerializer(ModelSerializer):
class Meta:
model = Animal
fields = ('id', 'name', 'sound')
```
Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`:
```python
from rest_framework.viewsets import ModelViewSet
from netbox_animal_sounds.models import Animal
from .serializers import AnimalSerializer
class AnimalViewSet(ModelViewSet):
queryset = Animal.objects.all()
serializer_class = AnimalSerializer
```
Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
```python
from rest_framework import routers
from .views import AnimalViewSet
router = routers.DefaultRouter()
router.register('animals', AnimalViewSet)
urlpatterns = router.urls
```
With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined.
![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png)
!!! warning
This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have.
## Navigation Menu Items
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = (
PluginMenuItem(
link='plugins:netbox_animal_sounds:random_animal',
link_text='Random sound',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
),
)
```
A `PluginMenuItem` has the following attributes:
* `link` - The name of the URL path to which this menu item links
* `link_text` - The text presented to the user
* `permissions` - A list of permissions required to display this link (optional)
* `buttons` - An iterable of PluginMenuButton instances to display (optional)
A `PluginMenuButton` has the following attributes:
* `link` - The name of the URL path to which this button links
* `title` - The tooltip text (displayed when the mouse hovers over the button)
* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/))
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (optional)
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
## Extending Core Templates
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
* `left_page()` - Inject content on the left side of the page
* `right_page()` - Inject content on the right side of the page
* `full_width_page()` - Inject content across the entire bottom of the page
* `buttons()` - Add buttons to the top of the page
Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
* `object` - The object being viewed
* `request` - The current request
* `settings` - Global NetBox settings
* `config` - Plugin-specific configuration parameters
For example, accessing `{{ request.user }}` within a template will return the current user.
Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below.
```python
from extras.plugins import PluginTemplateExtension
from .models import Animal
class SiteAnimalCount(PluginTemplateExtension):
model = 'dcim.site'
def right_page(self):
return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={
'animal_count': Animal.objects.count(),
})
template_extensions = [SiteAnimalCount]
```
## Background Tasks
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
These 3 core queues can be used out-of-the-box by plugins to define background tasks.
Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration
is below:
```python
class MyPluginConfig(PluginConfig):
name = 'myplugin'
...
queues = [
'queue1',
'queue2',
'queue-whatever-the-name'
]
```
The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*.
As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins.
In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks.
```
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
```

View File

@@ -1,27 +1,30 @@
# Background Tasks
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
These 3 core queues can be used out-of-the-box by plugins to define background tasks.
NetBox supports the queuing of tasks that need to be performed in the background, decoupled from the request-response cycle, using the [Python RQ](https://python-rq.org/) library. Three task queues of differing priority are defined by default:
Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration
is below:
* High
* Default
* Low
Any tasks in the "high" queue are completed before the default queue is checked, and any tasks in the default queue are completed before those in the "low" queue.
Plugins can also add custom queues for their own needs by setting the `queues` attribute under the PluginConfig class. An example is included below:
```python
class MyPluginConfig(PluginConfig):
name = 'myplugin'
...
queues = [
'queue1',
'queue2',
'queue-whatever-the-name'
'foo',
'bar',
]
```
The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*.
As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins.
The PluginConfig above creates two custom queues with the following names `my_plugin.foo` and `my_plugin.bar`. (The plugin's name is prepended to each queue to avoid conflicts between plugins.)
In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks.
```
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
```
!!! warning "Configuring the RQ worker process"
By default, NetBox's RQ worker process only services the high, default, and low queues. Plugins which introduce custom queues should advise users to either reconfigure the default worker, or run a dedicated worker specifying the necessary queues. For example:
```
python manage.py rqworker my_plugin.foo my_plugin.bar
```

View File

@@ -1,4 +1,4 @@
# Filter Sets
# Filters & Filter Sets
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
@@ -27,9 +27,9 @@ class MyFilterSet(NetBoxModelFilterSet):
fields = ('some', 'other', 'fields')
```
## Declaring Filter Sets
### Declaring Filter Sets
To utilize a filter set in the subclass of a generic view, such as `ObjectListView` or `BulkEditView`, set it as the `filterset` attribute on the view class:
To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class:
```python
# views.py
@@ -42,7 +42,7 @@ class MyModelListView(ObjectListView):
filterset = MyModelFitlerSet
```
To enable a filter on a REST API endpoint, set it as the `filterset_class` attribute on the API view:
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
```python
# api/views.py
@@ -54,3 +54,17 @@ class MyModelViewSet(...):
serializer_class = serializers.MyModelSerializer
filterset_class = filtersets.MyModelFilterSet
```
## Filter Classes
### TagFilter
The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
```python
from django_filters import FilterSet
from extras.filters import TagFilter
class MyModelFilterSet(FilterSet):
tag = TagFilter()
```

View File

@@ -2,14 +2,14 @@
## Form Classes
NetBox provides several base form classes for use by plugins. These are documented below.
NetBox provides several base form classes for use by plugins.
* `NetBoxModelForm`
* `NetBoxModelCSVForm`
* `NetBoxModelBulkEditForm`
* `NetBoxModelFilterSetForm`
### TODO: Include forms reference
<!-- TODO: Include forms reference -->
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.

View File

@@ -33,14 +33,10 @@ NetBox provides two object type classes for use by plugins.
::: netbox.graphql.types.BaseObjectType
selection:
members: false
rendering:
show_source: false
::: netbox.graphql.types.NetBoxObjectType
selection:
members: false
rendering:
show_source: false
## GraphQL Fields
@@ -49,11 +45,7 @@ NetBox provides two field classes for use by plugins.
::: netbox.graphql.fields.ObjectField
selection:
members: false
rendering:
show_source: false
::: netbox.graphql.fields.ObjectListField
selection:
members: false
rendering:
show_source: false

View File

@@ -1,106 +1,88 @@
# Plugins Development
!!! info "Help Improve the NetBox Plugins Framework!"
!!! tip "Help Improve the NetBox Plugins Framework!"
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently.
!!! info "Django Development"
Django is the Python framework on which NetBox is built. As Django itself is very well-documented, this documentation covers only the aspects of plugin development which are unique to NetBox.
Plugins can do a lot, including:
* Create Django models to store data in the database
* Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links
* Establish their own REST API endpoints
* Extend NetBox's REST and GraphQL APIs
* Add custom request/response middleware
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
!!! warning
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework to avoid breaking changes in future releases.
## 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 might look something like this:
```no-highlight
project-name/
- plugin_name/
- api/
- serializers.py
- urls.py
- views.py
- templates/
- plugin_name/
- *.html
- __init__.py
- filtersets.py
- models.py
- middleware.py
- navigation.py
- signals.py
- tables.py
- template_content.py
- urls.py
- views.py
- README
- README.md
- setup.py
```
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. This must be a valid Python package name (e.g. no spaces or hyphens).
* `README.md` - 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 to enable human-friendly display.
* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores.
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.
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, discussed below.
### Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
```python
from setuptools import find_packages, setup
setup(
name='netbox-animal-sounds',
version='0.1',
description='An example NetBox plugin',
url='https://github.com/netbox-community/netbox-animal-sounds',
author='Jeremy Stretch',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig
## PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
from extras.plugins import PluginConfig
class AnimalSoundsConfig(PluginConfig):
name = 'netbox_animal_sounds'
verbose_name = 'Animal Sounds'
description = 'An example plugin for development purposes'
class FooBarConfig(PluginConfig):
name = 'foo_bar'
verbose_name = 'Foo Bar'
description = 'An example NetBox plugin'
version = '0.1'
author = 'Jeremy Stretch'
author_email = 'author@example.com'
base_url = 'animal-sounds'
base_url = 'foo-bar'
required_settings = []
default_settings = {
'loud': False
'baz': True
}
config = AnimalSoundsConfig
config = FooBarConfig
```
NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors.
#### PluginConfig Attributes
### PluginConfig Attributes
| Name | Description |
|-----------------------|--------------------------------------------------------------------------------------------------------------------------|
@@ -116,6 +98,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `queues` | A list of custom background task queues to create |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
@@ -123,25 +106,60 @@ 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
## Create setup.py
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/`.)
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
```python
from setuptools import find_packages, setup
setup(
name='my-example-plugin',
version='0.1',
description='An example NetBox plugin',
url='https://github.com/jeremystretch/my-example-plugin',
author='Jeremy Stretch',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! info
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
## Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with 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
python3 -m venv ~/.virtualenvs/my_plugin
```
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.8/site-packages/
echo /opt/netbox/netbox > netbox.pth
echo /opt/netbox/netbox > $VENV/lib/python3.8/site-packages/netbox.pth
```
### Install the Plugin for Development
## Development Installation
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`):
```no-highlight
$ python setup.py develop
```
## Configure NetBox
To enable the plugin in NetBox, add it to the `PLUGINS` parameter in `configuration.py`:
```python
PLUGINS = [
'my_plugin',
]
```

View File

@@ -2,50 +2,26 @@
## Creating Models
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Instances of a model (objects) can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
Below is an example `models.py` file containing a model with two character fields:
Below is an example `models.py` file containing a model with two character (text) fields:
```python
from django.db import models
class Animal(models.Model):
name = models.CharField(max_length=50)
sound = models.CharField(max_length=50)
class MyModel(models.Model):
foo = models.CharField(max_length=50)
bar = models.CharField(max_length=50)
def __str__(self):
return self.name
return f'{self.foo} {self.bar}'
```
### Migrations
Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command.
!!! note
A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory.
```no-highlight
$ ./manage.py makemigrations netbox_animal_sounds
Migrations for 'netbox_animal_sounds':
/home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py
- Create model Animal
```
Next, we can apply the migration to the database with the `migrate` command:
```no-highlight
$ ./manage.py migrate netbox_animal_sounds
Operations to perform:
Apply all migrations: netbox_animal_sounds
Running migrations:
Applying netbox_animal_sounds.0001_initial... OK
```
For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
## Enabling NetBox Features
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable numerous feature, including:
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
* Change logging
* Custom fields
@@ -58,8 +34,8 @@ Plugin models can leverage certain NetBox features by inheriting from NetBox's `
This class performs two crucial functions:
1. Apply any fields, methods, or attributes necessary to the operation of these features
2. Register the model with NetBox as utilizing these feature
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
2. Register the model with NetBox as utilizing these features
Simply subclass BaseModel when defining a model in your plugin:
@@ -75,7 +51,9 @@ class MyModel(NetBoxModel):
### Enabling Features Individually
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.)
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)
For example, if we wanted to support only tags and export templates, we would inherit from NetBox's `ExportTemplatesMixin` and `TagsMixin` classes, and from Django's `Model` class. (Inheriting _all_ the available mixins is essentially the same as subclassing `NetBoxModel`.)
```python
# models.py
@@ -87,11 +65,35 @@ class MyModel(ExportTemplatesMixin, TagsMixin, models.Model):
...
```
The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.)
## Database Migrations
Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.)
!!! note Enable Developer Mode
NetBox enforces a safeguard around the `makemigrations` command to protect regular users from inadvertently creating erroneous schema migrations. To enable this command for plugin development, set `DEVELOPER=True` in `configuration.py`.
```no-highlight
$ ./manage.py makemigrations my_plugin
Migrations for 'my_plugin':
/home/jstretch/animal_sounds/my_plugin/migrations/0001_initial.py
- Create model MyModel
```
Next, we can apply the migration to the database with the `migrate` command:
```no-highlight
$ ./manage.py migrate my_plugin
Operations to perform:
Apply all migrations: my_plugin
Running migrations:
Applying my_plugin.0001_initial... OK
```
For more information about database migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
## Feature Mixins Reference
!!! note
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
::: netbox.models.features.ChangeLoggingMixin
@@ -109,3 +111,72 @@ The example above will enable export templates and tags, but no other NetBox fea
::: netbox.models.features.TagsMixin
::: netbox.models.features.WebhooksMixin
## Choice Sets
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)
To define choices for a model field, subclass `ChoiceSet` and define a tuple named `CHOICES`, of which each member is a two- or three-element tuple. These elements are:
* The database value
* The corresponding human-friendly label
* The assigned color (optional)
A complete example is provided below.
!!! note
Authors may find it useful to declare each of the database values as constants on the class, and reference them within `CHOICES` members. This convention allows the values to be referenced from outside the class, however it is not strictly required.
### Dynamic Configuration
Some model field choices in NetBox can be configured by an administrator. For example, the default values for the Site model's `status` field can be replaced or supplemented with custom choices. To enable dynamic configuration for a ChoiceSet subclass, define its `key` as a string specifying the model and field name to which it applies. For example:
```python
from utilities.choices import ChoiceSet
class StatusChoices(ChoiceSet):
key = 'MyModel.status'
```
To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/optional-settings.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as:
```python
FIELD_CHOICES = {
'my_plugin.MyModel.status': (
# Custom choices
)
}
```
### Example
```python
# choices.py
from utilities.choices import ChoiceSet
class StatusChoices(ChoiceSet):
key = 'MyModel.status'
STATUS_FOO = 'foo'
STATUS_BAR = 'bar'
STATUS_BAZ = 'baz'
CHOICES = (
(STATUS_FOO, 'Foo', 'red'),
(STATUS_BAR, 'Bar', 'green'),
(STATUS_BAZ, 'Baz', 'blue'),
)
```
```python
# models.py
from django.db import models
from .choices import StatusChoices
class MyModel(models.Model):
status = models.CharField(
max_length=50,
choices=StatusChoices,
default=StatusChoices.STATUS_FOO
)
```

View File

@@ -0,0 +1,50 @@
# Navigation
## Menu Items
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
!!! tip
The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = (
PluginMenuItem(
link='plugins:netbox_animal_sounds:random_animal',
link_text='Random sound',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
),
)
```
A `PluginMenuItem` has the following attributes:
| Attribute | Required | Description |
|---------------|----------|------------------------------------------------------|
| `link` | Yes | Name of the URL path to which this menu item links |
| `link_text` | Yes | The text presented to the user |
| `permissions` | - | A list of permissions required to display this link |
| `buttons` | - | An iterable of PluginMenuButton instances to include |
## Menu Buttons
A `PluginMenuButton` has the following attributes:
| Attribute | Required | Description |
|---------------|----------|--------------------------------------------------------------------|
| `link` | Yes | Name of the URL path to which this button links |
| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) |
| `icon_class` | Yes | Button icon CSS class* |
| `color` | - | One of the choices provided by `ButtonColorChoices` |
| `permissions` | - | A list of permissions required to display this button |
*NetBox supports [Material Design Icons](https://materialdesignicons.com/).
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.

View File

@@ -1,46 +1,69 @@
# REST API
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple.
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer.
First, we'll create a serializer for our `Animal` model, in `api/serializers.py`:
Generally speaking, there aren't many NetBox-specific components to implementing REST API functionality in a plugin. NetBox employs the [Django REST Framework](https://www.django-rest-framework.org/) (DRF) for its REST API, and plugin authors will find that they can largely replicate the same patterns found in NetBox's implementation. Some brief examples are included here for reference.
## Serializers
Serializers are responsible for converting Python objects to JSON data suitable for conveying to consumers, and vice versa. NetBox provides the `NetBoxModelSerializer` class for use by plugins to handle the assignment of tags and custom field data. (These features can also be included ad hoc via the `CustomFieldModelSerializer` and `TaggableModelSerializer` classes.)
### Example
To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.
```python
from rest_framework.serializers import ModelSerializer
from netbox_animal_sounds.models import Animal
# api/serializers.py
from netbox.api.serializers import NetBoxModelSerializer
from my_plugin.models import MyModel
class AnimalSerializer(ModelSerializer):
class MyModelSerializer(NetBoxModelSerializer):
class Meta:
model = Animal
fields = ('id', 'name', 'sound')
model = MyModel
fields = ('id', 'foo', 'bar')
```
Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`:
## Viewsets
Just as in the user interface, a REST API view handles the business logic of displaying and interacting with NetBox objects. NetBox provides the `NetBoxModelViewSet` class, which extends DRF's built-in `ModelViewSet` to handle bulk operations and object validation.
Unlike the user interface, typically only a single view set is required per model: This view set handles all request types (`GET`, `POST`, `DELETE`, etc.).
### Example
To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/views.py`, and define the `queryset` and `serializer_class` attributes.
```python
from rest_framework.viewsets import ModelViewSet
from netbox_animal_sounds.models import Animal
from .serializers import AnimalSerializer
# api/views.py
from netbox.api.viewsets import ModelViewSet
from my_plugin.models import MyModel
from .serializers import MyModelSerializer
class AnimalViewSet(ModelViewSet):
queryset = Animal.objects.all()
serializer_class = AnimalSerializer
class MyModelViewSet(ModelViewSet):
queryset = MyModel.objects.all()
serializer_class = MyModelSerializer
```
Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
## Routers
Routers map URLs to REST API views (endpoints). NetBox does not provide any custom components for this; the [`DefaultRouter`](https://www.django-rest-framework.org/api-guide/routers/#defaultrouter) class provided by DRF should suffice for most use cases.
Routers should be exposed in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
### Example
```python
# api/urls.py
from rest_framework import routers
from .views import AnimalViewSet
from .views import MyModelViewSet
router = routers.DefaultRouter()
router.register('animals', AnimalViewSet)
router.register('my-model', MyModelViewSet)
urlpatterns = router.urls
```
With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined.
![NetBox REST API plugin endpoint](../../media/plugins/plugin_rest_api_endpoint.png)
This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`.
!!! warning
This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have.
The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address.

View File

@@ -12,9 +12,9 @@ To provide additional functionality beyond what is supported by the stock `Table
It also includes several default columns:
* `pk` - A checkbox for selecting the object associated with each table row
* `id` - The object's numeric database ID, as a hyperlink to the object's view
* `actions` - A dropdown menu presenting object-specific actions available to the user.
* `pk` - A checkbox for selecting the object associated with each table row (where applicable)
* `id` - The object's numeric database ID, as a hyperlink to the object's view (hidden by default)
* `actions` - A dropdown menu presenting object-specific actions available to the user
### Example
@@ -38,69 +38,51 @@ class MyModelTable(NetBoxTable):
### Table Configuration
The NetBoxTable class supports dynamic configuration to support pagination and to effect user preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:
The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:
```python
table = MyModelTable(data=MyModel.objects.all())
table.configure(request)
```
If using a generic view provided by NetBox, table configuration is handled automatically.
This will automatically apply any user-specific preferences for the table. (If using a generic view provided by NetBox, table configuration is handled automatically.)
## Columns
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
::: netbox.tables.BooleanColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.ChoiceFieldColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.ColorColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.ColoredLabelColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.ContentTypeColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.ContentTypesColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.MarkdownColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.TagColumn
rendering:
show_source: false
selection:
members: false
::: netbox.tables.TemplateColumn
rendering:
show_source: false
selection:
members: false

View File

@@ -1,19 +1,25 @@
# Templates
## Base Templates
Templates are used to render HTML content generated from a set of context data. NetBox provides a set of built-in templates suitable for use in plugin views. Plugin authors can extend these templates to minimize the work needed to create custom templates while ensuring that the content they produce matches NetBox's layout and style. These templates are all written in the [Django Template Language (DTL)](https://docs.djangoproject.com/en/stable/ref/templates/language/).
## Standard Blocks
The following template blocks are available on all templates.
| Name | Required | Description |
|--------------|----------|---------------------------------------------------------------------|
| `title` | Yes | Page title |
| `content` | Yes | Page content |
| `head` | - | Content to include in the HTML `<head>` element |
| `javascript` | - | Javascript content included at the end of the HTML `<body>` element |
| Name | Required | Description |
|----------------|----------|---------------------------------------------------------------------|
| `title` | Yes | Page title |
| `content` | Yes | Page content |
| `head` | - | Content to include in the HTML `<head>` element |
| `footer` | - | Page footer content |
| `footer_links` | - | Links section of the page footer |
| `javascript` | - | Javascript content included at the end of the HTML `<body>` element |
!!! note
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
## Base Templates
### layout.html
Path: `base/layout.html`
@@ -44,7 +50,7 @@ An example of a plugin template which extends `layout.html` is included below.
{% endblock content %}
```
The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block.
The first line of the template instructs Django to extend the NetBox base template, and the `block` sections inject our custom content within its `header` and `content` blocks.
!!! note
Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important distinctions of which authors should be aware. Be sure to familiarize yourself with Django's template language before attempting to create new templates.
@@ -126,14 +132,14 @@ This template is used by the `ObjectListView` generic view to display a filterab
#### Context
| Name | Required | Description |
|------------------|----------|-----------------------------------------------------------------------|
| `model` | Yes | The object class |
| `table` | Yes | The table class used for rendering the list of objects |
| `permissions` | Yes | A mapping of add, change, and delete permissions for the current user |
| `action_buttons` | Yes | A list of buttons to display (options are `add`, `import`, `export`) |
| `filter_form` | - | The bound filterset form for filtering the objects list |
| `return_url` | - | The return URL to pass when submitting a bulk operation form |
| Name | Required | Description |
|---------------|----------|---------------------------------------------------------------------------------------------|
| `model` | Yes | The object class |
| `table` | Yes | The table class used for rendering the list of objects |
| `permissions` | Yes | A mapping of add, change, and delete permissions for the current user |
| `actions` | Yes | A list of buttons to display (`add`, `import`, `export`, `bulk_edit`, and/or `bulk_delete`) |
| `filter_form` | - | The bound filterset form for filtering the objects list |
| `return_url` | - | The return URL to pass when submitting a bulk operation form |
### bulk_import.html

View File

@@ -1,6 +1,10 @@
# Views
If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`:
## Writing Views
If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`.
As an example, let's write a view which displays a random animal and the sound it makes. We'll use Django's generic `View` class to minimize the amount of boilerplate code needed.
```python
from django.shortcuts import render
@@ -18,39 +22,11 @@ class RandomAnimalView(View):
})
```
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
This view retrieves a random Animal instance from the database and passes it as a context variable when rendering a template named `animal.html`. HTTP `GET` requests are handled by the view's `get()` method, and `POST` requests are handled by its `post()` method.
## View Classes
Our example above is extremely simple, but views can do just about anything. They are generally where the core of your plugin's functionality will reside. Views also are not limited to returning HTML content: A view could return a CSV file or image, for instance. For more information on views, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/class-based-views/).
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
| View Class | Description |
|------------|-------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
### Example Usage
```python
# views.py
from netbox.views.generic import ObjectEditView
from .models import Thing
class ThingEditView(ObjectEditView):
queryset = Thing.objects.all()
template_name = 'myplugin/thing.html'
...
```
## URL Registration
### URL Registration
To make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths.
@@ -71,6 +47,98 @@ A URL pattern has three components:
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
### View Classes
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
| View Class | Description |
|--------------------|--------------------------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
#### Example Usage
```python
# views.py
from netbox.views.generic import ObjectEditView
from .models import Thing
class ThingEditView(ObjectEditView):
queryset = Thing.objects.all()
template_name = 'myplugin/thing.html'
...
```
## Object Views
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseObjectView
::: netbox.views.generic.ObjectView
selection:
members:
- get_object
- get_template_name
::: netbox.views.generic.ObjectEditView
selection:
members:
- get_object
- alter_object
::: netbox.views.generic.ObjectDeleteView
selection:
members:
- get_object
## Multi-Object Views
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseMultiObjectView
::: netbox.views.generic.ObjectListView
selection:
members:
- get_table
- export_table
- export_template
::: netbox.views.generic.BulkImportView
selection:
members: false
::: netbox.views.generic.BulkEditView
selection:
members: false
::: netbox.views.generic.BulkDeleteView
selection:
members:
- get_form
## Feature Views
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
::: netbox.views.generic.ObjectChangeLogView
selection:
members:
- get_form
::: netbox.views.generic.ObjectJournalView
selection:
members:
- get_form
## Extending Core Views
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
@@ -107,126 +175,3 @@ class SiteAnimalCount(PluginTemplateExtension):
template_extensions = [SiteAnimalCount]
```
## Navigation Menu Items
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = (
PluginMenuItem(
link='plugins:netbox_animal_sounds:random_animal',
link_text='Random sound',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
),
)
```
A `PluginMenuItem` has the following attributes:
* `link` - The name of the URL path to which this menu item links
* `link_text` - The text presented to the user
* `permissions` - A list of permissions required to display this link (optional)
* `buttons` - An iterable of PluginMenuButton instances to display (optional)
A `PluginMenuButton` has the following attributes:
* `link` - The name of the URL path to which this button links
* `title` - The tooltip text (displayed when the mouse hovers over the button)
* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/))
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (optional)
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
## Object Views
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseObjectView
rendering:
show_source: false
::: netbox.views.generic.ObjectView
selection:
members:
- get_object
- get_template_name
rendering:
show_source: false
::: netbox.views.generic.ObjectEditView
selection:
members:
- get_object
- alter_object
rendering:
show_source: false
::: netbox.views.generic.ObjectDeleteView
selection:
members:
- get_object
rendering:
show_source: false
## Multi-Object Views
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseMultiObjectView
rendering:
show_source: false
::: netbox.views.generic.ObjectListView
selection:
members:
- get_table
- export_table
- export_template
rendering:
show_source: false
::: netbox.views.generic.BulkImportView
selection:
members: false
rendering:
show_source: false
::: netbox.views.generic.BulkEditView
selection:
members: false
rendering:
show_source: false
::: netbox.views.generic.BulkDeleteView
selection:
members:
- get_form
rendering:
show_source: false
## Feature Views
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
::: netbox.views.generic.ObjectChangeLogView
selection:
members:
- get_form
rendering:
show_source: false
::: netbox.views.generic.ObjectJournalView
selection:
members:
- get_form
rendering:
show_source: false

View File

@@ -1,6 +1,33 @@
# NetBox v3.1
## v3.1.9 (FUTURE)
## v3.1.10 (FUTURE)
---
## v3.1.9 (2022-03-07)
### Enhancements
* [#8594](https://github.com/netbox-community/netbox/issues/8594) - Enable filtering by exact description match for all applicable models
* [#8629](https://github.com/netbox-community/netbox/issues/8629) - Add description to tag table search function
* [#8664](https://github.com/netbox-community/netbox/issues/8664) - Show assigned ASNs/sites under list views
* [#8736](https://github.com/netbox-community/netbox/issues/8736) - Add PC and UPC fiber end faces for LC/SC/LSH port types
* [#8758](https://github.com/netbox-community/netbox/issues/8758) - Allow empty string substitution when renaming objects in bulk
* [#8762](https://github.com/netbox-community/netbox/issues/8762) - Link to rack elevations list from site view
* [#8766](https://github.com/netbox-community/netbox/issues/8766) - Add SCTP to service protocols list
### Bug Fixes
* [#8546](https://github.com/netbox-community/netbox/issues/8546) - Fix bulk import to restrict bridge, parent, and LAG to device interfaces
* [#8633](https://github.com/netbox-community/netbox/issues/8633) - Prevent navigation sidebar pin from disappearing at certain breakpoints
* [#8674](https://github.com/netbox-community/netbox/issues/8674) - Fix rendering of tabbed content in documentation
* [#8710](https://github.com/netbox-community/netbox/issues/8710) - Fix dynamic scope selection form fields when creating a VLAN group
* [#8713](https://github.com/netbox-community/netbox/issues/8713) - Restore missing "add" button on services list view
* [#8715](https://github.com/netbox-community/netbox/issues/8715) - Avoid returning multiple objects when restricting querysets using multiple tags in permissions
* [#8717](https://github.com/netbox-community/netbox/issues/8717) - Fix redirection after bulk edit/delete of prefixes from aggregate view
* [#8724](https://github.com/netbox-community/netbox/issues/8724) - Fix exception during device import with invalid device type
* [#8807](https://github.com/netbox-community/netbox/issues/8807) - Correct REST API URL for FHRP group assignments
* [#8808](https://github.com/netbox-community/netbox/issues/8808) - Fix members count under FHRP group list
---

View File

@@ -47,6 +47,7 @@ NetBox's plugins framework has been extended considerably in this release. Addit
* The `NetBoxTable` base class for rendering object tables with `django-tables2`, as well as various custom column classes
* Function-specific templates (for generic views)
* Various custom template tags and filters
* `NetBoxModelViewSet` and several base serializer classes now provide enhanced REST API functionality
* Plugins can now extend NetBox's GraphQL API with their own schema
No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their existing code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized.
@@ -144,6 +145,24 @@ Where it is desired to limit the range of available VLANs within a group, users
* [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports
* [#8649](https://github.com/netbox-community/netbox/issues/8649) - Enable customization of configuration module using `NETBOX_CONFIGURATION` environment variable
### Bug Fixes (From Beta1)
* [#8655](https://github.com/netbox-community/netbox/issues/8655) - Fix AttributeError when viewing cabled interfaces
* [#8656](https://github.com/netbox-community/netbox/issues/8656) - Fix migration error when upgrading from a v2.11 database
* [#8659](https://github.com/netbox-community/netbox/issues/8659) - Fix display of multi-object custom fields after deleting related object
* [#8661](https://github.com/netbox-community/netbox/issues/8661) - Fix ValueError exception when trying to connect a cable
* [#8670](https://github.com/netbox-community/netbox/issues/8670) - Fix filtering device components by installed module
* [#8671](https://github.com/netbox-community/netbox/issues/8671) - Fix AttributeError when viewing console/power/interface connection lists
* [#8682](https://github.com/netbox-community/netbox/issues/8682) - Limit available VLANs by group min/max VIDs
* [#8683](https://github.com/netbox-community/netbox/issues/8683) - Fix `ZoneInfoNotFoundError` exception under Python 3.9+
* [#8761](https://github.com/netbox-community/netbox/issues/8761) - Correct view name resolution under journal entry views
* [#8763](https://github.com/netbox-community/netbox/issues/8763) - Fix inventory item component assignment
* [#8764](https://github.com/netbox-community/netbox/issues/8764) - Correct view name resolution for dynamic form fields
* [#8791](https://github.com/netbox-community/netbox/issues/8791) - Fix display of form validation failures during device component creation
* [#8792](https://github.com/netbox-community/netbox/issues/8792) - Fix creation of circuit terminations via UI
* [#8810](https://github.com/netbox-community/netbox/issues/8810) - Enable filtering modules by type
* [#8815](https://github.com/netbox-community/netbox/issues/8815) - Fix display of custom object fields in table columns
### Other Changes
* [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later
@@ -152,6 +171,7 @@ Where it is desired to limit the range of available VLANs within a group, users
* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs
* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys
* [#8509](https://github.com/netbox-community/netbox/issues/8509) - `CSRF_TRUSTED_ORIGINS` is now a discrete configuration parameter (rather than being populated from `ALLOWED_HOSTS`)
* [#8684](https://github.com/netbox-community/netbox/issues/8684) - Change custom link template context variable `obj` to `object` (backward-compatible)
### REST API Changes

View File

@@ -8,11 +8,13 @@ theme:
icon:
repo: fontawesome/brands/github
palette:
- scheme: default
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/lightbulb-outline
name: Switch to Dark Mode
- scheme: slate
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/lightbulb
name: Switch to Light Mode
@@ -32,6 +34,7 @@ plugins:
show_root_heading: true
show_root_full_path: false
show_root_toc_entry: false
show_source: false
extra:
social:
- icon: fontawesome/brands/github
@@ -50,7 +53,8 @@ markdown_extensions:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.superfences
- pymdownx.tabbed
- pymdownx.tabbed:
alternate_style: true
nav:
- Introduction: 'index.md'
- Installation:
@@ -104,12 +108,13 @@ nav:
- Getting Started: 'plugins/development/index.md'
- Models: 'plugins/development/models.md'
- Views: 'plugins/development/views.md'
- Navigation: 'plugins/development/navigation.md'
- Templates: 'plugins/development/templates.md'
- Tables: 'plugins/development/tables.md'
- Forms: 'plugins/development/forms.md'
- Filter Sets: 'plugins/development/filtersets.md'
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql.md'
- GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md'
- Administration:
- Authentication: 'administration/authentication.md'

View File

@@ -5,7 +5,7 @@ from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import LinkTerminationSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -14,7 +14,7 @@ from .nested_serializers import *
# Providers
#
class ProviderSerializer(PrimaryModelSerializer):
class ProviderSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = serializers.IntegerField(read_only=True)
@@ -30,7 +30,7 @@ class ProviderSerializer(PrimaryModelSerializer):
# Provider networks
#
class ProviderNetworkSerializer(PrimaryModelSerializer):
class ProviderNetworkSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
provider = NestedProviderSerializer()
@@ -46,7 +46,7 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
# Circuits
#
class CircuitTypeSerializer(PrimaryModelSerializer):
class CircuitTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True)
@@ -70,7 +70,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
]
class CircuitSerializer(PrimaryModelSerializer):
class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False)

View File

@@ -3,8 +3,7 @@ from rest_framework.routers import APIRootView
from circuits import filtersets
from circuits.models import *
from dcim.api.views import PassThroughPortMixin
from extras.api.views import CustomFieldModelViewSet
from netbox.api.views import ModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.utils import count_related
from . import serializers
@@ -21,7 +20,7 @@ class CircuitsRootView(APIRootView):
# Providers
#
class ProviderViewSet(CustomFieldModelViewSet):
class ProviderViewSet(NetBoxModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=count_related(Circuit, 'provider')
)
@@ -33,7 +32,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
# Circuit Types
#
class CircuitTypeViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(NetBoxModelViewSet):
queryset = CircuitType.objects.prefetch_related('tags').annotate(
circuit_count=count_related(Circuit, 'type')
)
@@ -45,7 +44,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet):
# Circuits
#
class CircuitViewSet(CustomFieldModelViewSet):
class CircuitViewSet(NetBoxModelViewSet):
queryset = Circuit.objects.prefetch_related(
'type', 'tenant', 'provider', 'termination_a', 'termination_z'
).prefetch_related('tags')
@@ -57,7 +56,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
# Circuit Terminations
#
class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'provider_network', 'cable'
)
@@ -70,7 +69,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
# Provider networks
#
class ProviderNetworkViewSet(CustomFieldModelViewSet):
class ProviderNetworkViewSet(NetBoxModelViewSet):
queryset = ProviderNetwork.objects.prefetch_related('tags')
serializer_class = serializers.ProviderNetworkSerializer
filterset_class = filtersets.ProviderNetworkFilterSet

View File

@@ -6,7 +6,7 @@ from utilities.choices import ChoiceSet
#
class CircuitStatusChoices(ChoiceSet):
key = 'circuits.Circuit.status'
key = 'Circuit.status'
STATUS_DEPROVISIONING = 'deprovisioning'
STATUS_ACTIVE = 'active'

View File

@@ -95,7 +95,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet):
class Meta:
model = ProviderNetwork
fields = ['id', 'name', 'service_id']
fields = ['id', 'name', 'service_id', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -112,7 +112,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'description']
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -189,7 +189,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = Circuit
fields = ['id', 'cid', 'install_date', 'commit_rate']
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
def search(self, queryset, name, value):
if not value.strip():
@@ -230,7 +230,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
class Meta:
model = CircuitTermination
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -125,6 +125,19 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
initial_params={
'circuits': '$circuit'
}
)
circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
query_params={
'provider_id': '$provider',
},
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -155,8 +168,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = CircuitTermination
fields = [
'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
'upstream_speed', 'xconnect_id', 'pp_info', 'description',
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",
@@ -164,12 +177,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'pp_info': "Patch panel ID and port number(s)"
}
widgets = {
'term_side': forms.HiddenInput(),
'term_side': StaticSelect(),
'port_speed': SelectSpeedWidget(),
'upstream_speed': SelectSpeedWidget(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)

View File

@@ -0,0 +1,3 @@
from .circuits import *
from .columns import *
from .providers import *

View File

@@ -1,15 +1,13 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from circuits.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from .models import *
from .columns import CommitRateColumn
__all__ = (
'CircuitTable',
'CircuitTypeTable',
'ProviderTable',
'ProviderNetworkTable',
)
@@ -22,81 +20,6 @@ CIRCUITTERMINATION_LINK = """
"""
#
# Table columns
#
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None
#
# Providers
#
class ProviderTable(NetBoxTable):
name = tables.Column(
linkify=True
)
circuit_count = tables.Column(
accessor=Accessor('count_circuits'),
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
class Meta(NetBoxTable.Meta):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
#
# Provider networks
#
class ProviderNetworkTable(NetBoxTable):
name = tables.Column(
linkify=True
)
provider = tables.Column(
linkify=True
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:providernetwork_list'
)
class Meta(NetBoxTable.Meta):
model = ProviderNetwork
fields = (
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',
)
default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
#
# Circuit types
#
class CircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True
@@ -116,10 +39,6 @@ class CircuitTypeTable(NetBoxTable):
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
#
# Circuits
#
class CircuitTable(NetBoxTable):
cid = tables.Column(
linkify=True,

View File

@@ -0,0 +1,21 @@
import django_tables2 as tables
__all__ = (
'CommitRateColumn',
)
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None

View File

@@ -0,0 +1,52 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from circuits.models import *
from netbox.tables import NetBoxTable, columns
__all__ = (
'ProviderTable',
'ProviderNetworkTable',
)
class ProviderTable(NetBoxTable):
name = tables.Column(
linkify=True
)
circuit_count = tables.Column(
accessor=Accessor('count_circuits'),
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
class Meta(NetBoxTable.Meta):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
class ProviderNetworkTable(NetBoxTable):
name = tables.Column(
linkify=True
)
provider = tables.Column(
linkify=True
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:providernetwork_list'
)
class Meta(NetBoxTable.Meta):
model = ProviderNetwork
fields = (
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',
)
default_columns = ('pk', 'name', 'provider', 'service_id', 'description')

View File

@@ -108,8 +108,8 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
CircuitType.objects.bulk_create((
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
CircuitType(name='Circuit Type 1', slug='circuit-type-1', description='foobar1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2', description='foobar2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
))
@@ -121,6 +121,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['circuit-type-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Circuit.objects.all()
@@ -187,8 +191,8 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
@@ -241,6 +245,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -319,8 +327,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit.objects.bulk_create(circuits)
circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'),
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
@@ -349,6 +357,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'xconnect_id': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
circuits = Circuit.objects.all()[:2]
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
@@ -386,8 +398,8 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
Provider.objects.bulk_create(providers)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 1', provider=providers[0], description='foobar1'),
ProviderNetwork(name='Provider Network 2', provider=providers[1], description='foobar2'),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
@@ -396,6 +408,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['Provider Network 1', 'Provider Network 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}

View File

@@ -218,6 +218,7 @@ class CircuitTerminationTestCase(
CircuitTermination.objects.bulk_create(circuit_terminations)
cls.form_data = {
'circuit': circuits[2].pk,
'term_side': 'A',
'site': sites[2].pk,
'description': 'New description',

View File

@@ -57,7 +57,7 @@ urlpatterns = [
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),

View File

@@ -318,14 +318,6 @@ class CircuitTerminationEditView(generic.ObjectEditView):
model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
def alter_object(self, obj, request, url_args, url_kwargs):
if 'circuit' in url_kwargs:
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
return obj
def get_return_url(self, request, obj):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()

View File

@@ -12,7 +12,7 @@ from ipam.api.nested_serializers import (
from ipam.models import ASN, VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
)
from netbox.config import ConfigItem
from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -109,7 +109,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
]
class SiteSerializer(PrimaryModelSerializer):
class SiteSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = NestedRegionSerializer(required=False, allow_null=True)
@@ -161,7 +161,7 @@ class LocationSerializer(NestedGroupModelSerializer):
]
class RackRoleSerializer(PrimaryModelSerializer):
class RackRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True)
@@ -173,7 +173,7 @@ class RackRoleSerializer(PrimaryModelSerializer):
]
class RackSerializer(PrimaryModelSerializer):
class RackSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
site = NestedSiteSerializer()
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
@@ -212,7 +212,7 @@ class RackUnitSerializer(serializers.Serializer):
return obj['name']
class RackReservationSerializer(PrimaryModelSerializer):
class RackReservationSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = NestedRackSerializer()
user = NestedUserSerializer()
@@ -266,7 +266,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
# Device/module types
#
class ManufacturerSerializer(PrimaryModelSerializer):
class ManufacturerSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -280,7 +280,7 @@ class ManufacturerSerializer(PrimaryModelSerializer):
]
class DeviceTypeSerializer(PrimaryModelSerializer):
class DeviceTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
@@ -296,7 +296,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
]
class ModuleTypeSerializer(PrimaryModelSerializer):
class ModuleTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer()
# module_count = serializers.IntegerField(read_only=True)
@@ -487,7 +487,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
# Devices
#
class DeviceRoleSerializer(PrimaryModelSerializer):
class DeviceRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
@@ -500,7 +500,7 @@ class DeviceRoleSerializer(PrimaryModelSerializer):
]
class PlatformSerializer(PrimaryModelSerializer):
class PlatformSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
@@ -514,7 +514,7 @@ class PlatformSerializer(PrimaryModelSerializer):
]
class DeviceSerializer(PrimaryModelSerializer):
class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
@@ -556,7 +556,7 @@ class DeviceSerializer(PrimaryModelSerializer):
return data
class ModuleSerializer(PrimaryModelSerializer):
class ModuleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
device = NestedDeviceSerializer()
module_bay = NestedModuleBaySerializer()
@@ -594,7 +594,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components
#
class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -622,7 +622,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
]
class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -650,7 +650,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
]
class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -685,7 +685,7 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
]
class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -709,7 +709,7 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
]
class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -768,7 +768,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
return super().validate(data)
class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -798,7 +798,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -818,7 +818,7 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
]
class ModuleBaySerializer(PrimaryModelSerializer):
class ModuleBaySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
device = NestedDeviceSerializer()
# installed_module = NestedModuleSerializer(required=False, allow_null=True)
@@ -831,7 +831,7 @@ class ModuleBaySerializer(PrimaryModelSerializer):
]
class DeviceBaySerializer(PrimaryModelSerializer):
class DeviceBaySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
@@ -844,7 +844,7 @@ class DeviceBaySerializer(PrimaryModelSerializer):
]
class InventoryItemSerializer(PrimaryModelSerializer):
class InventoryItemSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
@@ -879,7 +879,7 @@ class InventoryItemSerializer(PrimaryModelSerializer):
# Device component roles
#
class InventoryItemRoleSerializer(PrimaryModelSerializer):
class InventoryItemRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -895,7 +895,7 @@ class InventoryItemRoleSerializer(PrimaryModelSerializer):
# Cables
#
class CableSerializer(PrimaryModelSerializer):
class CableSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@@ -1001,7 +1001,7 @@ class CablePathSerializer(serializers.ModelSerializer):
# Virtual chassis
#
class VirtualChassisSerializer(PrimaryModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False)
member_count = serializers.IntegerField(read_only=True)
@@ -1018,7 +1018,7 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
# Power panels
#
class PowerPanelSerializer(PrimaryModelSerializer):
class PowerPanelSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = NestedSiteSerializer()
location = NestedLocationSerializer(
@@ -1036,7 +1036,7 @@ class PowerPanelSerializer(PrimaryModelSerializer):
]
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(

View File

@@ -14,12 +14,12 @@ from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
from dcim import filtersets
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from extras.api.views import ConfigContextQuerySetMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.config import get_config
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
@@ -103,7 +103,7 @@ class PassThroughPortMixin(object):
# Regions
#
class RegionViewSet(CustomFieldModelViewSet):
class RegionViewSet(NetBoxModelViewSet):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@@ -119,7 +119,7 @@ class RegionViewSet(CustomFieldModelViewSet):
# Site groups
#
class SiteGroupViewSet(CustomFieldModelViewSet):
class SiteGroupViewSet(NetBoxModelViewSet):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
@@ -135,7 +135,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
# Sites
#
class SiteViewSet(CustomFieldModelViewSet):
class SiteViewSet(NetBoxModelViewSet):
queryset = Site.objects.prefetch_related(
'region', 'tenant', 'asns', 'tags'
).annotate(
@@ -154,7 +154,7 @@ class SiteViewSet(CustomFieldModelViewSet):
# Locations
#
class LocationViewSet(CustomFieldModelViewSet):
class LocationViewSet(NetBoxModelViewSet):
queryset = Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
@@ -176,7 +176,7 @@ class LocationViewSet(CustomFieldModelViewSet):
# Rack roles
#
class RackRoleViewSet(CustomFieldModelViewSet):
class RackRoleViewSet(NetBoxModelViewSet):
queryset = RackRole.objects.prefetch_related('tags').annotate(
rack_count=count_related(Rack, 'role')
)
@@ -188,7 +188,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
# Racks
#
class RackViewSet(CustomFieldModelViewSet):
class RackViewSet(NetBoxModelViewSet):
queryset = Rack.objects.prefetch_related(
'site', 'location', 'role', 'tenant', 'tags'
).annotate(
@@ -250,7 +250,7 @@ class RackViewSet(CustomFieldModelViewSet):
# Rack reservations
#
class RackReservationViewSet(ModelViewSet):
class RackReservationViewSet(NetBoxModelViewSet):
queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
serializer_class = serializers.RackReservationSerializer
filterset_class = filtersets.RackReservationFilterSet
@@ -260,7 +260,7 @@ class RackReservationViewSet(ModelViewSet):
# Manufacturers
#
class ManufacturerViewSet(CustomFieldModelViewSet):
class ManufacturerViewSet(NetBoxModelViewSet):
queryset = Manufacturer.objects.prefetch_related('tags').annotate(
devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
@@ -274,7 +274,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet):
# Device/module types
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
class DeviceTypeViewSet(NetBoxModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=count_related(Device, 'device_type')
)
@@ -283,7 +283,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
brief_prefetch_fields = ['manufacturer']
class ModuleTypeViewSet(CustomFieldModelViewSet):
class ModuleTypeViewSet(NetBoxModelViewSet):
queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate(
# module_count=count_related(Module, 'module_type')
)
@@ -296,61 +296,61 @@ class ModuleTypeViewSet(CustomFieldModelViewSet):
# Device type components
#
class ConsolePortTemplateViewSet(ModelViewSet):
class ConsolePortTemplateViewSet(NetBoxModelViewSet):
queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ConsolePortTemplateSerializer
filterset_class = filtersets.ConsolePortTemplateFilterSet
class ConsoleServerPortTemplateViewSet(ModelViewSet):
class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet):
queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ConsoleServerPortTemplateSerializer
filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
class PowerPortTemplateViewSet(ModelViewSet):
class PowerPortTemplateViewSet(NetBoxModelViewSet):
queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.PowerPortTemplateSerializer
filterset_class = filtersets.PowerPortTemplateFilterSet
class PowerOutletTemplateViewSet(ModelViewSet):
class PowerOutletTemplateViewSet(NetBoxModelViewSet):
queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.PowerOutletTemplateSerializer
filterset_class = filtersets.PowerOutletTemplateFilterSet
class InterfaceTemplateViewSet(ModelViewSet):
class InterfaceTemplateViewSet(NetBoxModelViewSet):
queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.InterfaceTemplateSerializer
filterset_class = filtersets.InterfaceTemplateFilterSet
class FrontPortTemplateViewSet(ModelViewSet):
class FrontPortTemplateViewSet(NetBoxModelViewSet):
queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.FrontPortTemplateSerializer
filterset_class = filtersets.FrontPortTemplateFilterSet
class RearPortTemplateViewSet(ModelViewSet):
class RearPortTemplateViewSet(NetBoxModelViewSet):
queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.RearPortTemplateSerializer
filterset_class = filtersets.RearPortTemplateFilterSet
class ModuleBayTemplateViewSet(ModelViewSet):
class ModuleBayTemplateViewSet(NetBoxModelViewSet):
queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ModuleBayTemplateSerializer
filterset_class = filtersets.ModuleBayTemplateFilterSet
class DeviceBayTemplateViewSet(ModelViewSet):
class DeviceBayTemplateViewSet(NetBoxModelViewSet):
queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.DeviceBayTemplateSerializer
filterset_class = filtersets.DeviceBayTemplateFilterSet
class InventoryItemTemplateViewSet(ModelViewSet):
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
serializer_class = serializers.InventoryItemTemplateSerializer
filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -360,7 +360,7 @@ class InventoryItemTemplateViewSet(ModelViewSet):
# Device roles
#
class DeviceRoleViewSet(CustomFieldModelViewSet):
class DeviceRoleViewSet(NetBoxModelViewSet):
queryset = DeviceRole.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'device_role'),
virtualmachine_count=count_related(VirtualMachine, 'role')
@@ -373,7 +373,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
# Platforms
#
class PlatformViewSet(CustomFieldModelViewSet):
class PlatformViewSet(NetBoxModelViewSet):
queryset = Platform.objects.prefetch_related('tags').annotate(
device_count=count_related(Device, 'platform'),
virtualmachine_count=count_related(VirtualMachine, 'platform')
@@ -386,7 +386,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
# Devices/modules
#
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
@@ -532,7 +532,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
return Response(response)
class ModuleViewSet(CustomFieldModelViewSet):
class ModuleViewSet(NetBoxModelViewSet):
queryset = Module.objects.prefetch_related(
'device', 'module_bay', 'module_type__manufacturer', 'tags',
)
@@ -544,7 +544,7 @@ class ModuleViewSet(CustomFieldModelViewSet):
# Device components
#
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsolePort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
@@ -553,7 +553,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
@@ -562,7 +562,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
@@ -571,7 +571,7 @@ class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerOutlet.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
@@ -580,7 +580,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
@@ -590,7 +590,7 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
)
@@ -599,7 +599,7 @@ class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = RearPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
)
@@ -608,21 +608,21 @@ class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
brief_prefetch_fields = ['device']
class ModuleBayViewSet(ModelViewSet):
class ModuleBayViewSet(NetBoxModelViewSet):
queryset = ModuleBay.objects.prefetch_related('tags')
serializer_class = serializers.ModuleBaySerializer
filterset_class = filtersets.ModuleBayFilterSet
brief_prefetch_fields = ['device']
class DeviceBayViewSet(ModelViewSet):
class DeviceBayViewSet(NetBoxModelViewSet):
queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags')
serializer_class = serializers.DeviceBaySerializer
filterset_class = filtersets.DeviceBayFilterSet
brief_prefetch_fields = ['device']
class InventoryItemViewSet(ModelViewSet):
class InventoryItemViewSet(NetBoxModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
serializer_class = serializers.InventoryItemSerializer
filterset_class = filtersets.InventoryItemFilterSet
@@ -633,7 +633,7 @@ class InventoryItemViewSet(ModelViewSet):
# Device component roles
#
class InventoryItemRoleViewSet(CustomFieldModelViewSet):
class InventoryItemRoleViewSet(NetBoxModelViewSet):
queryset = InventoryItemRole.objects.prefetch_related('tags').annotate(
inventoryitem_count=count_related(InventoryItem, 'role')
)
@@ -645,7 +645,7 @@ class InventoryItemRoleViewSet(CustomFieldModelViewSet):
# Cables
#
class CableViewSet(ModelViewSet):
class CableViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
@@ -658,7 +658,7 @@ class CableViewSet(ModelViewSet):
# Virtual chassis
#
class VirtualChassisViewSet(ModelViewSet):
class VirtualChassisViewSet(NetBoxModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=count_related(Device, 'virtual_chassis')
)
@@ -671,7 +671,7 @@ class VirtualChassisViewSet(ModelViewSet):
# Power panels
#
class PowerPanelViewSet(ModelViewSet):
class PowerPanelViewSet(NetBoxModelViewSet):
queryset = PowerPanel.objects.prefetch_related(
'site', 'location'
).annotate(
@@ -685,7 +685,7 @@ class PowerPanelViewSet(ModelViewSet):
# Power feeds
#
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
)

View File

@@ -6,7 +6,7 @@ from utilities.choices import ChoiceSet
#
class SiteStatusChoices(ChoiceSet):
key = 'dcim.Site.status'
key = 'Site.status'
STATUS_PLANNED = 'planned'
STATUS_STAGING = 'staging'
@@ -60,7 +60,7 @@ class RackWidthChoices(ChoiceSet):
class RackStatusChoices(ChoiceSet):
key = 'dcim.Rack.status'
key = 'Rack.status'
STATUS_RESERVED = 'reserved'
STATUS_AVAILABLE = 'available'
@@ -130,7 +130,7 @@ class DeviceFaceChoices(ChoiceSet):
class DeviceStatusChoices(ChoiceSet):
key = 'dcim.Device.status'
key = 'Device.status'
STATUS_OFFLINE = 'offline'
STATUS_ACTIVE = 'active'
@@ -1003,13 +1003,19 @@ class PortTypeChoices(ChoiceSet):
TYPE_MRJ21 = 'mrj21'
TYPE_ST = 'st'
TYPE_SC = 'sc'
TYPE_SC_PC = 'sc-pc'
TYPE_SC_UPC = 'sc-upc'
TYPE_SC_APC = 'sc-apc'
TYPE_FC = 'fc'
TYPE_LC = 'lc'
TYPE_LC_PC = 'lc-pc'
TYPE_LC_UPC = 'lc-upc'
TYPE_LC_APC = 'lc-apc'
TYPE_MTRJ = 'mtrj'
TYPE_MPO = 'mpo'
TYPE_LSH = 'lsh'
TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc'
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
@@ -1049,12 +1055,18 @@ class PortTypeChoices(ChoiceSet):
(
(TYPE_FC, 'FC'),
(TYPE_LC, 'LC'),
(TYPE_LC_PC, 'LC/PC'),
(TYPE_LC_UPC, 'LC/UPC'),
(TYPE_LC_APC, 'LC/APC'),
(TYPE_LSH, 'LSH'),
(TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'),
(TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'),
(TYPE_SC_PC, 'SC/PC'),
(TYPE_SC_UPC, 'SC/UPC'),
(TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
@@ -1175,7 +1187,7 @@ class CableLengthUnitChoices(ChoiceSet):
#
class PowerFeedStatusChoices(ChoiceSet):
key = 'dcim.PowerFeed.status'
key = 'PowerFeed.status'
STATUS_OFFLINE = 'offline'
STATUS_ACTIVE = 'active'

View File

@@ -149,7 +149,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = Site
fields = (
'id', 'name', 'slug', 'facility', 'latitude', 'longitude',
'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description'
)
def search(self, queryset, name, value):
@@ -239,7 +239,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
class Meta:
model = RackRole
fields = ['id', 'name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color', 'description']
class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -385,7 +385,7 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = RackReservation
fields = ['id', 'created']
fields = ['id', 'created', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -724,7 +724,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color', 'vm_role']
fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description']
class PlatformFilterSet(OrganizationalModelFilterSet):
@@ -972,6 +972,17 @@ class ModuleFilterSet(NetBoxModelFilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
module_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_type',
queryset=ModuleType.objects.all(),
label='Module type (ID)',
)
module_type = django_filters.ModelMultipleChoiceFilter(
field_name='module_type__model',
queryset=ModuleType.objects.all(),
to_field_name='model',
label='Module type (model)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
@@ -1076,6 +1087,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
)
class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
"""
Extends DeviceComponentFilterSet to add a module_id filter for components
which can be associated with a particular module within a device.
"""
module_id = django_filters.ModelMultipleChoiceFilter(
queryset=Module.objects.all(),
label='Module (ID)',
)
class CableTerminationFilterSet(django_filters.FilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
@@ -1096,7 +1118,12 @@ class PathEndpointFilterSet(django_filters.FilterSet):
return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class ConsolePortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
@@ -1107,7 +1134,12 @@ class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, Cable
fields = ['id', 'name', 'label', 'description']
class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class ConsoleServerPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
@@ -1118,7 +1150,12 @@ class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet,
fields = ['id', 'name', 'label', 'description']
class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PowerPortTypeChoices,
null_value=None
@@ -1129,7 +1166,12 @@ class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTe
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerOutletFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PowerOutletTypeChoices,
null_value=None
@@ -1144,7 +1186,12 @@ class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, Cable
fields = ['id', 'name', 'label', 'feed_leg', 'description']
class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class InterfaceFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1271,7 +1318,11 @@ class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTe
}.get(value, queryset.none())
class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class FrontPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
@@ -1282,7 +1333,11 @@ class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTe
fields = ['id', 'name', 'label', 'type', 'color', 'description']
class RearPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class RearPortFilterSet(
NetBoxModelFilterSet,
ModularDeviceComponentFilterSet,
CableTerminationFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None

View File

@@ -647,6 +647,19 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
'rf_channel_width', 'tx_power',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit interface choices for parent, bridge and lag to device only
params = {}
if data.get('device'):
params[f"device__{self.fields['device'].to_field_name}"] = data.get('device')
if params:
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:

View File

@@ -10,9 +10,11 @@ from utilities.forms import (
__all__ = (
'ComponentTemplateCreateForm',
'DeviceComponentCreateForm',
'DeviceTypeComponentCreateForm',
'FrontPortCreateForm',
'FrontPortTemplateCreateForm',
'ModularComponentTemplateCreateForm',
'ModuleBayCreateForm',
'ModuleBayTemplateCreateForm',
'VirtualChassisCreateForm',
)
@@ -34,25 +36,34 @@ class ComponentCreateForm(BootstrapMixin, forms.Form):
def clean(self):
super().clean()
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
if self.cleaned_data['label_pattern']:
name_pattern_count = len(self.cleaned_data['name_pattern'])
label_pattern_count = len(self.cleaned_data['label_pattern'])
if name_pattern_count != label_pattern_count:
# Validate that all patterned fields generate an equal number of values
patterned_fields = [
field_name for field_name in self.fields if field_name.endswith('_pattern')
]
pattern_count = len(self.cleaned_data['name_pattern'])
for field_name in patterned_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({
'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however '
f'{label_pattern_count} labels will be generated. These counts must match.'
field_name: f'The provided pattern specifies {value_count} values, but {pattern_count} are '
f'expected.'
}, code='label_pattern_mismatch')
class DeviceTypeComponentCreateForm(ComponentCreateForm):
class ComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned only to a DeviceType.
"""
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
)
field_order = ('device_type', 'name_pattern', 'label_pattern')
class ComponentTemplateCreateForm(ComponentCreateForm):
class ModularComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
"""
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False
@@ -71,7 +82,7 @@ class DeviceComponentCreateForm(ComponentCreateForm):
field_order = ('device', 'name_pattern', 'label_pattern')
class FrontPortTemplateCreateForm(DeviceTypeComponentCreateForm):
class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
rear_port_set = forms.MultipleChoiceField(
choices=[],
label='Rear ports',
@@ -84,19 +95,27 @@ class FrontPortTemplateCreateForm(DeviceTypeComponentCreateForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
device_type = DeviceType.objects.get(
pk=self.initial.get('device_type') or self.data.get('device_type')
)
# TODO: This needs better validation
if 'device_type' in self.initial or self.data.get('device_type'):
parent = DeviceType.objects.get(
pk=self.initial.get('device_type') or self.data.get('device_type')
)
elif 'module_type' in self.initial or self.data.get('module_type'):
parent = ModuleType.objects.get(
pk=self.initial.get('module_type') or self.data.get('module_type')
)
else:
return
# Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
occupied_port_positions = [
(front_port.rear_port_id, front_port.rear_port_position)
for front_port in device_type.frontporttemplates.all()
for front_port in parent.frontporttemplates.all()
]
# Populate rear port choices
choices = []
rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
rear_ports = parent.rearporttemplates.all()
for rear_port in rear_ports:
for i in range(1, rear_port.positions + 1):
if (rear_port.pk, i) not in occupied_port_positions:
@@ -162,6 +181,24 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
}
class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
position_pattern = ExpandableNameField(
label='Position',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
)
field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern')
class ModuleBayCreateForm(DeviceComponentCreateForm):
position_pattern = ExpandableNameField(
label='Position',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
)
field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),

View File

@@ -14,6 +14,31 @@ class Migration(migrations.Migration):
]
operations = [
# Rename any indexes left over from the old Module model (now InventoryItem) (#8656)
migrations.RunSQL(
"""
DO $$
DECLARE
idx record;
BEGIN
FOR idx IN
SELECT indexname AS old_name,
replace(indexname, 'module', 'inventoryitem') AS new_name
FROM pg_indexes
WHERE schemaname = 'public' AND
tablename = 'dcim_inventoryitem' AND
indexname LIKE 'dcim_module_%'
LOOP
EXECUTE format(
'ALTER INDEX %I RENAME TO %I;',
idx.old_name,
idx.new_name
);
END LOOP;
END$$;
"""
),
migrations.AlterModelOptions(
name='consoleporttemplate',
options={'ordering': ('device_type', 'module_type', '_name')},

View File

@@ -804,10 +804,11 @@ class Device(NetBoxModel, ConfigContextModel):
})
# Prevent 0U devices from being assigned to a specific position
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
})
if hasattr(self, 'device_type'):
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
})
if self.rack:

View File

@@ -45,7 +45,7 @@ def get_cabletermination_row_class(record):
if record.mark_connected:
return 'success'
elif record.cable:
return record.cable.get_status_class()
return record.cable.get_status_color()
return ''
@@ -744,8 +744,8 @@ class DeviceModuleBayTable(ModuleBayTable):
class Meta(DeviceComponentTable.Meta):
model = ModuleBay
fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions')
default_columns = ('pk', 'name', 'label', 'description', 'installed_module')
fields = ('pk', 'id', 'name', 'label', 'position', 'installed_module', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
class InventoryItemTable(DeviceComponentTable):

View File

@@ -1,5 +1,4 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,

View File

@@ -82,6 +82,10 @@ class SiteTable(NetBoxTable):
accessor=tables.A('asns__count'),
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name='ASN Count'
)
asns = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
tenant = TenantColumn()
@@ -93,9 +97,9 @@ class SiteTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Site
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
'created', 'last_updated', 'actions',
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')

View File

@@ -112,6 +112,11 @@ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
#
CONSOLEPORT_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
@@ -139,6 +144,11 @@ CONSOLEPORT_BUTTONS = """
"""
CONSOLESERVERPORT_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-sm btn-success" title="Add inventory item">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
@@ -166,6 +176,11 @@ CONSOLESERVERPORT_BUTTONS = """
"""
POWERPORT_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
@@ -192,6 +207,11 @@ POWERPORT_BUTTONS = """
"""
POWEROUTLET_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
@@ -214,10 +234,20 @@ POWEROUTLET_BUTTONS = """
"""
INTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-sm btn-success" title="Add IP address">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% if perms.ipam.add_ipaddress or perms.dcim.add_inventoryitem %}
<span class="dropdown">
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Add">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.ipam.add_ipaddress %}
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">IP Address</a></li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
{% endif %}
</ul>
</span>
{% endif %}
{% if record.link %}
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
@@ -240,7 +270,7 @@ INTERFACE_BUTTONS = """
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@@ -261,6 +291,11 @@ INTERFACE_BUTTONS = """
"""
FRONTPORT_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
@@ -293,6 +328,11 @@ FRONTPORT_BUTTONS = """
"""
REARPORT_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}

View File

@@ -151,8 +151,8 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns)
sites = (
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', latitude=10, longitude=10),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', latitude=20, longitude=20),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', latitude=10, longitude=10, description='foobar1'),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', latitude=20, longitude=20, description='foobar1'),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', latitude=30, longitude=30),
)
Site.objects.bulk_create(sites)
@@ -189,6 +189,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'longitude': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -317,8 +321,8 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000', description='foobar1'),
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00', description='foobar2'),
RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
)
RackRole.objects.bulk_create(rack_roles)
@@ -335,6 +339,10 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Rack.objects.all()
@@ -558,8 +566,8 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]),
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1]),
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
)
RackReservation.objects.bulk_create(reservations)
@@ -592,6 +600,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
@@ -1302,8 +1314,8 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True),
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True),
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
)
DeviceRole.objects.bulk_create(device_roles)
@@ -1326,6 +1338,10 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'vm_role': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Platform.objects.all()
@@ -1787,6 +1803,13 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_module_type(self):
module_types = ModuleType.objects.all()[:2]
params = {'module_type_id': [module_types[0].pk, module_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'module_type': [module_types[0].model, module_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_device(self):
device_types = Device.objects.all()[:2]
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
@@ -1831,7 +1854,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -1850,6 +1874,20 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
console_server_ports = (
ConsoleServerPort(device=devices[3], name='Console Server Port 1'),
ConsoleServerPort(device=devices[3], name='Console Server Port 2'),
@@ -1857,9 +1895,9 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsoleServerPort.objects.bulk_create(console_server_ports)
console_ports = (
ConsolePort(device=devices[0], name='Console Port 1', label='A', description='First'),
ConsolePort(device=devices[1], name='Console Port 2', label='B', description='Second'),
ConsolePort(device=devices[2], name='Console Port 3', label='C', description='Third'),
ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
)
ConsolePort.objects.bulk_create(console_ports)
@@ -1914,6 +1952,11 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
@@ -1958,7 +2001,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -1977,6 +2021,20 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
console_ports = (
ConsolePort(device=devices[3], name='Console Server Port 1'),
ConsolePort(device=devices[3], name='Console Server Port 2'),
@@ -1984,9 +2042,9 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsolePort.objects.bulk_create(console_ports)
console_server_ports = (
ConsoleServerPort(device=devices[0], name='Console Server Port 1', label='A', description='First'),
ConsoleServerPort(device=devices[1], name='Console Server Port 2', label='B', description='Second'),
ConsoleServerPort(device=devices[2], name='Console Server Port 3', label='C', description='Third'),
ConsoleServerPort(device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'),
ConsoleServerPort(device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'),
ConsoleServerPort(device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'),
)
ConsoleServerPort.objects.bulk_create(console_server_ports)
@@ -2048,6 +2106,11 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2085,7 +2148,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -2104,6 +2168,20 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
power_outlets = (
PowerOutlet(device=devices[3], name='Power Outlet 1'),
PowerOutlet(device=devices[3], name='Power Outlet 2'),
@@ -2111,9 +2189,9 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerOutlet.objects.bulk_create(power_outlets)
power_ports = (
PowerPort(device=devices[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'),
PowerPort(device=devices[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'),
PowerPort(device=devices[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'),
PowerPort(device=devices[0], module=modules[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'),
PowerPort(device=devices[1], module=modules[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'),
PowerPort(device=devices[2], module=modules[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'),
)
PowerPort.objects.bulk_create(power_ports)
@@ -2183,6 +2261,11 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2220,7 +2303,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -2239,6 +2323,20 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
power_ports = (
PowerPort(device=devices[3], name='Power Outlet 1'),
PowerPort(device=devices[3], name='Power Outlet 2'),
@@ -2246,9 +2344,9 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort.objects.bulk_create(power_ports)
power_outlets = (
PowerOutlet(device=devices[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'),
PowerOutlet(device=devices[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'),
PowerOutlet(device=devices[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'),
PowerOutlet(device=devices[0], module=modules[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'),
PowerOutlet(device=devices[1], module=modules[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'),
PowerOutlet(device=devices[2], module=modules[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'),
)
PowerOutlet.objects.bulk_create(power_outlets)
@@ -2314,6 +2412,11 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2351,7 +2454,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -2370,6 +2474,20 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
vrfs = (
VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
@@ -2383,9 +2501,9 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
@@ -2525,6 +2643,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@@ -2603,7 +2726,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -2622,6 +2746,20 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
rear_ports = (
RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=6),
RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C, positions=6),
@@ -2633,9 +2771,9 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPort.objects.bulk_create(rear_ports)
front_ports = (
FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, rear_port=rear_ports[0], rear_port_position=1, description='First'),
FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
FrontPort(device=devices[0], module=modules[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, rear_port=rear_ports[0], rear_port_position=1, description='First'),
FrontPort(device=devices[1], module=modules[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
FrontPort(device=devices[2], module=modules[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1),
FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1),
FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1),
@@ -2702,6 +2840,11 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@@ -2739,7 +2882,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
locations = (
@@ -2758,10 +2902,24 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
)
Module.objects.bulk_create(modules)
rear_ports = (
RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, positions=1, description='First'),
RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, positions=2, description='Second'),
RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, positions=3, description='Third'),
RearPort(device=devices[0], module=modules[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, positions=1, description='First'),
RearPort(device=devices[1], module=modules[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, positions=2, description='Second'),
RearPort(device=devices[2], module=modules[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, positions=3, description='Third'),
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
@@ -2832,6 +2990,11 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@@ -50,23 +50,26 @@ class DeviceTypeComponentsView(DeviceComponentsView):
return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
def get_extra_context(self, request, instance):
if self.viewname:
return_url = reverse(self.viewname, kwargs={'pk': instance.pk})
else:
return_url = instance.get_absolute_url()
return {
'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
'return_url': return_url,
}
context = super().get_extra_context(request, instance)
context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk})
return context
class ModuleTypeComponentsView(DeviceComponentsView):
queryset = ModuleType.objects.all()
template_name = 'dcim/moduletype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent)
def get_extra_context(self, request, instance):
context = super().get_extra_context(request, instance)
context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk})
return context
class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
@@ -732,21 +735,25 @@ class ManufacturerView(generic.ObjectView):
queryset = Manufacturer.objects.all()
def get_extra_context(self, request, instance):
devicetypes = DeviceType.objects.restrict(request.user, 'view').filter(
device_types = DeviceType.objects.restrict(request.user, 'view').filter(
manufacturer=instance
).annotate(
instance_count=count_related(Device, 'device_type')
)
module_types = ModuleType.objects.restrict(request.user, 'view').filter(
manufacturer=instance
)
inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
manufacturer=instance
)
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',))
devicetypes_table.configure(request)
return {
'devicetypes_table': devicetypes_table,
'inventory_item_count': inventory_items.count(),
'module_type_count': module_types.count(),
}
@@ -858,6 +865,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
child_model = ModuleBayTemplate
table = tables.ModuleBayTemplateTable
filterset = filtersets.ModuleBayTemplateFilterSet
viewname = 'dcim:devicetype_modulebays'
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
@@ -962,42 +970,49 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
child_model = ConsolePortTemplate
table = tables.ConsolePortTemplateTable
filterset = filtersets.ConsolePortTemplateFilterSet
viewname = 'dcim:moduletype_consoleports'
class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
child_model = ConsoleServerPortTemplate
table = tables.ConsoleServerPortTemplateTable
filterset = filtersets.ConsoleServerPortTemplateFilterSet
viewname = 'dcim:moduletype_consoleserverports'
class ModuleTypePowerPortsView(ModuleTypeComponentsView):
child_model = PowerPortTemplate
table = tables.PowerPortTemplateTable
filterset = filtersets.PowerPortTemplateFilterSet
viewname = 'dcim:moduletype_powerports'
class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
child_model = PowerOutletTemplate
table = tables.PowerOutletTemplateTable
filterset = filtersets.PowerOutletTemplateFilterSet
viewname = 'dcim:moduletype_poweroutlets'
class ModuleTypeInterfacesView(ModuleTypeComponentsView):
child_model = InterfaceTemplate
table = tables.InterfaceTemplateTable
filterset = filtersets.InterfaceTemplateFilterSet
viewname = 'dcim:moduletype_interfaces'
class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
child_model = FrontPortTemplate
table = tables.FrontPortTemplateTable
filterset = filtersets.FrontPortTemplateFilterSet
viewname = 'dcim:moduletype_frontports'
class ModuleTypeRearPortsView(ModuleTypeComponentsView):
child_model = RearPortTemplate
table = tables.RearPortTemplateTable
filterset = filtersets.RearPortTemplateFilterSet
viewname = 'dcim:moduletype_rearports'
class ModuleTypeEditView(generic.ObjectEditView):
@@ -1060,8 +1075,9 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsolePortTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
template_name = 'dcim/component_template_create.html'
class ConsolePortTemplateEditView(generic.ObjectEditView):
@@ -1094,8 +1110,9 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPortTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
template_name = 'dcim/component_template_create.html'
class ConsoleServerPortTemplateEditView(generic.ObjectEditView):
@@ -1128,8 +1145,9 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerPortTemplateCreateView(generic.ComponentCreateView):
queryset = PowerPortTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.PowerPortTemplateForm
template_name = 'dcim/component_template_create.html'
class PowerPortTemplateEditView(generic.ObjectEditView):
@@ -1162,8 +1180,9 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
queryset = PowerOutletTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
template_name = 'dcim/component_template_create.html'
class PowerOutletTemplateEditView(generic.ObjectEditView):
@@ -1196,8 +1215,9 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
class InterfaceTemplateCreateView(generic.ComponentCreateView):
queryset = InterfaceTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.InterfaceTemplateForm
template_name = 'dcim/component_template_create.html'
class InterfaceTemplateEditView(generic.ObjectEditView):
@@ -1232,6 +1252,7 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
queryset = FrontPortTemplate.objects.all()
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
template_name = 'dcim/frontporttemplate_create.html'
def initialize_forms(self, request):
form, model_form = super().initialize_forms(request)
@@ -1272,8 +1293,9 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
class RearPortTemplateCreateView(generic.ComponentCreateView):
queryset = RearPortTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.RearPortTemplateForm
template_name = 'dcim/component_template_create.html'
class RearPortTemplateEditView(generic.ObjectEditView):
@@ -1306,8 +1328,10 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
class ModuleBayTemplateCreateView(generic.ComponentCreateView):
queryset = ModuleBayTemplate.objects.all()
form = forms.DeviceTypeComponentCreateForm
form = forms.ModuleBayTemplateCreateForm
model_form = forms.ModuleBayTemplateForm
template_name = 'dcim/modulebaytemplate_create.html'
patterned_fields = ('name', 'label', 'position')
class ModuleBayTemplateEditView(generic.ObjectEditView):
@@ -1340,8 +1364,9 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceBayTemplateCreateView(generic.ComponentCreateView):
queryset = DeviceBayTemplate.objects.all()
form = forms.DeviceTypeComponentCreateForm
form = forms.ComponentTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
template_name = 'dcim/component_template_create.html'
class DeviceBayTemplateEditView(generic.ObjectEditView):
@@ -1374,9 +1399,9 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.ModularComponentTemplateCreateForm
model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitem_create.html'
template_name = 'dcim/inventoryitemtemplate_create.html'
def alter_object(self, instance, request):
# Set component (if any)
@@ -1791,7 +1816,7 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class ConsolePortView(generic.ObjectView):
@@ -1850,7 +1875,7 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class ConsoleServerPortView(generic.ObjectView):
@@ -1909,7 +1934,7 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class PowerPortView(generic.ObjectView):
@@ -1968,7 +1993,7 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class PowerOutletView(generic.ObjectView):
@@ -2027,7 +2052,7 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class InterfaceView(generic.ObjectView):
@@ -2149,7 +2174,7 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class FrontPortView(generic.ObjectView):
@@ -2216,7 +2241,7 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class RearPortView(generic.ObjectView):
@@ -2275,7 +2300,7 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class ModuleBayView(generic.ObjectView):
@@ -2284,8 +2309,9 @@ class ModuleBayView(generic.ObjectView):
class ModuleBayCreateView(generic.ComponentCreateView):
queryset = ModuleBay.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.ModuleBayCreateForm
model_form = forms.ModuleBayForm
patterned_fields = ('name', 'label', 'position')
class ModuleBayEditView(generic.ObjectEditView):
@@ -2330,7 +2356,7 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class DeviceBayView(generic.ObjectView):
@@ -2452,7 +2478,7 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class InventoryItemView(generic.ObjectView):
@@ -2685,7 +2711,7 @@ class CableListView(generic.ObjectListView):
filterset = filtersets.CableFilterSet
filterset_form = forms.CableFilterForm
table = tables.CableTable
action_buttons = ('import', 'export')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
class CableView(generic.ObjectView):
@@ -2768,6 +2794,10 @@ class CableCreateView(generic.ObjectEditView):
return super().dispatch(request, *args, **kwargs)
def get_object(self, **kwargs):
# Always return a new instance
return self.queryset.model()
def alter_object(self, obj, request, url_args, url_kwargs):
termination_a_type = url_kwargs.get('termination_a_type')
termination_a_id = url_kwargs.get('termination_a_id')
@@ -2848,7 +2878,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html'
action_buttons = ('export',)
actions = ('export',)
def get_extra_context(self, request):
return {
@@ -2862,7 +2892,7 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html'
action_buttons = ('export',)
actions = ('export',)
def get_extra_context(self, request):
return {
@@ -2876,7 +2906,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html'
action_buttons = ('export',)
actions = ('export',)
def get_extra_context(self, request):
return {

View File

@@ -18,7 +18,7 @@ from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, count_related
from . import serializers
@@ -58,7 +58,7 @@ class ConfigContextQuerySetMixin:
# Webhooks
#
class WebhookViewSet(ModelViewSet):
class WebhookViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Webhook.objects.all()
serializer_class = serializers.WebhookSerializer
@@ -69,36 +69,18 @@ class WebhookViewSet(ModelViewSet):
# Custom fields
#
class CustomFieldViewSet(ModelViewSet):
class CustomFieldViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all()
serializer_class = serializers.CustomFieldSerializer
filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldModelViewSet(ModelViewSet):
"""
Include the applicable set of CustomFields in the ModelViewSet context.
"""
def get_serializer_context(self):
# Gather all custom fields for the model
content_type = ContentType.objects.get_for_model(self.queryset.model)
custom_fields = content_type.custom_fields.all()
context = super().get_serializer_context()
context.update({
'custom_fields': custom_fields,
})
return context
#
# Custom links
#
class CustomLinkViewSet(ModelViewSet):
class CustomLinkViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomLink.objects.all()
serializer_class = serializers.CustomLinkSerializer
@@ -109,7 +91,7 @@ class CustomLinkViewSet(ModelViewSet):
# Export templates
#
class ExportTemplateViewSet(ModelViewSet):
class ExportTemplateViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ExportTemplate.objects.all()
serializer_class = serializers.ExportTemplateSerializer
@@ -120,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
# Tags
#
class TagViewSet(ModelViewSet):
class TagViewSet(NetBoxModelViewSet):
queryset = Tag.objects.annotate(
tagged_items=count_related(TaggedItem, 'tag')
)
@@ -132,7 +114,7 @@ class TagViewSet(ModelViewSet):
# Image attachments
#
class ImageAttachmentViewSet(ModelViewSet):
class ImageAttachmentViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
@@ -143,7 +125,7 @@ class ImageAttachmentViewSet(ModelViewSet):
# Journal entries
#
class JournalEntryViewSet(ModelViewSet):
class JournalEntryViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = JournalEntry.objects.all()
serializer_class = serializers.JournalEntrySerializer
@@ -154,7 +136,7 @@ class JournalEntryViewSet(ModelViewSet):
# Config contexts
#
class ConfigContextViewSet(ModelViewSet):
class ConfigContextViewSet(NetBoxModelViewSet):
queryset = ConfigContext.objects.prefetch_related(
'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
)

View File

@@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet):
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -105,7 +105,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
class Meta:
model = ExportTemplate
fields = ['id', 'content_type', 'name']
fields = ['id', 'content_type', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -179,14 +179,15 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(slug__icontains=value)
Q(slug__icontains=value) |
Q(description__icontains=value)
)
def _content_type(self, queryset, name, values):

View File

@@ -72,9 +72,9 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
help_texts = {
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. '
'Links which render as empty text will not be displayed.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>.',
}

View File

@@ -0,0 +1 @@
from .tables import *

View File

@@ -1,8 +1,9 @@
import django_tables2 as tables
from django.conf import settings
from extras.models import *
from netbox.tables import NetBoxTable, columns
from .models import *
from .template_code import *
__all__ = (
'ConfigContextTable',
@@ -17,32 +18,6 @@ __all__ = (
'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>
{% endif %}
{% if perms.extras.delete_configcontext %}
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
{% endif %}
"""
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ record.user.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """
{% if record.changed_object and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}
"""
OBJECTCHANGE_REQUEST_ID = """
<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
"""
#
# Custom fields
@@ -189,9 +164,8 @@ class ConfigContextTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
'last_updated',
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
@@ -225,7 +199,10 @@ class ObjectChangeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ObjectChange
fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
fields = (
'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
'actions',
)
class ObjectJournalTable(NetBoxTable):
@@ -260,8 +237,8 @@ class JournalEntryTable(ObjectJournalTable):
class Meta(NetBoxTable.Meta):
model = JournalEntry
fields = (
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
'comments', 'actions'
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments',
'actions',
)
default_columns = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'

View File

@@ -0,0 +1,25 @@
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>
{% endif %}
{% if perms.extras.delete_configcontext %}
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
{% endif %}
"""
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ record.user.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """
{% if record.changed_object and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}
"""
OBJECTCHANGE_REQUEST_ID = """
<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
"""

View File

@@ -42,7 +42,8 @@ def custom_links(context, obj):
# Pass select context data when rendering the CustomLink
link_context = {
'obj': obj,
'object': obj,
'obj': obj, # TODO: Remove in NetBox v3.5
'debug': context.get('debug', False), # django.template.context_processors.debug
'request': context['request'], # django.template.context_processors.request
'user': context['user'], # django.contrib.auth.context_processors.auth

View File

@@ -162,8 +162,8 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = (
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING'),
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING'),
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'),
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'),
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'),
)
ExportTemplate.objects.bulk_create(export_templates)
@@ -176,6 +176,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
params = {'content_type': ContentType.objects.get(model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
queryset = ImageAttachment.objects.all()
@@ -565,8 +569,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000'),
Tag(name='Tag 2', slug='tag-2', color='00ff00'),
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
Tag(name='Tag 3', slug='tag-3', color='0000ff'),
)
Tag.objects.bulk_create(tags)
@@ -590,6 +594,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self):
params = {'content_type': ['dcim.site', 'circuits.provider']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -11,7 +11,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.htmx import is_htmx
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables
from .choices import JobResultStatusChoices
@@ -269,7 +269,7 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable
action_buttons = ('add',)
actions = ('add', 'bulk_edit', 'bulk_delete')
class ConfigContextView(generic.ObjectView):
@@ -366,7 +366,7 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
template_name = 'extras/objectchange_list.html'
action_buttons = ('export',)
actions = ('export',)
class ObjectChangeView(generic.ObjectView):
@@ -458,7 +458,7 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable
action_buttons = ('export',)
actions = ('export', 'bulk_edit', 'bulk_delete')
class JournalEntryView(generic.ObjectView):
@@ -478,7 +478,7 @@ class JournalEntryEditView(generic.ObjectEditView):
if not instance.assigned_object:
return reverse('extras:journalentry_list')
obj = instance.assigned_object
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
viewname = get_viewname(obj, 'journal')
return reverse(viewname, kwargs={'pk': obj.pk})
@@ -487,7 +487,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
def get_return_url(self, request, instance):
obj = instance.assigned_object
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
viewname = get_viewname(obj, 'journal')
return reverse(viewname, kwargs={'pk': obj.pk})

View File

@@ -9,7 +9,7 @@ from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import *
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -20,7 +20,7 @@ from .nested_serializers import *
# ASNs
#
class ASNSerializer(PrimaryModelSerializer):
class ASNSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True)
@@ -37,7 +37,7 @@ class ASNSerializer(PrimaryModelSerializer):
# VRFs
#
class VRFSerializer(PrimaryModelSerializer):
class VRFSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
import_targets = SerializedPKRelatedField(
@@ -67,7 +67,7 @@ class VRFSerializer(PrimaryModelSerializer):
# Route targets
#
class RouteTargetSerializer(PrimaryModelSerializer):
class RouteTargetSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -82,7 +82,7 @@ class RouteTargetSerializer(PrimaryModelSerializer):
# RIRs/aggregates
#
class RIRSerializer(PrimaryModelSerializer):
class RIRSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True)
@@ -94,7 +94,7 @@ class RIRSerializer(PrimaryModelSerializer):
]
class AggregateSerializer(PrimaryModelSerializer):
class AggregateSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
rir = NestedRIRSerializer()
@@ -113,7 +113,7 @@ class AggregateSerializer(PrimaryModelSerializer):
# FHRP Groups
#
class FHRPGroupSerializer(PrimaryModelSerializer):
class FHRPGroupSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail')
ip_addresses = NestedIPAddressSerializer(many=True, read_only=True)
@@ -125,8 +125,8 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
]
class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
group = NestedFHRPGroupSerializer()
interface_type = ContentTypeField(
queryset=ContentType.objects.all()
@@ -153,7 +153,7 @@ class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
# VLANs
#
class RoleSerializer(PrimaryModelSerializer):
class RoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
@@ -166,7 +166,7 @@ class RoleSerializer(PrimaryModelSerializer):
]
class VLANGroupSerializer(PrimaryModelSerializer):
class VLANGroupSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
scope_type = ContentTypeField(
queryset=ContentType.objects.filter(
@@ -196,7 +196,7 @@ class VLANGroupSerializer(PrimaryModelSerializer):
return serializer(obj.scope, context=context).data
class VLANSerializer(PrimaryModelSerializer):
class VLANSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True, default=None)
@@ -230,7 +230,7 @@ class AvailableVLANSerializer(serializers.Serializer):
])
class CreateAvailableVLANSerializer(PrimaryModelSerializer):
class CreateAvailableVLANSerializer(NetBoxModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False)
@@ -251,7 +251,7 @@ class CreateAvailableVLANSerializer(PrimaryModelSerializer):
# Prefixes
#
class PrefixSerializer(PrimaryModelSerializer):
class PrefixSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True)
@@ -323,7 +323,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
# IP ranges
#
class IPRangeSerializer(PrimaryModelSerializer):
class IPRangeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -345,7 +345,7 @@ class IPRangeSerializer(PrimaryModelSerializer):
# IP addresses
#
class IPAddressSerializer(PrimaryModelSerializer):
class IPAddressSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -403,7 +403,7 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceTemplateSerializer(PrimaryModelSerializer):
class ServiceTemplateSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@@ -415,7 +415,7 @@ class ServiceTemplateSerializer(PrimaryModelSerializer):
]
class ServiceSerializer(PrimaryModelSerializer):
class ServiceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)

View File

@@ -1,19 +1,18 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django_pglocks import advisory_lock
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.views import APIView
from dcim.models import Site
from extras.api.views import CustomFieldModelViewSet
from ipam import filtersets
from ipam.models import *
from netbox.api.views import ModelViewSet, ObjectValidationMixin
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import count_related
@@ -32,13 +31,13 @@ class IPAMRootView(APIRootView):
# Viewsets
#
class ASNViewSet(CustomFieldModelViewSet):
class ASNViewSet(NetBoxModelViewSet):
queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns'))
serializer_class = serializers.ASNSerializer
filterset_class = filtersets.ASNFilterSet
class VRFViewSet(CustomFieldModelViewSet):
class VRFViewSet(NetBoxModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags'
).annotate(
@@ -49,13 +48,13 @@ class VRFViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VRFFilterSet
class RouteTargetViewSet(CustomFieldModelViewSet):
class RouteTargetViewSet(NetBoxModelViewSet):
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
serializer_class = serializers.RouteTargetSerializer
filterset_class = filtersets.RouteTargetFilterSet
class RIRViewSet(CustomFieldModelViewSet):
class RIRViewSet(NetBoxModelViewSet):
queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir')
).prefetch_related('tags')
@@ -63,13 +62,13 @@ class RIRViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RIRFilterSet
class AggregateViewSet(CustomFieldModelViewSet):
class AggregateViewSet(NetBoxModelViewSet):
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
serializer_class = serializers.AggregateSerializer
filterset_class = filtersets.AggregateFilterSet
class RoleViewSet(CustomFieldModelViewSet):
class RoleViewSet(NetBoxModelViewSet):
queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role')
@@ -78,7 +77,7 @@ class RoleViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RoleFilterSet
class PrefixViewSet(CustomFieldModelViewSet):
class PrefixViewSet(NetBoxModelViewSet):
queryset = Prefix.objects.prefetch_related(
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
)
@@ -93,7 +92,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
return super().get_serializer_class()
class IPRangeViewSet(CustomFieldModelViewSet):
class IPRangeViewSet(NetBoxModelViewSet):
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
serializer_class = serializers.IPRangeSerializer
filterset_class = filtersets.IPRangeFilterSet
@@ -101,7 +100,7 @@ class IPRangeViewSet(CustomFieldModelViewSet):
parent_model = IPRange # AvailableIPsMixin
class IPAddressViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(NetBoxModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
)
@@ -109,20 +108,20 @@ class IPAddressViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.IPAddressFilterSet
class FHRPGroupViewSet(CustomFieldModelViewSet):
class FHRPGroupViewSet(NetBoxModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
serializer_class = serializers.FHRPGroupSerializer
filterset_class = filtersets.FHRPGroupFilterSet
brief_prefetch_fields = ('ip_addresses',)
class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface')
serializer_class = serializers.FHRPGroupAssignmentSerializer
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
class VLANGroupViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(NetBoxModelViewSet):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
).prefetch_related('tags')
@@ -130,7 +129,7 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANGroupFilterSet
class VLANViewSet(CustomFieldModelViewSet):
class VLANViewSet(NetBoxModelViewSet):
queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags'
).annotate(
@@ -140,13 +139,13 @@ class VLANViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANFilterSet
class ServiceTemplateViewSet(CustomFieldModelViewSet):
class ServiceTemplateViewSet(NetBoxModelViewSet):
queryset = ServiceTemplate.objects.prefetch_related('tags')
serializer_class = serializers.ServiceTemplateSerializer
filterset_class = filtersets.ServiceTemplateFilterSet
class ServiceViewSet(CustomFieldModelViewSet):
class ServiceViewSet(NetBoxModelViewSet):
queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses'
)

View File

@@ -17,7 +17,7 @@ class IPAddressFamilyChoices(ChoiceSet):
#
class PrefixStatusChoices(ChoiceSet):
key = 'ipam.Prefix.status'
key = 'Prefix.status'
STATUS_CONTAINER = 'container'
STATUS_ACTIVE = 'active'
@@ -37,7 +37,7 @@ class PrefixStatusChoices(ChoiceSet):
#
class IPRangeStatusChoices(ChoiceSet):
key = 'ipam.IPRange.status'
key = 'IPRange.status'
STATUS_ACTIVE = 'active'
STATUS_RESERVED = 'reserved'
@@ -55,7 +55,7 @@ class IPRangeStatusChoices(ChoiceSet):
#
class IPAddressStatusChoices(ChoiceSet):
key = 'ipam.IPAddress.status'
key = 'IPAddress.status'
STATUS_ACTIVE = 'active'
STATUS_RESERVED = 'reserved'
@@ -134,7 +134,7 @@ class FHRPGroupAuthTypeChoices(ChoiceSet):
#
class VLANStatusChoices(ChoiceSet):
key = 'ipam.VLAN.status'
key = 'VLAN.status'
STATUS_ACTIVE = 'active'
STATUS_RESERVED = 'reserved'
@@ -155,8 +155,10 @@ class ServiceProtocolChoices(ChoiceSet):
PROTOCOL_TCP = 'tcp'
PROTOCOL_UDP = 'udp'
PROTOCOL_SCTP = 'sctp'
CHOICES = (
(PROTOCOL_TCP, 'TCP'),
(PROTOCOL_UDP, 'UDP'),
(PROTOCOL_SCTP, 'SCTP'),
)

View File

@@ -74,7 +74,7 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = VRF
fields = ['id', 'name', 'rd', 'enforce_unique']
fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -115,7 +115,7 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = RouteTarget
fields = ['id', 'name']
fields = ['id', 'name', 'description']
class RIRFilterSet(OrganizationalModelFilterSet):
@@ -151,7 +151,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = Aggregate
fields = ['id', 'date_added']
fields = ['id', 'date_added', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -199,7 +199,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class Meta:
model = ASN
fields = ['id', 'asn']
fields = ['id', 'asn', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -220,7 +220,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
class Meta:
model = Role
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'description']
class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -348,7 +348,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = Prefix
fields = ['id', 'is_pool', 'mark_utilized']
fields = ['id', 'is_pool', 'mark_utilized', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -453,7 +453,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class Meta:
model = IPRange
fields = ['id']
fields = ['id', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -828,7 +828,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = VLAN
fields = ['id', 'vid', 'name']
fields = ['id', 'vid', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -900,7 +900,7 @@ class ServiceFilterSet(NetBoxModelFilterSet):
class Meta:
model = Service
fields = ['id', 'name', 'protocol']
fields = ['id', 'name', 'protocol', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -26,8 +26,8 @@ class FHRPGroupTable(NetBoxTable):
orderable=False,
verbose_name='IP Addresses'
)
interface_count = tables.Column(
verbose_name='Interfaces'
member_count = tables.Column(
verbose_name='Members'
)
tags = columns.TagColumn(
url_name='ipam:fhrpgroup_list'
@@ -36,10 +36,10 @@ class FHRPGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = FHRPGroup
fields = (
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count')
class FHRPGroupAssignmentTable(NetBoxTable):

View File

@@ -112,6 +112,10 @@ class ASNTable(NetBoxTable):
site_count = columns.LinkedCountColumn(
viewname='dcim:site_list',
url_params={'asn_id': 'pk'},
verbose_name='Site Count'
)
sites = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='Sites'
)
tenant = TenantColumn()
@@ -122,8 +126,8 @@ class ASNTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ASN
fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated',
'actions',
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'tags', 'created',
'last_updated', 'actions',
)
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant')

View File

@@ -35,8 +35,8 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
]
asns = (
ASN(asn=64512, rir=rirs[0], tenant=tenants[0]),
ASN(asn=64513, rir=rirs[0], tenant=tenants[0]),
ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'),
ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
ASN(asn=64516, rir=rirs[0], tenant=tenants[3]),
@@ -86,6 +86,10 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VRF.objects.all()
@@ -117,8 +121,8 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vrfs = (
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False),
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False),
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False, description='foobar1'),
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False, description='foobar2'),
VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False),
VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True),
@@ -174,6 +178,10 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RouteTarget.objects.all()
@@ -198,8 +206,8 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
route_targets = (
RouteTarget(name='65000:1001', tenant=tenants[0]),
RouteTarget(name='65000:1002', tenant=tenants[0]),
RouteTarget(name='65000:1001', tenant=tenants[0], description='foobar1'),
RouteTarget(name='65000:1002', tenant=tenants[0], description='foobar2'),
RouteTarget(name='65000:1003', tenant=tenants[0]),
RouteTarget(name='65000:1004', tenant=tenants[0]),
RouteTarget(name='65000:2001', tenant=tenants[1]),
@@ -256,6 +264,10 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RIR.objects.all()
@@ -323,8 +335,8 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
aggregates = (
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'),
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02'),
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01', description='foobar1'),
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02', description='foobar2'),
Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'),
Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'),
Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'),
@@ -340,6 +352,10 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'date_added': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Test for multiple values
def test_prefix(self):
params = {'prefix': '10.1.0.0/16'}
@@ -375,8 +391,8 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
Role(name='Role 1', slug='role-1', description='foobar1'),
Role(name='Role 2', slug='role-2', description='foobar2'),
Role(name='Role 3', slug='role-3'),
)
Role.objects.bulk_create(roles)
@@ -389,6 +405,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['role-1', 'role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Prefix.objects.all()
@@ -467,8 +487,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
prefixes = (
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
@@ -601,6 +621,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPRange.objects.all()
@@ -639,8 +663,8 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
ip_ranges = (
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE),
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar1'),
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar2'),
IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
@@ -692,6 +716,10 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPAddress.objects.all()
@@ -1201,8 +1229,8 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vlans = (
# Create one VLAN per VLANGroup
VLAN(vid=1, name='Region 1', group=groups[0]),
VLAN(vid=2, name='Region 2', group=groups[1]),
VLAN(vid=1, name='Region 1', group=groups[0], description='foobar1'),
VLAN(vid=2, name='Region 2', group=groups[1], description='foobar2'),
VLAN(vid=3, name='Region 3', group=groups[2]),
VLAN(vid=4, name='Site Group 1', group=groups[3]),
VLAN(vid=5, name='Site Group 2', group=groups[4]),
@@ -1271,6 +1299,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self):
roles = Role.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]}
@@ -1366,8 +1398,8 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(virtual_machines)
services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001], description='foobar1'),
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002], description='foobar2'),
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
@@ -1383,6 +1415,10 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_port(self):
params = {'port': '1001'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@@ -86,14 +86,17 @@ def add_available_vlans(vlans, vlan_group=None):
"""
Create fake records for all gaps between used VLANs
"""
min_vid = vlan_group.min_vid if vlan_group else VLAN_VID_MIN
max_vid = vlan_group.max_vid if vlan_group else VLAN_VID_MAX
if not vlans:
return [{
'vid': VLAN_VID_MIN,
'vid': min_vid,
'vlan_group': vlan_group,
'available': VLAN_VID_MAX - VLAN_VID_MIN + 1
'available': max_vid - min_vid + 1
}]
prev_vid = VLAN_VID_MAX
prev_vid = max_vid
new_vlans = []
for vlan in vlans:
if vlan.vid - prev_vid > 1:
@@ -104,17 +107,17 @@ def add_available_vlans(vlans, vlan_group=None):
})
prev_vid = vlan.vid
if vlans[0].vid > VLAN_VID_MIN:
if vlans[0].vid > min_vid:
new_vlans.append({
'vid': VLAN_VID_MIN,
'vid': min_vid,
'vlan_group': vlan_group,
'available': vlans[0].vid - VLAN_VID_MIN,
'available': vlans[0].vid - min_vid,
})
if prev_vid < VLAN_VID_MAX:
if prev_vid < max_vid:
new_vlans.append({
'vid': prev_vid + 1,
'vlan_group': vlan_group,
'available': VLAN_VID_MAX - prev_vid,
'available': max_vid - prev_vid,
})
vlans = list(vlans) + new_vlans

View File

@@ -1080,7 +1080,6 @@ class ServiceListView(generic.ObjectListView):
filterset = filtersets.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
action_buttons = ('import', 'export')
class ServiceView(generic.ObjectView):

View File

@@ -1,193 +0,0 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import ManyToManyField
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CreateOnlyDefault
from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
from extras.models import CustomField, Tag
from utilities.utils import dict_to_filter_params
class BaseModelSerializer(serializers.ModelSerializer):
display = serializers.SerializerMethodField(read_only=True)
def get_display(self, obj):
return str(obj)
class ValidatedModelSerializer(BaseModelSerializer):
"""
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
"""
def validate(self, data):
# Remove custom fields data and tags (if any) prior to model validation
attrs = data.copy()
attrs.pop('custom_fields', None)
attrs.pop('tags', None)
# Skip ManyToManyFields
for field in self.Meta.model._meta.get_fields():
if isinstance(field, ManyToManyField):
attrs.pop(field.name, None)
# Run clean() on an instance of the model
if self.instance is None:
instance = self.Meta.model(**attrs)
else:
instance = self.instance
for k, v in attrs.items():
setattr(instance, k, v)
instance.full_clean()
return data
class CustomFieldModelSerializer(ValidatedModelSerializer):
"""
Extends ModelSerializer to render any CustomFields and their values associated with an object.
"""
custom_fields = CustomFieldsDataField(
source='custom_field_data',
default=CreateOnlyDefault(CustomFieldDefaultValues())
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(content_types=content_type)
# Populate CustomFieldValues for each instance from database
if type(self.instance) in (list, tuple):
for obj in self.instance:
self._populate_custom_fields(obj, fields)
else:
self._populate_custom_fields(self.instance, fields)
def _populate_custom_fields(self, instance, custom_fields):
instance.custom_fields = {}
for field in custom_fields:
instance.custom_fields[field.name] = instance.cf.get(field.name)
#
# Nested serializers
#
class WritableNestedSerializer(BaseModelSerializer):
"""
Returns a nested representation of an object on read, but accepts only a primary key on write.
"""
def to_internal_value(self, data):
if data is None:
return None
# Dictionary of related object attributes
if isinstance(data, dict):
params = dict_to_filter_params(data)
queryset = self.Meta.model.objects
try:
return queryset.get(**params)
except ObjectDoesNotExist:
raise ValidationError(
"Related object not found using the provided attributes: {}".format(params)
)
except MultipleObjectsReturned:
raise ValidationError(
"Multiple objects match the provided attributes: {}".format(params)
)
except FieldError as e:
raise ValidationError(e)
# Integer PK of related object
if isinstance(data, int):
pk = data
else:
try:
# PK might have been mistakenly passed as a string
pk = int(data)
except (TypeError, ValueError):
raise ValidationError(
"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
"unrecognized value: {}".format(data)
)
# Look up object by PK
queryset = self.Meta.model.objects
try:
return queryset.get(pk=int(data))
except ObjectDoesNotExist:
raise ValidationError(
"Related object not found using the provided numeric ID: {}".format(pk)
)
#
# Nested tags serialization
#
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
class Meta:
model = Tag
fields = ['id', 'url', 'display', 'name', 'slug', 'color']
#
# Base model serializers
#
class PrimaryModelSerializer(CustomFieldModelSerializer):
"""
Adds support for custom fields and tags.
"""
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', None)
instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging
instance._tags = tags or []
instance = super().update(instance, validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags):
if tags:
instance.tags.set([t.name for t in tags])
else:
instance.tags.clear()
return instance
class NestedGroupModelSerializer(PrimaryModelSerializer):
"""
Extends PrimaryModelSerializer to include MPTT support.
"""
_depth = serializers.IntegerField(source='level', read_only=True)
class BulkOperationSerializer(serializers.Serializer):
id = serializers.IntegerField()

View File

@@ -0,0 +1,27 @@
from rest_framework import serializers
from .base import *
from .features import *
from .nested import *
#
# Base model serializers
#
class NetBoxModelSerializer(TaggableModelSerializer, CustomFieldModelSerializer, ValidatedModelSerializer):
"""
Adds support for custom fields and tags.
"""
pass
class NestedGroupModelSerializer(NetBoxModelSerializer):
"""
Extends PrimaryModelSerializer to include MPTT support.
"""
_depth = serializers.IntegerField(source='level', read_only=True)
class BulkOperationSerializer(serializers.Serializer):
id = serializers.IntegerField()

View File

@@ -0,0 +1,43 @@
from django.db.models import ManyToManyField
from rest_framework import serializers
__all__ = (
'BaseModelSerializer',
'ValidatedModelSerializer',
)
class BaseModelSerializer(serializers.ModelSerializer):
display = serializers.SerializerMethodField(read_only=True)
def get_display(self, obj):
return str(obj)
class ValidatedModelSerializer(BaseModelSerializer):
"""
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
"""
def validate(self, data):
# Remove custom fields data and tags (if any) prior to model validation
attrs = data.copy()
attrs.pop('custom_fields', None)
attrs.pop('tags', None)
# Skip ManyToManyFields
for field in self.Meta.model._meta.get_fields():
if isinstance(field, ManyToManyField):
attrs.pop(field.name, None)
# Run clean() on an instance of the model
if self.instance is None:
instance = self.Meta.model(**attrs)
else:
instance = self.instance
for k, v in attrs.items():
setattr(instance, k, v)
instance.full_clean()
return data

View File

@@ -0,0 +1,80 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
from rest_framework.fields import CreateOnlyDefault
from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
from extras.models import CustomField
from .nested import NestedTagSerializer
__all__ = (
'CustomFieldModelSerializer',
'TaggableModelSerializer',
)
class CustomFieldModelSerializer(serializers.Serializer):
"""
Introduces support for custom field assignment. Adds `custom_fields` serialization and ensures
that custom field data is populated upon initialization.
"""
custom_fields = CustomFieldsDataField(
source='custom_field_data',
default=CreateOnlyDefault(CustomFieldDefaultValues())
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(content_types=content_type)
# Populate custom field values for each instance from database
if type(self.instance) in (list, tuple):
for obj in self.instance:
self._populate_custom_fields(obj, fields)
else:
self._populate_custom_fields(self.instance, fields)
def _populate_custom_fields(self, instance, custom_fields):
instance.custom_fields = {}
for field in custom_fields:
instance.custom_fields[field.name] = instance.cf.get(field.name)
class TaggableModelSerializer(serializers.Serializer):
"""
Introduces support for Tag assignment. Adds `tags` serialization, and handles tag assignment
on create() and update().
"""
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', None)
instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging
instance._tags = tags or []
instance = super().update(instance, validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags):
if tags:
instance.tags.set([t.name for t in tags])
else:
instance.tags.clear()
return instance

View File

@@ -0,0 +1,62 @@
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from extras.models import Tag
from utilities.utils import dict_to_filter_params
from .base import BaseModelSerializer
__all__ = (
'NestedTagSerializer',
'WritableNestedSerializer',
)
class WritableNestedSerializer(BaseModelSerializer):
"""
Represents an object related through a ForeignKey field. On write, it accepts a primary key (PK) value or a
dictionary of attributes which can be used to uniquely identify the related object. This class should be
subclassed to return a full representation of the related object on read.
"""
def to_internal_value(self, data):
if data is None:
return None
# Dictionary of related object attributes
if isinstance(data, dict):
params = dict_to_filter_params(data)
queryset = self.Meta.model.objects
try:
return queryset.get(**params)
except ObjectDoesNotExist:
raise ValidationError(f"Related object not found using the provided attributes: {params}")
except MultipleObjectsReturned:
raise ValidationError(f"Multiple objects match the provided attributes: {params}")
except FieldError as e:
raise ValidationError(e)
# Integer PK of related object
try:
# Cast as integer in case a PK was mistakenly sent as a string
pk = int(data)
except (TypeError, ValueError):
raise ValidationError(
f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
f"unrecognized value: {data}"
)
# Look up object by PK
try:
return self.Meta.model.objects.get(pk=pk)
except ObjectDoesNotExist:
raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
class Meta:
model = Tag
fields = ['id', 'url', 'display', 'name', 'slug', 'color']

View File

@@ -1,292 +1,17 @@
import logging
import platform
from collections import OrderedDict
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
from rq.worker import Worker
from extras.models import ExportTemplate
from netbox.api import BulkOperationSerializer
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import SerializerNotFound
from utilities.api import get_serializer_for_model
HTTP_ACTIONS = {
'GET': 'view',
'OPTIONS': None,
'HEAD': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
'DELETE': 'delete',
}
#
# Mixins
#
class BulkUpdateModelMixin:
"""
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set.
For example:
PATCH /api/dcim/sites/
[
{
"id": 123,
"name": "New name"
},
{
"id": 456,
"status": "planned"
}
]
"""
def bulk_update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
qs = self.get_queryset().filter(
pk__in=[o['id'] for o in serializer.data]
)
# Map update data by object ID
update_data = {
obj.pop('id'): obj for obj in request.data
}
data = self.perform_bulk_update(qs, update_data, partial=partial)
return Response(data, status=status.HTTP_200_OK)
def perform_bulk_update(self, objects, update_data, partial):
with transaction.atomic():
data_list = []
for obj in objects:
data = update_data.get(obj.id)
if hasattr(obj, 'snapshot'):
obj.snapshot()
serializer = self.get_serializer(obj, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
data_list.append(serializer.data)
return data_list
def bulk_partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True
return self.bulk_update(request, *args, **kwargs)
class BulkDestroyModelMixin:
"""
Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
DELETE /api/dcim/sites/
[
{"id": 123},
{"id": 456}
]
"""
def bulk_destroy(self, request, *args, **kwargs):
serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
qs = self.get_queryset().filter(
pk__in=[o['id'] for o in serializer.data]
)
self.perform_bulk_destroy(qs)
return Response(status=status.HTTP_204_NO_CONTENT)
def perform_bulk_destroy(self, objects):
with transaction.atomic():
for obj in objects:
if hasattr(obj, 'snapshot'):
obj.snapshot()
self.perform_destroy(obj)
class ObjectValidationMixin:
def _validate_objects(self, instance):
"""
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
if type(instance) is list:
# Check that all instances are still included in the view's queryset
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
#
# Viewsets
#
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
"""
Extend DRF's ModelViewSet to support bulk update and delete functions.
"""
brief = False
brief_prefetch_fields = []
def get_object_with_snapshot(self):
"""
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
record the "before" data in the changelog.
"""
obj = super().get_object()
if hasattr(obj, 'snapshot'):
obj.snapshot()
return obj
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list):
kwargs['many'] = True
return super().get_serializer(*args, **kwargs)
def get_serializer_class(self):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
logger.debug(f"Using serializer {serializer}")
return serializer
except SerializerNotFound:
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
# Fall back to the hard-coded serializer class
logger.debug(f"Using serializer {self.serializer_class}")
return self.serializer_class
def get_queryset(self):
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
if self.brief:
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
return super().get_queryset()
def initialize_request(self, request, *args, **kwargs):
# Check if brief=True has been passed
if request.method == 'GET' and request.GET.get('brief'):
self.brief = True
return super().initialize_request(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if not request.user.is_authenticated:
return
# Restrict the view's QuerySet to allow only the permitted objects
action = HTTP_ACTIONS[request.method]
if action:
self.queryset = self.queryset.restrict(request.user, action)
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
try:
return super().dispatch(request, *args, **kwargs)
except ProtectedError as e:
protected_objects = list(e.protected_objects)
msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
logger.warning(msg)
return self.finalize_response(
request,
Response({'detail': msg}, status=409),
*args,
**kwargs
)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.
"""
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset)
return super().list(request, *args, **kwargs)
def perform_create(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Creating new {model._meta.verbose_name}")
# Enforce object-level permissions on save()
try:
with transaction.atomic():
instance = serializer.save()
self._validate_objects(instance)
except ObjectDoesNotExist:
raise PermissionDenied()
def update(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
# Enforce object-level permissions on save()
try:
with transaction.atomic():
instance = serializer.save()
self._validate_objects(instance)
except ObjectDoesNotExist:
raise PermissionDenied()
def destroy(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().destroy(request, *args, **kwargs)
def perform_destroy(self, instance):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
return super().perform_destroy(instance)
#
# Views
#
class APIRootView(APIView):
"""

View File

@@ -0,0 +1,182 @@
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from extras.models import ExportTemplate
from netbox.api.exceptions import SerializerNotFound
from utilities.api import get_serializer_for_model
from .mixins import *
__all__ = (
'NetBoxModelViewSet',
)
HTTP_ACTIONS = {
'GET': 'view',
'OPTIONS': None,
'HEAD': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
'DELETE': 'delete',
}
class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet):
"""
Extend DRF's ModelViewSet to support bulk update and delete functions.
"""
brief = False
brief_prefetch_fields = []
def get_object_with_snapshot(self):
"""
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
record the "before" data in the changelog.
"""
obj = super().get_object()
if hasattr(obj, 'snapshot'):
obj.snapshot()
return obj
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list):
kwargs['many'] = True
return super().get_serializer(*args, **kwargs)
def get_serializer_class(self):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
logger.debug(f"Using serializer {serializer}")
return serializer
except SerializerNotFound:
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
# Fall back to the hard-coded serializer class
logger.debug(f"Using serializer {self.serializer_class}")
return self.serializer_class
def get_serializer_context(self):
"""
For models which support custom fields, populate the `custom_fields` context.
"""
context = super().get_serializer_context()
if hasattr(self.queryset.model, 'custom_fields'):
content_type = ContentType.objects.get_for_model(self.queryset.model)
context.update({
'custom_fields': content_type.custom_fields.all(),
})
return context
def get_queryset(self):
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
if self.brief:
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
return super().get_queryset()
def initialize_request(self, request, *args, **kwargs):
# Check if brief=True has been passed
if request.method == 'GET' and request.GET.get('brief'):
self.brief = True
return super().initialize_request(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if not request.user.is_authenticated:
return
# Restrict the view's QuerySet to allow only the permitted objects
action = HTTP_ACTIONS[request.method]
if action:
self.queryset = self.queryset.restrict(request.user, action)
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
try:
return super().dispatch(request, *args, **kwargs)
except ProtectedError as e:
protected_objects = list(e.protected_objects)
msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
logger.warning(msg)
return self.finalize_response(
request,
Response({'detail': msg}, status=409),
*args,
**kwargs
)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.
"""
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset)
return super().list(request, *args, **kwargs)
def perform_create(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Creating new {model._meta.verbose_name}")
# Enforce object-level permissions on save()
try:
with transaction.atomic():
instance = serializer.save()
self._validate_objects(instance)
except ObjectDoesNotExist:
raise PermissionDenied()
def update(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
# Enforce object-level permissions on save()
try:
with transaction.atomic():
instance = serializer.save()
self._validate_objects(instance)
except ObjectDoesNotExist:
raise PermissionDenied()
def destroy(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().destroy(request, *args, **kwargs)
def perform_destroy(self, instance):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
return super().perform_destroy(instance)

View File

@@ -0,0 +1,113 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from rest_framework import status
from rest_framework.response import Response
from netbox.api.serializers import BulkOperationSerializer
__all__ = (
'BulkUpdateModelMixin',
'BulkDestroyModelMixin',
'ObjectValidationMixin',
)
class BulkUpdateModelMixin:
"""
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set.
For example:
PATCH /api/dcim/sites/
[
{
"id": 123,
"name": "New name"
},
{
"id": 456,
"status": "planned"
}
]
"""
def bulk_update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
qs = self.get_queryset().filter(
pk__in=[o['id'] for o in serializer.data]
)
# Map update data by object ID
update_data = {
obj.pop('id'): obj for obj in request.data
}
data = self.perform_bulk_update(qs, update_data, partial=partial)
return Response(data, status=status.HTTP_200_OK)
def perform_bulk_update(self, objects, update_data, partial):
with transaction.atomic():
data_list = []
for obj in objects:
data = update_data.get(obj.id)
if hasattr(obj, 'snapshot'):
obj.snapshot()
serializer = self.get_serializer(obj, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
data_list.append(serializer.data)
return data_list
def bulk_partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True
return self.bulk_update(request, *args, **kwargs)
class BulkDestroyModelMixin:
"""
Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one
or more JSON objects, each specifying the numeric ID of an object to be deleted. For example:
DELETE /api/dcim/sites/
[
{"id": 123},
{"id": 456}
]
"""
def bulk_destroy(self, request, *args, **kwargs):
serializer = BulkOperationSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
qs = self.get_queryset().filter(
pk__in=[o['id'] for o in serializer.data]
)
self.perform_bulk_destroy(qs)
return Response(status=status.HTTP_204_NO_CONTENT)
def perform_bulk_destroy(self, objects):
with transaction.atomic():
for obj in objects:
if hasattr(obj, 'snapshot'):
obj.snapshot()
self.perform_destroy(obj)
class ObjectValidationMixin:
def _validate_objects(self, instance):
"""
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
if type(instance) is list:
# Check that all instances are still included in the view's queryset
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)

View File

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.2.0-beta1'
VERSION = '3.2.0-beta2'
# Hostname
HOSTNAME = platform.node()

View File

@@ -217,7 +217,7 @@ class ChoiceFieldColumn(tables.Column):
# Determine the background color to use (try calling object.get_FOO_color())
try:
bg_color = getattr(record, f'get_{bound_column.name}_color')()
bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
except AttributeError:
bg_color = self.DEFAULT_BG_COLOR
@@ -361,27 +361,35 @@ class CustomFieldColumn(tables.Column):
super().__init__(*args, **kwargs)
@staticmethod
def _likify_item(item):
if hasattr(item, 'get_absolute_url'):
return f'<a href="{item.get_absolute_url()}">{item}</a>'
return item
def render(self, value):
if isinstance(value, list):
return ', '.join(v for v in value)
elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
return mark_safe(f'<a href="{value}">{value}</a>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
return ', '.join(v for v in value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return mark_safe(', '.join([
self._likify_item(obj) for obj in self.customfield.deserialize(value)
]))
if value is not None:
obj = self.customfield.deserialize(value)
if hasattr(obj, 'get_absolute_url'):
return mark_safe(f'<a href="{obj.get_absolute_url}">{obj}</a>')
return obj
return mark_safe(self._likify_item(obj))
return self.default
def value(self, value):
if isinstance(value, list):
return ','.join(v for v in value)
return ','.join(str(v) for v in self.customfield.deserialize(value))
if value is not None:
return value
return self.customfield.deserialize(value)
return self.default
@@ -399,7 +407,10 @@ class CustomLinkColumn(tables.Column):
def render(self, record):
try:
rendered = self.customlink.render({'obj': record})
rendered = self.customlink.render({
'object': record,
'obj': record, # TODO: Remove in NetBox v3.5
})
if rendered:
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
except Exception as e:
@@ -408,7 +419,10 @@ class CustomLinkColumn(tables.Column):
def value(self, record):
try:
rendered = self.customlink.render({'obj': record})
rendered = self.customlink.render({
'object': record,
'obj': record, # TODO: Remove in NetBox v3.5
})
if rendered:
return rendered['link']
except Exception:

View File

@@ -41,40 +41,37 @@ class BaseTable(tables.Table):
if self.empty_text is None:
self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
# Hide non-default columns
default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), *self.exempt_columns]
for column in self.columns:
if column.name not in default_columns:
self.columns.hide(column.name)
# Apply custom column ordering for user
# Determine the table columns to display by checking the following:
# 1. User's configuration for the table
# 2. Meta.default_columns
# 3. Meta.fields
selected_columns = None
if user is not None and not isinstance(user, AnonymousUser):
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
if selected_columns:
if not selected_columns:
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
# Show only persistent or selected columns
for name, column in self.columns.items():
if name in [*self.exempt_columns, *selected_columns]:
self.columns.show(name)
else:
self.columns.hide(name)
# Hide non-selected columns which are not exempt
for column in self.columns:
if column.name not in [*selected_columns, *self.exempt_columns]:
self.columns.hide(column.name)
# Rearrange the sequence to list selected columns first, followed by all remaining columns
# TODO: There's probably a more clever way to accomplish this
self.sequence = [
*[c for c in selected_columns if c in self.columns.names()],
*[c for c in self.columns.names() if c not in selected_columns]
]
# Rearrange the sequence to list selected columns first, followed by all remaining columns
# TODO: There's probably a more clever way to accomplish this
self.sequence = [
*[c for c in selected_columns if c in self.columns.names()],
*[c for c in self.columns.names() if c not in selected_columns]
]
# PK column should always come first
if 'pk' in self.sequence:
self.sequence.remove('pk')
self.sequence.insert(0, 'pk')
# PK column should always come first
if 'pk' in self.sequence:
self.sequence.remove('pk')
self.sequence.insert(0, 'pk')
# Actions column should always come last
if 'actions' in self.sequence:
self.sequence.remove('actions')
self.sequence.append('actions')
# Actions column should always come last
if 'actions' in self.sequence:
self.sequence.remove('actions')
self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
if isinstance(self.data, TableQuerysetData):
@@ -127,6 +124,30 @@ class BaseTable(tables.Table):
self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
return self._objects_count
def configure(self, request):
"""
Configure the table for a specific request context. This performs pagination and records
the user's preferred ordering logic.
"""
# Save ordering preference
if request.user.is_authenticated:
table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering
# Paginate the table results
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
tables.RequestConfig(request, paginate).configure(self)
class NetBoxTable(BaseTable):
"""
@@ -167,27 +188,3 @@ class NetBoxTable(BaseTable):
])
super().__init__(*args, extra_columns=extra_columns, **kwargs)
def configure(self, request):
"""
Configure the table for a specific request context. This performs pagination and records
the user's preferred ordering logic.
"""
# Save ordering preference
if request.user.is_authenticated:
table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering
# Paginate the table results
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
tables.RequestConfig(request, paginate).configure(self)

View File

@@ -1,5 +1,6 @@
import logging
import re
from collections import defaultdict
from copy import deepcopy
from django.contrib import messages
@@ -42,27 +43,34 @@ class ObjectListView(BaseMultiObjectView):
Attributes:
filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
action_buttons: A list of buttons to include at the top of the page
actions: Supported actions for the model. When adding custom actions, bulk action names must
be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete
action_perms: A dictionary mapping supported actions to a set of permissions required for each
"""
template_name = 'generic/object_list.html'
filterset = None
filterset_form = None
action_buttons = ('add', 'import', 'export')
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
action_perms = defaultdict(set, **{
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
})
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
def get_table(self, request, permissions):
def get_table(self, request, bulk_actions=True):
"""
Return the django-tables2 Table instance to be used for rendering the objects list.
Args:
request: The current request
permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating
whether the user has each
bulk_actions: Show checkboxes for object selection
"""
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
if 'pk' in table.base_columns and bulk_actions:
table.columns.show('pk')
return table
@@ -135,17 +143,20 @@ class ObjectListView(BaseMultiObjectView):
if self.filterset:
self.queryset = self.filterset(request.GET, self.queryset).qs
# Compile a dictionary indicating which permissions are available to the current user for this model
permissions = {}
for action in ('add', 'change', 'delete', 'view'):
perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name)
# Determine the available actions
actions = []
for action in self.actions:
if request.user.has_perms([
get_permission_for_model(model, name) for name in self.action_perms[action]
]):
actions.append(action)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
if 'export' in request.GET:
# Export the current table view
if request.GET['export'] == 'table':
table = self.get_table(request, permissions)
table = self.get_table(request, has_bulk_actions)
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
@@ -163,11 +174,11 @@ class ObjectListView(BaseMultiObjectView):
# Fall back to default table/YAML export
else:
table = self.get_table(request, permissions)
table = self.get_table(request, has_bulk_actions)
return self.export_table(table)
# Render the objects table
table = self.get_table(request, permissions)
table = self.get_table(request, has_bulk_actions)
table.configure(request)
# If this is an HTMX request, return only the rendered table HTML
@@ -179,8 +190,7 @@ class ObjectListView(BaseMultiObjectView):
context = {
'model': model,
'table': table,
'permissions': permissions,
'action_buttons': self.action_buttons,
'actions': actions,
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
**self.get_extra_context(request),
}

View File

@@ -17,7 +17,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
from utilities.htmx import is_htmx
from utilities.permissions import get_permission_for_model
from utilities.utils import normalize_querydict, prepare_cloned_fields
from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
from utilities.views import GetReturnURLMixin
from .base import BaseObjectView
@@ -453,7 +453,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
# If this is an HTMX request, return only the rendered deletion form as modal content
if is_htmx(request):
viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
viewname = get_viewname(self.queryset.model, action='delete')
form_url = reverse(viewname, kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', {
'object': obj,
@@ -559,42 +559,19 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
})
def post(self, request):
logger = logging.getLogger('netbox.views.ComponentCreateView')
form, model_form = self.initialize_forms(request)
instance = self.alter_object(self.queryset.model, request)
self.validate_form(request, form)
if form.is_valid() and not form.errors:
if '_addanother' in request.POST:
return redirect(request.get_full_path())
else:
return redirect(self.get_return_url(request))
return render(request, self.template_name, {
'object': instance,
'replication_form': form,
'form': model_form,
'return_url': self.get_return_url(request),
})
# TODO: Refactor this method for clarity & better error reporting
def validate_form(self, request, form):
"""
Validate form values and set errors on the form object as they are detected. If
no errors are found, signal success messages.
"""
logger = logging.getLogger('netbox.views.ComponentCreateView')
if form.is_valid():
new_components = []
data = deepcopy(request.POST)
names = form.cleaned_data['name_pattern']
labels = form.cleaned_data.get('label_pattern')
pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern'])
for i, name in enumerate(names):
label = labels[i] if labels else None
# Initialize the individual component form
data['name'] = name
data['label'] = label
for i in range(pattern_count):
for field_name in self.patterned_fields:
if form.cleaned_data.get(f'{field_name}_pattern'):
data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i]
if hasattr(form, 'get_iterative_data'):
data.update(form.get_iterative_data(i))
@@ -620,8 +597,12 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
messages.success(request, "Added {} {}".format(
len(new_components), self.queryset.model._meta.verbose_name_plural
))
# Return the newly created objects so overridden post methods can use the data as needed.
return new_objs
# Redirect user on success
if '_addanother' in request.POST:
return redirect(request.get_full_path())
else:
return redirect(self.get_return_url(request))
except PermissionsViolation:
msg = "Component creation failed due to object-level permissions violation"
@@ -629,4 +610,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
form.add_error(None, msg)
clear_webhooks.send(sender=self)
return None
return render(request, self.template_name, {
'object': instance,
'replication_form': form,
'form': model_form,
'return_url': self.get_return_url(request),
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,16 @@ type ShowHideMap = {
*
* @example vlangroup_edit
*/
[view: string]: {
[view: string]: string;
};
type ShowHideLayout = {
/**
* Name of layout config
*
* @example vlangroup
*/
[config: string]: {
/**
* Default layout.
*/
@@ -19,15 +28,15 @@ type ShowHideMap = {
};
/**
* Mapping of scope names to arrays of object types whose fields should be hidden or shown when
* Mapping of layout names to arrays of object types whose fields should be hidden or shown when
* the scope type (key) is selected.
*
* For example, if `region` is the scope type, the fields with IDs listed in
* showHideMap.region.hide should be hidden, and the fields with IDs listed in
* showHideMap.region.show should be shown.
*/
const showHideMap: ShowHideMap = {
vlangroup_edit: {
const showHideLayout: ShowHideLayout = {
vlangroup: {
region: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region'],
@@ -70,6 +79,17 @@ const showHideMap: ShowHideMap = {
},
},
};
/**
* Mapping of view names to layout configurations
*
* For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
*/
const showHideMap: ShowHideMap = {
vlangroup_add: 'vlangroup',
vlangroup_edit: 'vlangroup',
};
/**
* Toggle visibility of a given element's parent.
* @param query CSS Query.
@@ -94,8 +114,9 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
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();
const layoutConfig = showHideMap[view];
for (const [scope, fields] of Object.entries(showHideMap[view])) {
for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
// If the scope type ends with the specified scope, toggle its field visibility according to
// the show/hide values.
if (scopeType.endsWith(scope)) {
@@ -109,7 +130,7 @@ function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSe
break;
} else {
// Otherwise, hide all fields.
for (const field of showHideMap[view].default.hide) {
for (const field of showHideLayout[layoutConfig].default.hide) {
toggleParentVisibility(`#${field}`, 'hide');
}
}

View File

@@ -23,7 +23,6 @@ $theme-colors: (
'danger': $danger,
'light': $light,
'dark': $dark,
// General-purpose palette
'blue': $blue-300,
'indigo': $indigo-300,
@@ -37,7 +36,7 @@ $theme-colors: (
'cyan': $cyan-300,
'gray': $gray-300,
'black': $black,
'white': $white,
'white': $white
);
// Gradient
@@ -146,7 +145,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
$nav-pills-link-active-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg;
$navbar-light-color: $darkest;
$navbar-light-color: $navbar-dark-color;
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
$navbar-light-toggler-border-color: $gray-700;

View File

@@ -139,7 +139,7 @@
<body>
<script type="text/javascript">
(function() {
function checkSideNav() {
// Check localStorage to see if the sidebar should be pinned.
var sideNavRaw = localStorage.getItem('netbox-sidenav');
// Determine if the device has a small screeen. This media query is equivalent to
@@ -154,11 +154,15 @@
// jumpy/glitchy behavior on page reloads.
document.body.setAttribute('data-sidenav-pinned', '');
document.body.setAttribute('data-sidenav-show', '');
document.body.removeAttribute('data-sidenav-hidden');
} else {
document.body.removeAttribute('data-sidenav-pinned');
document.body.setAttribute('data-sidenav-hidden', '');
}
}
})();
}
window.addEventListener('resize', function(){ checkSideNav() });
checkSideNav();
</script>
{# Page layout #}

View File

@@ -116,52 +116,54 @@ Blocks:
{# Page footer #}
<footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0">
{% block footer %}
<div class="row align-items-center justify-content-between mx-0">
{# Docs & Community Links #}
<div class="col-sm-12 col-md-auto fs-4 noprint">
<nav class="nav justify-content-center justify-content-lg-start">
{# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
<div class="col-sm-12 col-md-auto fs-4 noprint">
<nav class="nav justify-content-center justify-content-lg-start">
{% block footer_links %}
{# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# REST API #}
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# REST API #}
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# API docs #}
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# API docs #}
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# GraphQL API #}
{% if config.GRAPHQL_ENABLED %}
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endif %}
{# GraphQL API #}
{% if config.GRAPHQL_ENABLED %}
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endif %}
{# GitHub #}
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# GitHub #}
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# NetDev Slack #}
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endblock footer_links %}
</nav>
</div>
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
</div>
{# NetDev Slack #}
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
</nav>
</div>
{# System Info #}
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
</div>
</div>
{% endblock footer %}
</footer>
</div>

View File

@@ -2,31 +2,14 @@
{% load static %}
{% load form_helpers %}
{% block title %}{{ object.circuit.provider }} {{ object.circuit }} - Side {{ form.term_side.value }}{% endblock %}
{% block form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Circuit Termination</h5>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Provider</label>
<div class="col">
<input class="form-control" value="{{ object.circuit.provider }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Circuit</label>
<div class="col">
<input class="form-control" value="{{ object.circuit.cid }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Termination</label>
<div class="col">
<input class="form-control" value="{{ form.term_side.value }}" disabled />
</div>
</div>
{% render_field form.provider %}
{% render_field form.circuit %}
{% render_field form.term_side %}
{% render_field form.mark_connected %}
{% with providernetwork_tab_active=form.initial.provider_network %}
<div class="row mb-2">

View File

@@ -5,7 +5,7 @@
<strong class="d-block d-md-inline mb-3 mb-md-0">Termination - {{ side }} Side</strong>
<div class="float-md-end">
{% if not termination and perms.circuits.add_circuittermination %}
<a href="{% url 'circuits:circuittermination_add' circuit=object.pk %}?term_side={{ side }}" class="btn btn-sm btn-success lh-1">
<a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add
</a>
{% endif %}

View File

@@ -2,6 +2,8 @@
{% load form_helpers %}
{% block form %}
{% render_form replication_form %}
{% block replication_fields %}
{% render_form replication_form %}
{% endblock replication_fields %}
{{ block.super }}
{% endblock form %}

View File

@@ -0,0 +1,34 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="row mb-2">
<div class="offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="devicetype_tab" data-bs-toggle="tab" aria-controls="devicetype" data-bs-target="#devicetype" class="nav-link {% if not form.initial.module_type %}active{% endif %}">
Device Type
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="moduletype_tab" data-bs-toggle="tab" aria-controls="moduletype" data-bs-target="#moduletype" class="nav-link {% if form.initial.module_type %}active{% endif %}">
Module Type
</button>
</li>
</ul>
</div>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.module_type %}active{% endif %}" id="devicetype" role="tabpanel">
{% render_field replication_form.device_type %}
</div>
<div class="tab-pane {% if form.initial.module_type %}active{% endif %}" id="moduletype" role="tabpanel">
{% render_field replication_form.module_type %}
</div>
{% block replication_fields %}
{% render_field replication_form.name_pattern %}
{% render_field replication_form.label_pattern %}
{% endblock replication_fields %}
</div>
{{ block.super }}
{% endblock form %}

View File

@@ -73,4 +73,5 @@
</ul>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'dcim/component_template_create.html' %}
{% load form_helpers %}
{% block replication_fields %}
{{ block.super }}
{% render_field replication_form.rear_port_set %}
{% endblock replication_fields %}

View File

@@ -1,9 +1,9 @@
{% extends 'generic/object_edit.html' %}
{% extends 'dcim/component_create.html' %}
{% load helpers %}
{% load form_helpers %}
{% block form %}
{% render_form replication_form %}
{% block replication_fields %}
{{ block.super }}
{% if object.component %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">
@@ -14,5 +14,4 @@
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}
{% endblock replication_fields %}

View File

@@ -0,0 +1,17 @@
{% extends 'dcim/component_template_create.html' %}
{% load helpers %}
{% load form_helpers %}
{% block replication_fields %}
{{ block.super }}
{% if object.component %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">
{{ object.component|meta:"verbose_name"|bettertitle }}
</label>
<div class="col">
<input class="form-control" value="{{ object.component }}" disabled />
</div>
</div>
{% endif %}
{% endblock replication_fields %}

View File

@@ -34,6 +34,12 @@
<a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.pk }}">{{ devicetypes_table.rows|length }}</a>
</td>
</tr>
<tr>
<th scope="row">Module types</th>
<td>
<a href="{% url 'dcim:moduletype_list' %}?manufacturer_id={{ object.pk }}">{{ module_type_count }}</a>
</td>
</tr>
<tr>
<th scope="row">Inventory Items</th>
<td>

Some files were not shown because too many files have changed in this diff Show More