Compare commits

...

98 Commits

Author SHA1 Message Date
Jeremy Stretch
b8dd379cef Merge pull request #3518 from netbox-community/develop
Release v2.6.4
2019-09-19 09:34:49 -04:00
Jeremy Stretch
be2417d808 Release v2.6.4 2019-09-19 09:30:16 -04:00
Jeremy Stretch
d4b263dd0c Fixes #3439: Add a note about the Swagger API interface 2019-09-18 16:41:08 -04:00
Jeremy Stretch
37aaf6f4ab Fixes #3429: Update installation docs for Redis 2019-09-18 16:35:45 -04:00
Jeremy Stretch
51fb0b59ec Closes #3485: Enable embedded graphs for devices 2019-09-18 15:59:52 -04:00
Jeremy Stretch
a0545568cd Fixes #3514: Label TextVar fields when rendering custom script forms 2019-09-18 15:39:26 -04:00
Jeremy Stretch
84208d5429 Fixes #3511: Correct API URL for nested device bays 2019-09-18 14:40:47 -04:00
Jeremy Stretch
7264a4ffb6 Fixes #3513: Fix assignment of tags when creating front/rear ports 2019-09-18 14:33:47 -04:00
Daniel Sheppard
e8ee6f1bc5 Clean up extra line that snuck in 2019-09-17 15:45:55 -05:00
Jeremy Stretch
a742d897d7 Closes #3510: Add minimum/maximum prefix length enforcement for IPNetworkVar 2019-09-17 16:36:36 -04:00
Daniel Sheppard
0601619f50 Merge branch 'develop' of https://github.com/netbox-community/netbox into develop 2019-09-13 12:09:02 -05:00
Daniel Sheppard
1a1f6aff7b Closes: #3495 2019-09-13 12:08:48 -05:00
Jeremy Stretch
5962e7c942 Fixes #3501: Fix rendering of checkboxes on custom script forms 2019-09-13 11:45:35 -04:00
Daniel Sheppard
57d35181f0 Fix performance issues when creating/editing interfaces due to unfiltered vlan queryset 2019-09-12 11:13:40 -05:00
Daniel Sheppard
73065fa6e7 Using static element to determine brief parameter, corrected to $(element) 2019-09-11 10:10:43 -05:00
Jeremy Stretch
a8ca536d44 Bump platform name/slug max length to 100 chars (#3318) 2019-09-10 15:50:41 -04:00
Jeremy Stretch
062c65fd67 Changelog cleanup 2019-09-10 15:45:54 -04:00
Jeremy Stretch
f533530693 Moved related projects list to the wiki 2019-09-10 15:30:19 -04:00
Jeremy Stretch
355910e182 Fixes #3489: Prevent exception triggered by webhook upon object deletion 2019-09-09 15:50:10 -04:00
Daniel Sheppard
e67d4fb2e5 Update Changelog with Future Changes 2019-09-06 13:32:21 -05:00
Daniel Sheppard
050f2478d3 Fixes: #3318 - Increases length of platform name and slug to 64 characters (#3353) 2019-09-06 13:01:27 -05:00
Daniel Sheppard
9c6dbd7337 Add in in-line vlan editing and Bulk vlan editing (#3350)
* Fixes #3341 - Added in-line vlan editing
* Fixes #2160 - Added bulk vlan editing

Inconsequential behaviour changes:

* APISelect can now take "full=True" to return a non-brief set
* Select2 will no group by "group & site, group, site, global" if full=True is set in APISelect
2019-09-06 12:45:37 -05:00
Daniel Sheppard
8f5e73a598 Add filter for has local context data (#3159)
* Add filter for has local context data
* Broke out filter and form for re-use
* Fix missing StaticSelect2 import
* Fix missing BOOLEAN_WITH_BLANK_CHOICES import
* Fix class resolution
* Fix field ordering
* Fix PEP8 errors
2019-09-06 11:42:56 -05:00
Jeremy Stretch
2ce0ff505a Post-release version bump 2019-09-04 16:28:58 -04:00
Jeremy Stretch
a4d8b92cb1 Merge pull request #3478 from netbox-community/develop
Release v2.6.3
2019-09-04 16:27:32 -04:00
Jeremy Stretch
143a158e4a Release v2.6.3 2019-09-04 16:23:06 -04:00
Jeremy Stretch
4213454234 Tweak docs for custom scripts 2019-09-04 16:21:21 -04:00
Jeremy Stretch
559beffd24 Add documentation for custom links 2019-08-28 12:39:11 -04:00
Jeremy Stretch
5f4bac6076 Closes #3454: Enable filtering circuits by region 2019-08-28 12:12:27 -04:00
Jeremy Stretch
8ff3d2cbf6 Closes #3456: Enable bulk editing of tag color 2019-08-28 11:56:00 -04:00
Jeremy Stretch
273a9793db Fix typo 2019-08-28 10:51:56 -04:00
Jeremy Stretch
5a911aa5a1 Fixes #3392: Add database index for ObjectChange time 2019-08-28 10:48:19 -04:00
Jeremy Stretch
3078e366e2 Simplify changelog cleanup logic 2019-08-28 10:44:05 -04:00
Jeremy Stretch
22b8a45a71 Add tests for changelog 2019-08-28 10:18:37 -04:00
Jeremy Stretch
4756353fbd Merge pull request #3453 from netbox-community/3452-change-logging
Fixes #3452: Queue deletion ObjectChanges until after response is sent
2019-08-28 09:51:54 -04:00
Jeremy Stretch
3e8799b5c7 Fix script form rendering 2019-08-28 09:20:19 -04:00
Jeremy Stretch
a3d9e633c1 Always include 'commit' option 2019-08-26 17:04:04 -04:00
Jeremy Stretch
6e66f8d68a Fixes #3452: Queue deletion ObjectChanges until after response is sent 2019-08-26 16:52:05 -04:00
Jeremy Stretch
03ac2721bc Merge pull request #3423 from netbox-community/3415-custom-scripts
Add custom scripting
2019-08-26 14:06:22 -04:00
Jeremy Stretch
456621695a Wrap script form inside a panel 2019-08-26 13:53:30 -04:00
Jeremy Stretch
9a9660a765 Fix errant changelog entries when executing a script without committing 2019-08-26 11:59:38 -04:00
Jeremy Stretch
6568653d13 Fix typo in link 2019-08-22 10:51:29 -04:00
Jeremy Stretch
5248fc7c7c Merge pull request #3434 from netbox-community/3428-cache-invalidation
Cache invalidation
2019-08-22 10:49:51 -04:00
Jeremy Stretch
6a8f256a56 Fix typo 2019-08-21 15:46:06 -04:00
John Anderson
46bedc6156 missed a merge conflict resolution 2019-08-21 14:30:07 -04:00
John Anderson
63c3f423c2 Merge branch 'develop' into 3428-cache-invalidation 2019-08-20 17:35:54 -04:00
John Anderson
3d2a738f44 #3428 changelog 2019-08-20 17:27:40 -04:00
John Anderson
f0f1ef2ef2 fix signals update call 2019-08-20 17:20:46 -04:00
John Anderson
c359ac5737 convert update() calls to save() calls 2019-08-20 17:16:00 -04:00
Jeremy Stretch
a4936ad0dd Introduce BaseScript for extending Script without creating a new executable script 2019-08-19 14:40:08 -04:00
Jeremy Stretch
a02ded6b01 Import Django User model automatically when running nbshell 2019-08-19 11:47:50 -04:00
Jeremy Stretch
2d2bb3ec0c Fixes #3421: Fix exception when ordering power connections list by PDU 2019-08-19 11:27:36 -04:00
Jeremy Stretch
eb6e95ae9b Add tests for Script Variables 2019-08-19 10:41:44 -04:00
Jeremy Stretch
f56a0aebdb Closes #3430: Linkify platform field on device view 2019-08-19 09:50:41 -04:00
John Anderson
c54f2e3e40 remove blank line after update call 2019-08-19 02:11:54 -04:00
John Anderson
ade844f7a7 fixes #3428 - caching invalidation issues
Mitgate invalidation issues by using prefetch_related instead of select_related.
Also use invalidated_update instead of just update.
2019-08-19 01:53:39 -04:00
Jeremy Stretch
2929621651 Updated docs for IPNetworkVar and FileVar 2019-08-16 15:31:29 -04:00
Jeremy Stretch
de770faf6a Add FileVar for file uploads 2019-08-16 15:27:58 -04:00
Jeremy Stretch
99394de14e Change fields to field_order 2019-08-15 16:19:25 -04:00
Jeremy Stretch
305d330391 Docs updates 2019-08-15 16:07:15 -04:00
Jeremy Stretch
9c41984f6d Changelog for #3426 2019-08-15 15:31:05 -04:00
Jeremy Stretch
aa858fea03 Merge pull request #3427 from candlerb/candlerb/3426
Improve API error handling when a list is given as a choice value
2019-08-15 15:29:47 -04:00
Brian Candler
6e5d527fec Improve API error handling when a list is given as a choice value
Fixes #3426
2019-08-15 17:16:24 +01:00
Jeremy Stretch
b11d3dde46 Closes #3391: Update Bootstrap CSS to v3.4.1 2019-08-15 11:47:57 -04:00
Jeremy Stretch
3df8ccb92f Fixes #3424: Fix tag coloring for non-linked tags 2019-08-15 11:12:52 -04:00
Jeremy Stretch
0b95cab47b Closes #3386: Add mac_address filter for virtual machines 2019-08-15 11:02:40 -04:00
Jeremy Stretch
cb0dbc0769 Add TextVar for large text entry 2019-08-14 16:20:52 -04:00
Jeremy Stretch
47d60dbb20 Fix table column widths 2019-08-14 15:46:08 -04:00
Jeremy Stretch
f8326ef6df Add markdown rendering for log mesages 2019-08-14 14:38:11 -04:00
Jeremy Stretch
434e656e27 Include stack trace when catching an exception 2019-08-14 14:26:13 -04:00
Jeremy Stretch
8bd1fad7d0 Use TreeNodeChoiceField for MPTT objects 2019-08-14 14:03:11 -04:00
Jeremy Stretch
7f65e009a8 Add convenience functions for loading YAML/JSON data from file 2019-08-14 13:08:21 -04:00
Jeremy Stretch
11e5e1c490 Show script log when an exception occurs 2019-08-14 12:19:36 -04:00
Jeremy Stretch
9c079ead4c Fix notice when form does not require user input 2019-08-14 10:18:25 -04:00
Jeremy Stretch
c562af3a13 Record script execution time 2019-08-14 10:12:30 -04:00
Jeremy Stretch
30e14db881 Tweak form display (cosmetic) 2019-08-14 09:40:23 -04:00
Jeremy Stretch
dab30f50d3 Add IPNetworkVar 2019-08-13 09:48:51 -04:00
Jeremy Stretch
3d6a583ce4 Allow user to override module name 2019-08-13 09:09:12 -04:00
Jeremy Stretch
44fd0ebb2d Meta.fields should be optional 2019-08-12 16:59:09 -04:00
Jeremy Stretch
0d289d660d Add option to commit database changes 2019-08-12 14:28:06 -04:00
Jeremy Stretch
3e75da4307 Implemented run_script() wrapper 2019-08-12 13:51:25 -04:00
Jeremy Stretch
19eb4c510c Move script attributes under a Meta class 2019-08-12 13:16:18 -04:00
Jeremy Stretch
dd4dafa7be Closes #3420: Serial number filter for racks, devices, and inventory items is now case-insensitive 2019-08-12 12:10:36 -04:00
Jeremy Stretch
116c395948 Fixes #3422: Prevent navigation menu from overlapping page content 2019-08-12 11:57:48 -04:00
Jeremy Stretch
ab504439fb Implemented permissions for scripts 2019-08-12 11:39:36 -04:00
Jeremy Stretch
463c636301 Extend example custom script to generate output 2019-08-12 11:13:16 -04:00
Jeremy Stretch
950a09895b BooleanVar cannot be required 2019-08-12 11:13:16 -04:00
Jeremy Stretch
c62a9f1b3f Example script tweak 2019-08-12 11:13:16 -04:00
Jeremy Stretch
3f7f3f88f3 Fix form field ordering 2019-08-12 11:13:16 -04:00
Jeremy Stretch
4fc19742ec Added documentation for custom scripts 2019-08-12 11:13:16 -04:00
Jeremy Stretch
9d054fb345 Add options for script vars; include script output 2019-08-12 11:13:16 -04:00
Jeremy Stretch
a25a27f31f Initial work on custom scripts (#3415) 2019-08-12 11:13:16 -04:00
Jeremy Stretch
f18c3be745 Merge pull request #3412 from netbox-community/3405-bugfix
Move device component creation logic out of Device model
2019-08-08 21:16:11 -04:00
Jeremy Stretch
0516aecb03 Changelog for #3405 2019-08-07 17:49:54 -04:00
Jeremy Stretch
605be30fb2 Add test for device component creation 2019-08-07 17:48:12 -04:00
Jeremy Stretch
86cd044a68 Fixes #3405: Move device component creation logic into template models 2019-08-07 17:47:44 -04:00
Jeremy Stretch
068a0e2257 Removed invalid contact email 2019-08-02 15:02:29 -04:00
Jeremy Stretch
3a2fc43542 Post-release version bump 2019-08-02 10:31:56 -04:00
108 changed files with 3418 additions and 1353 deletions

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@
/netbox/netbox/ldap_config.py /netbox/netbox/ldap_config.py
/netbox/reports/* /netbox/reports/*
!/netbox/reports/__init__.py !/netbox/reports/__init__.py
/netbox/scripts/*
!/netbox/scripts/__init__.py
/netbox/static /netbox/static
.idea .idea
/*.sh /*.sh

View File

@@ -1,3 +1,53 @@
v2.6.4 (2019-09-19)
## Enhancements
* [#2160](https://github.com/netbox-community/netbox/issues/2160) - Add bulk editing for interface VLAN assignment
* [#3027](https://github.com/netbox-community/netbox/issues/3028) - Add `local_context_data` boolean filter for devices
* [#3318](https://github.com/netbox-community/netbox/issues/3318) - Increase length of platform name and slug to 100 characters
* [#3341](https://github.com/netbox-community/netbox/issues/3341) - Enable inline VLAN assignment while editing an interface
* [#3485](https://github.com/netbox-community/netbox/issues/3485) - Enable embedded graphs for devices
* [#3510](https://github.com/netbox-community/netbox/issues/3510) - Add minimum/maximum prefix length enforcement for `IPNetworkVar`
## Bug Fixes
* [#3489](https://github.com/netbox-community/netbox/issues/3489) - Prevent exception triggered by webhook upon object deletion
* [#3501](https://github.com/netbox-community/netbox/issues/3501) - Fix rendering of checkboxes on custom script forms
* [#3511](https://github.com/netbox-community/netbox/issues/3511) - Correct API URL for nested device bays
* [#3513](https://github.com/netbox-community/netbox/issues/3513) - Fix assignment of tags when creating front/rear ports
* [#3514](https://github.com/netbox-community/netbox/issues/3514) - Label TextVar fields when rendering custom script forms
v2.6.3 (2019-09-04)
## New Features
### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415))
Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/additional-features/custom-scripts/) for more detail.
Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.
## Enhancements
* [#3386](https://github.com/netbox-community/netbox/issues/3386) - Add `mac_address` filter for virtual machines
* [#3391](https://github.com/netbox-community/netbox/issues/3391) - Update Bootstrap CSS to v3.4.1
* [#3405](https://github.com/netbox-community/netbox/issues/3405) - Fix population of power port/outlet details on device creation
* [#3422](https://github.com/netbox-community/netbox/issues/3422) - Prevent navigation menu from overlapping page content
* [#3430](https://github.com/netbox-community/netbox/issues/3430) - Linkify platform field on device view
* [#3454](https://github.com/netbox-community/netbox/issues/3454) - Enable filtering circuits by region
* [#3456](https://github.com/netbox-community/netbox/issues/3456) - Enable bulk editing of tag color
## Bug Fixes
* [#3392](https://github.com/netbox-community/netbox/issues/3392) - Add database index for ObjectChange time
* [#3420](https://github.com/netbox-community/netbox/issues/3420) - Serial number filter for racks, devices, and inventory items is now case-insensitive
* [#3428](https://github.com/netbox-community/netbox/issues/3428) - Fixed cache invalidation issues ([#3300](https://github.com/netbox-community/netbox/issues/3300), [#3363](https://github.com/netbox-community/netbox/issues/3363), [#3379](https://github.com/netbox-community/netbox/issues/3379), [#3382](https://github.com/netbox-community/netbox/issues/3382)) by switching to `prefetch_related()` instead of `select_related()` and removing use of `update()`
* [#3421](https://github.com/netbox-community/netbox/issues/3421) - Fix exception when ordering power connections list by PDU
* [#3424](https://github.com/netbox-community/netbox/issues/3424) - Fix tag coloring for non-linked tags
* [#3426](https://github.com/netbox-community/netbox/issues/3426) - Improve API error handling for ChoiceFields
---
v2.6.2 (2019-08-02) v2.6.2 (2019-08-02)
## Enhancements ## Enhancements

View File

@@ -43,15 +43,4 @@ and run `upgrade.sh`.
# Related projects # Related projects
## Supported SDK Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.
- [pynetbox](https://github.com/digitalocean/pynetbox) - A Python API client library for Netbox
## Community SDK
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) - A Ruby client library for Netbox
- [powerbox](https://github.com/BatmanAMA/powerbox) - A PowerShell library for Netbox
## Ansible Inventory
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) - Ansible dynamic inventory script for Netbox

View File

@@ -0,0 +1,43 @@
# Custom Links
Custom links allow users to place arbitrary hyperlinks within NetBox views. These are helpful for cross-referencing related records in external systems. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
For example, you might define a link like this:
* Text: `View NMS`
* URL: `https://nms.example.com/nodes/?name={{ obj.name }}`
When viewing a device named Router4, this link would render as:
```
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
```
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
## Conditional Rendering
Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.
For example, if you only want to display a link for active devices, you could set the link text to
```
{% if obj.status == 1 %}View NMS{% endif %}
```
The link will not appear when viewing a device with any status other than "active."
Another example, if you want to only show an object of a certain manufacturer, you could set the link text to:
```
{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS {% endif %}
```
The link will only appear when viewing a device with a manufacturer name of "Cisco."
## Link Groups
You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a
single button bearing the name of the group.

View File

@@ -0,0 +1,213 @@
# Custom Scripts
Custom scripting was introduced to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as:
* Automatically populate new devices and cables in preparation for a new site deployment
* Create a range of new reserved prefixes or IP addresses
* Fetch data from an external source and import it to NetBox
Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything.
## Writing Custom Scripts
All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
```
from extras.scripts import Script
class MyScript(Script):
..
```
Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.)
```
class MyScript(Script):
var1 = StringVar(...)
var2 = IntegerVar(...)
var3 = ObjectVar(...)
def run(self, data):
...
```
The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution.
Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI.
## Module Attributes
### `name`
You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the filename will be used.
## Script Attributes
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
### `name`
This is the human-friendly names of your script. If omitted, the class name will be used.
### `description`
A human-friendly description of what your script does.
### `field_order`
A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example:
```
field_order = ['var1', 'var2', 'var3']
```
## Reading Data from Files
The Script class provides two convenience methods for reading data from files:
* `load_yaml`
* `load_json`
These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
## Logging
The Script object provides a set of convenient functions for recording messages at different severity levels:
* `log_debug`
* `log_success`
* `log_info`
* `log_warning`
* `log_failure`
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
## Variable Reference
### StringVar
Stores a string of characters (i.e. a line of text). Options include:
* `min_length` - Minimum number of characters
* `max_length` - Maximum number of characters
* `regex` - A regular expression against which the provided value must match
Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field.
### TextVar
Arbitrary text of any length. Renders as multi-line text input field.
### IntegerVar
Stored a numeric integer. Options include:
* `min_value:` - Minimum value
* `max_value` - Maximum value
### BooleanVar
A true/false flag. This field has no options beyond the defaults.
### ObjectVar
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/)
### FileVar
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
### IPNetworkVar
An IPv4 or IPv6 network with a mask.
### Default Options
All variables support the following default options:
* `label` - The name of the form field
* `description` - A brief description of the field
* `default` - The field's default value
* `required` - Indicates whether the field is mandatory (default: true)
## Example
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
* The name of the new site
* The device model (a filtered list of defined device types)
* The number of access switches to create
These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects.
```
from django.utils.text import slugify
from dcim.constants import *
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import *
class NewBranchScript(Script):
class Meta:
name = "New Branch"
description = "Provision a new branch site"
fields = ['site_name', 'switch_count', 'switch_model']
site_name = StringVar(
description="Name of the new site"
)
switch_count = IntegerVar(
description="Number of access switches to create"
)
switch_model = ObjectVar(
description="Access switch model",
queryset = DeviceType.objects.filter(
manufacturer__name='Cisco',
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
)
)
def run(self, data):
# Create the new site
site = Site(
name=data['site_name'],
slug=slugify(data['site_name']),
status=SITE_STATUS_PLANNED
)
site.save()
self.log_success("Created new site: {}".format(site))
# Create access switches
switch_role = DeviceRole.objects.get(name='Access Switch')
for i in range(1, data['switch_count'] + 1):
switch = Device(
device_type=data['switch_model'],
name='{}-switch{}'.format(site.slug, i),
site=site,
status=DEVICE_STATUS_PLANNED,
device_role=switch_role
)
switch.save()
self.log_success("Created new switch: {}".format(switch))
# Generate a CSV table of new devices
output = [
'name,make,model'
]
for switch in Device.objects.filter(site=site):
attrs = [
switch.name,
switch.device_type.manufacturer.name,
switch.device_type.model
]
output.append(','.join(attrs))
return '\n'.join(output)
```

View File

@@ -43,7 +43,7 @@ class DeviceConnectionsReport(Report):
def test_console_connection(self): def test_console_connection(self):
# Check that every console port for every active device has a connection defined. # Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
if console_port.connected_endpoint is None: if console_port.connected_endpoint is None:
self.log_failure( self.log_failure(
console_port.device, console_port.device,

View File

@@ -1,5 +1,3 @@
NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
# What is a REST API? # What is a REST API?
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP to create, retrieve, update, and delete objects from a database. (This set of operations is commonly referred to as CRUD.) Each type of operation is associated with a particular HTTP verb: REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP to create, retrieve, update, and delete objects from a database. (This set of operations is commonly referred to as CRUD.) Each type of operation is associated with a particular HTTP verb:
@@ -34,6 +32,10 @@ $ curl -s http://localhost:8000/api/ipam/ip-addresses/2954/ | jq '.'
Each attribute of the NetBox object is expressed as a field in the dictionary. Fields may include their own nested objects, as in the case of the `status` field above. Every object includes a primary key named `id` which uniquely identifies it in the database. Each attribute of the NetBox object is expressed as a field in the dictionary. Fields may include their own nested objects, as in the case of the `status` field above. Every object includes a primary key named `id` which uniquely identifies it in the database.
# Interactive Documentation
Comprehensive, interactive documentation of all API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with NetBox's various API endpoints and different request types.
# URL Hierarchy # URL Hierarchy
NetBox's entire API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application: NetBox's entire API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:

View File

@@ -277,6 +277,14 @@ The file path to the location where custom reports will be kept. By default, thi
--- ---
## SCRIPTS_ROOT
Default: $BASE_DIR/netbox/scripts/
The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
---
## SESSION_FILE_PATH ## SESSION_FILE_PATH
Default: None Default: None

View File

@@ -16,11 +16,11 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* NAME - Database name * `NAME` - Database name
* USER - PostgreSQL username * `USER` - PostgreSQL username
* PASSWORD - PostgreSQL password * `PASSWORD` - PostgreSQL password
* HOST - Name or IP address of the database server (use `localhost` if running locally) * `HOST` - Name or IP address of the database server (use `localhost` if running locally)
* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432) * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
Example: Example:
@@ -36,16 +36,6 @@ DATABASE = {
--- ---
## SECRET_KEY
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
---
## REDIS ## REDIS
[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of [Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
@@ -54,13 +44,13 @@ functionality (as well as other planned features).
Redis is configured using a configuration setting similar to `DATABASE`: Redis is configured using a configuration setting similar to `DATABASE`:
* HOST - Name or IP address of the Redis server (use `localhost` if running locally) * `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* PORT - TCP port of the Redis service; leave blank for default port (6379) * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* PASSWORD - Redis password (if set) * `PASSWORD` - Redis password (if set)
* DATABASE - Numeric database ID for webhooks * `DATABASE` - Numeric database ID for webhooks
* CACHE_DATABASE - Numeric database ID for caching * `CACHE_DATABASE` - Numeric database ID for caching
* DEFAULT_TIMEOUT - Connection timeout in seconds * `DEFAULT_TIMEOUT` - Connection timeout in seconds
* SSL - Use SSL connection to Redis * `SSL` - Use SSL connection to Redis
Example: Example:
@@ -84,3 +74,13 @@ REDIS = {
!!! warning: !!! warning:
It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
processing data being lost in cache flushing events. processing data being lost in cache flushing events.
---
## SECRET_KEY
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.

View File

@@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre
### 4. Update relevant querysets ### 4. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups. If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
### 5. Update API serializer ### 5. Update API serializer

View File

@@ -101,9 +101,10 @@ Move into the NetBox configuration directory and make a copy of `configuration.e
Open `configuration.py` with your preferred editor and set the following variables: Open `configuration.py` with your preferred editor and set the following variables:
* ALLOWED_HOSTS * `ALLOWED_HOSTS`
* DATABASE * `DATABASE`
* SECRET_KEY * `REDIS`
* `SECRET_KEY`
## ALLOWED_HOSTS ## ALLOWED_HOSTS
@@ -117,7 +118,7 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
## DATABASE ## DATABASE
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../configuration/required-settings/#database) for more detail on individual parameters.
Example: Example:
@@ -131,6 +132,22 @@ DATABASE = {
} }
``` ```
## REDIS
Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings/#redis) for more detail on individual parameters.
```python
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
```
## SECRET_KEY ## SECRET_KEY
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system. Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
@@ -140,21 +157,6 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
!!! note !!! note
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state. In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
## Webhooks Configuration
If you have opted to enable the webhooks, set `WEBHOOKS_ENABLED = True` and define the relevant `REDIS` database parameters. Below is an example:
```python
WEBHOOKS_ENABLED = True
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
}
```
# Run Database Migrations # Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):

View File

@@ -35,7 +35,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
filterset_class = filters.ProviderFilter filterset_class = filters.ProviderFilter
@action(detail=True) @action(detail=True)
def graphs(self, request, pk=None): def graphs(self, request, pk):
""" """
A convenience method for rendering graphs for a particular provider. A convenience method for rendering graphs for a particular provider.
""" """
@@ -62,7 +62,7 @@ class CircuitTypeViewSet(ModelViewSet):
# #
class CircuitViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags') queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilter filterset_class = filters.CircuitFilter
@@ -72,7 +72,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
# #
class CircuitTerminationViewSet(ModelViewSet): class CircuitTerminationViewSet(ModelViewSet):
queryset = CircuitTermination.objects.select_related( queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'connected_endpoint__device', 'cable' 'circuit', 'site', 'connected_endpoint__device', 'cable'
) )
serializer_class = serializers.CircuitTerminationSerializer serializer_class = serializers.CircuitTerminationSerializer

View File

@@ -1,10 +1,10 @@
import django_filters import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.models import Site from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from .constants import CIRCUIT_STATUS_CHOICES from .constants import CIRCUIT_STATUS_CHOICES
from .models import Provider, Circuit, CircuitTermination, CircuitType from .models import Provider, Circuit, CircuitTermination, CircuitType
@@ -98,6 +98,17 @@ class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:

View File

@@ -270,23 +270,21 @@ class CircuitTermination(CableTermination):
def __str__(self): def __str__(self):
return 'Side {}'.format(self.get_term_side_display()) return 'Side {}'.format(self.get_term_side_display())
def log_change(self, user, request_id, action): def to_objectchange(self, action):
""" # Annotate the parent Circuit
Reference the parent circuit when recording the change.
"""
try: try:
related_object = self.circuit related_object = self.circuit
except Circuit.DoesNotExist: except Circuit.DoesNotExist:
# Parent circuit has been deleted # Parent circuit has been deleted
related_object = None related_object = None
ObjectChange(
user=user, return ObjectChange(
request_id=request_id,
changed_object=self, changed_object=self,
related_object=related_object, object_repr=str(self),
action=action, action=action,
related_object=related_object,
object_data=serialize_object(self) object_data=serialize_object(self)
).save() )
@property @property
def parent(self): def parent(self):
@@ -295,6 +293,6 @@ class CircuitTermination(CableTermination):
def get_peer_termination(self): def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A' peer_side = 'Z' if self.term_side == 'A' else 'A'
try: try:
return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side) return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side)
except CircuitTermination.DoesNotExist: except CircuitTermination.DoesNotExist:
return None return None

View File

@@ -10,4 +10,8 @@ def update_circuit(instance, **kwargs):
""" """
When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
""" """
Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now()) circuits = Circuit.objects.filter(pk=instance.circuit_id)
time = timezone.now()
for circuit in circuits:
circuit.last_updated = time
circuit.save()

View File

@@ -35,11 +35,7 @@ class ProviderView(PermissionRequiredMixin, View):
def get(self, request, slug): def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug) provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related( circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
'type', 'tenant'
).prefetch_related(
'terminations__site'
)
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', { return render(request, 'circuits/provider.html', {
@@ -134,10 +130,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class CircuitListView(PermissionRequiredMixin, ObjectListView): class CircuitListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuit' permission_required = 'circuits.view_circuit'
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
queryset = Circuit.objects.select_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant' 'provider', 'type', 'tenant', 'terminations__site'
).prefetch_related(
'terminations__site'
).annotate( ).annotate(
a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
@@ -153,13 +147,13 @@ class CircuitView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related( termination_a = CircuitTermination.objects.prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region', 'connected_endpoint__device'
).filter( ).filter(
circuit=circuit, term_side=TERM_SIDE_A circuit=circuit, term_side=TERM_SIDE_A
).first() ).first()
termination_z = CircuitTermination.objects.select_related( termination_z = CircuitTermination.objects.prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region', 'connected_endpoint__device'
).filter( ).filter(
circuit=circuit, term_side=TERM_SIDE_Z circuit=circuit, term_side=TERM_SIDE_Z
@@ -199,7 +193,7 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit' permission_required = 'circuits.change_circuit'
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter filter = filters.CircuitFilter
table = tables.CircuitTable table = tables.CircuitTable
form = forms.CircuitBulkEditForm form = forms.CircuitBulkEditForm
@@ -208,7 +202,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit' permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter filter = filters.CircuitFilter
table = tables.CircuitTable table = tables.CircuitTable
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'

View File

@@ -228,7 +228,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
class NestedDeviceBaySerializer(WritableNestedSerializer): class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
class Meta: class Meta:

View File

@@ -480,7 +480,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
return super().validate(data) return super().validate(data)
class RearPortSerializer(ValidatedModelSerializer): class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES) type = ChoiceField(choices=PORT_TYPE_CHOICES)
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
@@ -502,7 +502,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
class FrontPortSerializer(ValidatedModelSerializer): class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES) type = ChoiceField(choices=PORT_TYPE_CHOICES)
rear_port = FrontPortRearPortSerializer() rear_port = FrontPortRearPortSerializer()

View File

@@ -23,7 +23,8 @@ from dcim.models import (
) )
from extras.api.serializers import RenderedGraphSerializer from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from utilities.api import ( from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
@@ -109,10 +110,8 @@ class RegionViewSet(ModelViewSet):
# #
class SiteViewSet(CustomFieldModelViewSet): class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.select_related( queryset = Site.objects.prefetch_related(
'region', 'tenant' 'region', 'tenant', 'tags'
).prefetch_related(
'tags'
).annotate( ).annotate(
device_count=get_subquery(Device, 'site'), device_count=get_subquery(Device, 'site'),
rack_count=get_subquery(Rack, 'site'), rack_count=get_subquery(Rack, 'site'),
@@ -125,7 +124,7 @@ class SiteViewSet(CustomFieldModelViewSet):
filterset_class = filters.SiteFilter filterset_class = filters.SiteFilter
@action(detail=True) @action(detail=True)
def graphs(self, request, pk=None): def graphs(self, request, pk):
""" """
A convenience method for rendering graphs for a particular site. A convenience method for rendering graphs for a particular site.
""" """
@@ -140,7 +139,7 @@ class SiteViewSet(CustomFieldModelViewSet):
# #
class RackGroupViewSet(ModelViewSet): class RackGroupViewSet(ModelViewSet):
queryset = RackGroup.objects.select_related('site').annotate( queryset = RackGroup.objects.prefetch_related('site').annotate(
rack_count=Count('racks') rack_count=Count('racks')
) )
serializer_class = serializers.RackGroupSerializer serializer_class = serializers.RackGroupSerializer
@@ -164,10 +163,8 @@ class RackRoleViewSet(ModelViewSet):
# #
class RackViewSet(CustomFieldModelViewSet): class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.select_related( queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant' 'site', 'group__site', 'role', 'tenant', 'tags'
).prefetch_related(
'tags'
).annotate( ).annotate(
device_count=get_subquery(Device, 'rack'), device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack') powerfeed_count=get_subquery(PowerFeed, 'rack')
@@ -206,7 +203,7 @@ class RackViewSet(CustomFieldModelViewSet):
# #
class RackReservationViewSet(ModelViewSet): class RackReservationViewSet(ModelViewSet):
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
serializer_class = serializers.RackReservationSerializer serializer_class = serializers.RackReservationSerializer
filterset_class = filters.RackReservationFilter filterset_class = filters.RackReservationFilter
@@ -234,7 +231,7 @@ class ManufacturerViewSet(ModelViewSet):
# #
class DeviceTypeViewSet(CustomFieldModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
device_count=Count('instances') device_count=Count('instances')
) )
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
@@ -246,49 +243,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
# #
class ConsolePortTemplateViewSet(ModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet):
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ConsolePortTemplateSerializer serializer_class = serializers.ConsolePortTemplateSerializer
filterset_class = filters.ConsolePortTemplateFilter filterset_class = filters.ConsolePortTemplateFilter
class ConsoleServerPortTemplateViewSet(ModelViewSet): class ConsoleServerPortTemplateViewSet(ModelViewSet):
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ConsoleServerPortTemplateSerializer serializer_class = serializers.ConsoleServerPortTemplateSerializer
filterset_class = filters.ConsoleServerPortTemplateFilter filterset_class = filters.ConsoleServerPortTemplateFilter
class PowerPortTemplateViewSet(ModelViewSet): class PowerPortTemplateViewSet(ModelViewSet):
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.PowerPortTemplateSerializer serializer_class = serializers.PowerPortTemplateSerializer
filterset_class = filters.PowerPortTemplateFilter filterset_class = filters.PowerPortTemplateFilter
class PowerOutletTemplateViewSet(ModelViewSet): class PowerOutletTemplateViewSet(ModelViewSet):
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.PowerOutletTemplateSerializer serializer_class = serializers.PowerOutletTemplateSerializer
filterset_class = filters.PowerOutletTemplateFilter filterset_class = filters.PowerOutletTemplateFilter
class InterfaceTemplateViewSet(ModelViewSet): class InterfaceTemplateViewSet(ModelViewSet):
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.InterfaceTemplateSerializer serializer_class = serializers.InterfaceTemplateSerializer
filterset_class = filters.InterfaceTemplateFilter filterset_class = filters.InterfaceTemplateFilter
class FrontPortTemplateViewSet(ModelViewSet): class FrontPortTemplateViewSet(ModelViewSet):
queryset = FrontPortTemplate.objects.select_related('device_type__manufacturer') queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.FrontPortTemplateSerializer serializer_class = serializers.FrontPortTemplateSerializer
filterset_class = filters.FrontPortTemplateFilter filterset_class = filters.FrontPortTemplateFilter
class RearPortTemplateViewSet(ModelViewSet): class RearPortTemplateViewSet(ModelViewSet):
queryset = RearPortTemplate.objects.select_related('device_type__manufacturer') queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.RearPortTemplateSerializer serializer_class = serializers.RearPortTemplateSerializer
filterset_class = filters.RearPortTemplateFilter filterset_class = filters.RearPortTemplateFilter
class DeviceBayTemplateViewSet(ModelViewSet): class DeviceBayTemplateViewSet(ModelViewSet):
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.DeviceBayTemplateSerializer serializer_class = serializers.DeviceBayTemplateSerializer
filterset_class = filters.DeviceBayTemplateFilter filterset_class = filters.DeviceBayTemplateFilter
@@ -324,11 +321,9 @@ class PlatformViewSet(ModelViewSet):
# #
class DeviceViewSet(CustomFieldModelViewSet): class DeviceViewSet(CustomFieldModelViewSet):
queryset = Device.objects.select_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
).prefetch_related(
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
) )
filterset_class = filters.DeviceFilter filterset_class = filters.DeviceFilter
@@ -352,6 +347,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
return serializers.DeviceWithConfigContextSerializer return serializers.DeviceWithConfigContextSerializer
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular Device.
"""
device = get_object_or_404(Device, pk=pk)
queryset = Graph.objects.filter(type=GRAPH_TYPE_DEVICE)
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@action(detail=True, url_path='napalm') @action(detail=True, url_path='napalm')
def napalm(self, request, pk): def napalm(self, request, pk):
""" """
@@ -429,58 +435,42 @@ class DeviceViewSet(CustomFieldModelViewSet):
# #
class ConsolePortViewSet(CableTraceMixin, ModelViewSet): class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
queryset = ConsolePort.objects.select_related( queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
)
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsolePortFilter filterset_class = filters.ConsolePortFilter
class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.select_related( queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filters.ConsoleServerPortFilter filterset_class = filters.ConsoleServerPortFilter
class PowerPortViewSet(CableTraceMixin, ModelViewSet): class PowerPortViewSet(CableTraceMixin, ModelViewSet):
queryset = PowerPort.objects.select_related( queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable' 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
).prefetch_related(
'tags'
) )
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerPortFilter filterset_class = filters.PowerPortFilter
class PowerOutletViewSet(CableTraceMixin, ModelViewSet): class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
queryset = PowerOutlet.objects.select_related( queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
)
serializer_class = serializers.PowerOutletSerializer serializer_class = serializers.PowerOutletSerializer
filterset_class = filters.PowerOutletFilter filterset_class = filters.PowerOutletFilter
class InterfaceViewSet(CableTraceMixin, ModelViewSet): class InterfaceViewSet(CableTraceMixin, ModelViewSet):
queryset = Interface.objects.filter( queryset = Interface.objects.prefetch_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags'
).filter(
device__isnull=False device__isnull=False
).select_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable'
).prefetch_related(
'ip_addresses', 'tags'
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilter filterset_class = filters.InterfaceFilter
@action(detail=True) @action(detail=True)
def graphs(self, request, pk=None): def graphs(self, request, pk):
""" """
A convenience method for rendering graphs for a particular interface. A convenience method for rendering graphs for a particular interface.
""" """
@@ -491,33 +481,25 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
class FrontPortViewSet(ModelViewSet): class FrontPortViewSet(ModelViewSet):
queryset = FrontPort.objects.select_related( queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
'device__device_type__manufacturer', 'rear_port', 'cable'
).prefetch_related(
'tags'
)
serializer_class = serializers.FrontPortSerializer serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilter filterset_class = filters.FrontPortFilter
class RearPortViewSet(ModelViewSet): class RearPortViewSet(ModelViewSet):
queryset = RearPort.objects.select_related( queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
'device__device_type__manufacturer', 'cable'
).prefetch_related(
'tags'
)
serializer_class = serializers.RearPortSerializer serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilter filterset_class = filters.RearPortFilter
class DeviceBayViewSet(ModelViewSet): class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags') queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
serializer_class = serializers.DeviceBaySerializer serializer_class = serializers.DeviceBaySerializer
filterset_class = filters.DeviceBayFilter filterset_class = filters.DeviceBayFilter
class InventoryItemViewSet(ModelViewSet): class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags') queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
serializer_class = serializers.InventoryItemSerializer serializer_class = serializers.InventoryItemSerializer
filterset_class = filters.InventoryItemFilter filterset_class = filters.InventoryItemFilter
@@ -527,7 +509,7 @@ class InventoryItemViewSet(ModelViewSet):
# #
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = ConsolePort.objects.select_related( queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device' 'device', 'connected_endpoint__device'
).filter( ).filter(
connected_endpoint__isnull=False connected_endpoint__isnull=False
@@ -537,7 +519,7 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = PowerPort.objects.select_related( queryset = PowerPort.objects.prefetch_related(
'device', 'connected_endpoint__device' 'device', 'connected_endpoint__device'
).filter( ).filter(
_connected_poweroutlet__isnull=False _connected_poweroutlet__isnull=False
@@ -547,7 +529,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.select_related( queryset = Interface.objects.prefetch_related(
'device', '_connected_interface__device' 'device', '_connected_interface__device'
).filter( ).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair # Avoid duplicate connections by only selecting the lower PK in a connected pair
@@ -587,7 +569,7 @@ class VirtualChassisViewSet(ModelViewSet):
# #
class PowerPanelViewSet(ModelViewSet): class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.select_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
powerfeed_count=Count('powerfeeds') powerfeed_count=Count('powerfeeds')
@@ -601,11 +583,7 @@ class PowerPanelViewSet(ModelViewSet):
# #
class PowerFeedViewSet(CustomFieldModelViewSet): class PowerFeedViewSet(CustomFieldModelViewSet):
queryset = PowerFeed.objects.select_related( queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags')
'power_panel', 'rack'
).prefetch_related(
'tags'
)
serializer_class = serializers.PowerFeedSerializer serializer_class = serializers.PowerFeedSerializer
filterset_class = filters.PowerFeedFilter filterset_class = filters.PowerFeedFilter

View File

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES from utilities.constants import COLOR_CHOICES
@@ -160,12 +160,15 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'outer_width', 'outer_depth', 'outer_unit',
] ]
@@ -421,7 +424,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver'] fields = ['id', 'name', 'slug', 'napalm_driver']
class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@@ -519,6 +522,9 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
field_name='interfaces__mac_address', field_name='interfaces__mac_address',
label='MAC address', label='MAC address',
) )
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
has_primary_ip = django_filters.BooleanFilter( has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip', method='_has_primary_ip',
label='Has a primary IP', label='Has a primary IP',
@@ -560,7 +566,7 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] fields = ['id', 'name', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@@ -847,10 +853,13 @@ class InventoryItemFilter(DeviceComponentFilterSet):
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ['id', 'name', 'part_id', 'serial', 'asset_tag', 'discovered'] fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@@ -13,7 +13,9 @@ from taggit.forms import TagField
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import (
AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm
)
from ipam.models import IPAddress, VLAN, VLANGroup from ipam.models import IPAddress, VLAN, VLANGroup
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
@@ -54,6 +56,25 @@ def get_device_by_name_or_pk(name):
return device return device
class InterfaceCommonForm:
def clean(self):
super().clean()
# Validate VLAN assignments
tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
class BulkRenameForm(forms.Form): class BulkRenameForm(forms.Form):
""" """
An extendable form to be used for renaming device components in bulk. An extendable form to be used for renaming device components in bulk.
@@ -632,7 +653,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
) )
group_id = ChainedModelChoiceField( group_id = ChainedModelChoiceField(
label='Rack group', label='Rack group',
queryset=RackGroup.objects.select_related('site'), queryset=RackGroup.objects.prefetch_related('site'),
chains=( chains=(
('site', 'site'), ('site', 'site'),
), ),
@@ -745,7 +766,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
) )
) )
group_id = FilterChoiceField( group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site'), queryset=RackGroup.objects.prefetch_related('site'),
label='Rack group', label='Rack group',
null_label='-- None --', null_label='-- None --',
widget=APISelectMultiple( widget=APISelectMultiple(
@@ -788,6 +809,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
slug = SlugField( slug = SlugField(
slug_source='model' slug_source='model'
) )
comments = CommentField()
tags = TagField( tags = TagField(
required=False required=False
) )
@@ -1221,7 +1243,9 @@ class DeviceRoleCSVForm(forms.ModelForm):
# #
class PlatformForm(BootstrapMixin, forms.ModelForm): class PlatformForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField(
max_length=64
)
class Meta: class Meta:
model = Platform model = Platform
@@ -1335,7 +1359,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False) tags = TagField(required=False)
local_context_data = JSONField(required=False) local_context_data = JSONField(
required=False,
label=''
)
class Meta: class Meta:
model = Device model = Device
@@ -1391,14 +1418,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
interface_ids = self.instance.vc_interfaces.values('pk') interface_ids = self.instance.vc_interfaces.values('pk')
# Collect interface IPs # Collect interface IPs
interface_ips = IPAddress.objects.select_related('interface').filter( interface_ips = IPAddress.objects.prefetch_related('interface').filter(
family=family, interface_id__in=interface_ids family=family, interface_id__in=interface_ids
) )
if interface_ips: if interface_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list)) ip_choices.append(('Interface IPs', ip_list))
# Collect NAT IPs # Collect NAT IPs
nat_ips = IPAddress.objects.select_related('nat_inside').filter( nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
family=family, nat_inside__interface__in=interface_ids family=family, nat_inside__interface__in=interface_ids
) )
if nat_ips: if nat_ips:
@@ -1675,7 +1702,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
] ]
class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
model = Device model = Device
field_order = [ field_order = [
'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
@@ -1710,7 +1737,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
) )
) )
rack_group_id = FilterChoiceField( rack_group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related( queryset=RackGroup.objects.prefetch_related(
'site' 'site'
), ),
label='Rack group', label='Rack group',
@@ -1749,7 +1776,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
) )
) )
device_type_id = FilterChoiceField( device_type_id = FilterChoiceField(
queryset=DeviceType.objects.select_related( queryset=DeviceType.objects.prefetch_related(
'manufacturer' 'manufacturer'
), ),
label='Model', label='Model',
@@ -2108,7 +2135,26 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
# Interfaces # Interfaces
# #
class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
untagged_vlan = forms.ModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tagged_vlans = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tags = TagField( tags = TagField(
required=False required=False
) )
@@ -2147,112 +2193,38 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG
) )
def clean(self): # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
super().clean()
# Validate VLAN assignments
tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
vlans = forms.MultipleChoiceField(
choices=[],
label='VLANs',
widget=StaticSelect2Multiple(
attrs={
'size': 20,
}
)
)
tagged = forms.BooleanField(
required=False,
initial=True
)
class Meta:
model = Interface
fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.mode == IFACE_MODE_ACCESS:
self.initial['tagged'] = False
# Find all VLANs already assigned to the interface for exclusion from the list
assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
if self.instance.untagged_vlan is not None:
assigned_vlans.append(self.instance.untagged_vlan.pk)
# Compile VLAN choices
vlan_choices = [] vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
# Add non-grouped global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append( vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans]) ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
) )
# Add grouped global VLANs
for group in VLANGroup.objects.filter(site=None): for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append( vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
) )
site = getattr(self.instance.parent, 'site', None) site = getattr(self.instance.device, 'site', None)
if site is not None: if site is not None:
# Add non-grouped site VLANs # Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans) site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs # Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site): for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(( vlan_choices.append((
'{} / {}'.format(group.site.name, group.name), '{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans] [(vlan.pk, vlan) for vlan in site_group_vlans]
)) ))
self.fields['vlans'].choices = vlan_choices self.fields['untagged_vlan'].choices = vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
def clean(self):
super().clean()
# Only untagged VLANs permitted on an access interface
if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
# 'tagged' is required if more than one VLAN is selected
if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
raise forms.ValidationError("Only one untagged VLAN may be selected.")
def save(self, *args, **kwargs):
if self.cleaned_data['tagged']:
for vlan in self.cleaned_data['vlans']:
self.instance.tagged_vlans.add(vlan)
else:
self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
return super().save(*args, **kwargs)
class InterfaceCreateForm(ComponentForm, forms.Form): class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
name_pattern = ExpandableNameField( name_pattern = ExpandableNameField(
label='Name' label='Name'
) )
@@ -2296,6 +2268,24 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
tags = TagField( tags = TagField(
required=False required=False
) )
untagged_vlan = forms.ModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tagged_vlans = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -2313,8 +2303,38 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
else: else:
self.fields['lag'].queryset = Interface.objects.none() self.fields['lag'].queryset = Interface.objects.none()
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): site = getattr(self.parent, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@@ -2358,10 +2378,28 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
untagged_vlan = forms.ModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
tagged_vlans = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
)
)
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'lag', 'mac_address', 'mtu', 'description', 'mode', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -2377,6 +2415,36 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
else: else:
self.fields['lag'].choices = [] self.fields['lag'].choices = []
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
if self.parent_obj is not None:
site = getattr(self.parent_obj, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
class InterfaceBulkRenameForm(BulkRenameForm): class InterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2 on 2019-07-17 20:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0073_interface_form_factor_to_type'),
]
operations = [
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
]

View File

@@ -31,18 +31,20 @@ class ComponentTemplateModel(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def log_change(self, user, request_id, action): def instantiate(self, device):
""" """
Log an ObjectChange including the parent DeviceType. Instantiate a new component on the specified Device.
""" """
ObjectChange( raise NotImplementedError()
user=user,
request_id=request_id, def to_objectchange(self, action):
return ObjectChange(
changed_object=self, changed_object=self,
related_object=self.device_type, object_repr=str(self),
action=action, action=action,
related_object=self.device_type,
object_data=serialize_object(self) object_data=serialize_object(self)
).save() )
class ComponentModel(models.Model): class ComponentModel(models.Model):
@@ -54,23 +56,21 @@ class ComponentModel(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def log_change(self, user, request_id, action): def to_objectchange(self, action):
""" # Annotate the parent Device/VM
Log an ObjectChange including the parent Device/VM.
"""
try: try:
parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
except ObjectDoesNotExist: except ObjectDoesNotExist:
# The parent device/VM has already been deleted # The parent device/VM has already been deleted
parent = None parent = None
ObjectChange(
user=user, return ObjectChange(
request_id=request_id,
changed_object=self, changed_object=self,
related_object=parent, object_repr=str(self),
action=action, action=action,
related_object=parent,
object_data=serialize_object(self) object_data=serialize_object(self)
).save() )
@property @property
def parent(self): def parent(self):
@@ -601,7 +601,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
# Update racked devices if the assigned Site has been changed. # Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id: if _site_id is not None and self.site_id != _site_id:
Device.objects.filter(rack=self).update(site_id=self.site.pk) devices = Device.objects.filter(rack=self)
for device in devices:
device.site = self.site
device.save()
def to_csv(self): def to_csv(self):
return ( return (
@@ -658,7 +661,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
# Add devices to rack units list # Add devices to rack units list
if self.pk: if self.pk:
for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\ for device in Device.objects.prefetch_related('device_type__manufacturer', 'device_role')\
.annotate(devicebay_count=Count('device_bays'))\ .annotate(devicebay_count=Count('device_bays'))\
.exclude(pk=exclude)\ .exclude(pk=exclude)\
.filter(rack=self, position__gt=0)\ .filter(rack=self, position__gt=0)\
@@ -691,7 +694,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
""" """
# Gather all devices which consume U space within the rack # Gather all devices which consume U space within the rack
devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton # Initialize the rack unit skeleton
units = list(range(1, self.u_height + 1)) units = list(range(1, self.u_height + 1))
@@ -1010,6 +1013,12 @@ class ConsolePortTemplate(ComponentTemplateModel):
def __str__(self): def __str__(self):
return self.name return self.name
def instantiate(self, device):
return ConsolePort(
device=device,
name=self.name
)
class ConsoleServerPortTemplate(ComponentTemplateModel): class ConsoleServerPortTemplate(ComponentTemplateModel):
""" """
@@ -1033,6 +1042,12 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
def __str__(self): def __str__(self):
return self.name return self.name
def instantiate(self, device):
return ConsoleServerPort(
device=device,
name=self.name
)
class PowerPortTemplate(ComponentTemplateModel): class PowerPortTemplate(ComponentTemplateModel):
""" """
@@ -1068,6 +1083,14 @@ class PowerPortTemplate(ComponentTemplateModel):
def __str__(self): def __str__(self):
return self.name return self.name
def instantiate(self, device):
return PowerPort(
device=device,
name=self.name,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw
)
class PowerOutletTemplate(ComponentTemplateModel): class PowerOutletTemplate(ComponentTemplateModel):
""" """
@@ -1112,6 +1135,18 @@ class PowerOutletTemplate(ComponentTemplateModel):
"Parent power port ({}) must belong to the same device type".format(self.power_port) "Parent power port ({}) must belong to the same device type".format(self.power_port)
) )
def instantiate(self, device):
if self.power_port:
power_port = PowerPort.objects.get(device=device, name=self.power_port.name)
else:
power_port = None
return PowerOutlet(
device=device,
name=self.name,
power_port=power_port,
feed_leg=self.feed_leg
)
class InterfaceTemplate(ComponentTemplateModel): class InterfaceTemplate(ComponentTemplateModel):
""" """
@@ -1159,6 +1194,14 @@ class InterfaceTemplate(ComponentTemplateModel):
""" """
self.type = value self.type = value
def instantiate(self, device):
return Interface(
device=device,
name=self.name,
type=self.type,
mgmt_only=self.mgmt_only
)
class FrontPortTemplate(ComponentTemplateModel): class FrontPortTemplate(ComponentTemplateModel):
""" """
@@ -1213,6 +1256,19 @@ class FrontPortTemplate(ComponentTemplateModel):
) )
) )
def instantiate(self, device):
if self.rear_port:
rear_port = RearPort.objects.get(device=device, name=self.rear_port.name)
else:
rear_port = None
return FrontPort(
device=device,
name=self.name,
type=self.type,
rear_port=rear_port,
rear_port_position=self.rear_port_position
)
class RearPortTemplate(ComponentTemplateModel): class RearPortTemplate(ComponentTemplateModel):
""" """
@@ -1243,6 +1299,14 @@ class RearPortTemplate(ComponentTemplateModel):
def __str__(self): def __str__(self):
return self.name return self.name
def instantiate(self, device):
return RearPort(
device=device,
name=self.name,
type=self.type,
positions=self.positions
)
class DeviceBayTemplate(ComponentTemplateModel): class DeviceBayTemplate(ComponentTemplateModel):
""" """
@@ -1266,6 +1330,12 @@ class DeviceBayTemplate(ComponentTemplateModel):
def __str__(self): def __str__(self):
return self.name return self.name
def instantiate(self, device):
return DeviceBay(
device=device,
name=self.name
)
# #
# Devices # Devices
@@ -1315,11 +1385,12 @@ class Platform(ChangeLoggedModel):
specifying a NAPALM driver. specifying a NAPALM driver.
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
unique=True unique=True,
max_length=100
) )
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
to='dcim.Manufacturer', to='dcim.Manufacturer',
@@ -1640,49 +1711,36 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# If this is a new Device, instantiate all of the related components per the DeviceType definition # If this is a new Device, instantiate all of the related components per the DeviceType definition
if is_new: if is_new:
ConsolePort.objects.bulk_create( ConsolePort.objects.bulk_create(
[ConsolePort(device=self, name=template.name) for template in [x.instantiate(self) for x in self.device_type.consoleport_templates.all()]
self.device_type.consoleport_templates.all()]
) )
ConsoleServerPort.objects.bulk_create( ConsoleServerPort.objects.bulk_create(
[ConsoleServerPort(device=self, name=template.name) for template in [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
self.device_type.consoleserverport_templates.all()]
) )
PowerPort.objects.bulk_create( PowerPort.objects.bulk_create(
[PowerPort(device=self, name=template.name) for template in [x.instantiate(self) for x in self.device_type.powerport_templates.all()]
self.device_type.powerport_templates.all()]
) )
PowerOutlet.objects.bulk_create( PowerOutlet.objects.bulk_create(
[PowerOutlet(device=self, name=template.name) for template in [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
self.device_type.poweroutlet_templates.all()]
) )
Interface.objects.bulk_create( Interface.objects.bulk_create(
[Interface(device=self, name=template.name, type=template.type, [x.instantiate(self) for x in self.device_type.interface_templates.all()]
mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] )
RearPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.rearport_templates.all()]
)
FrontPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.frontport_templates.all()]
) )
RearPort.objects.bulk_create([
RearPort(
device=self,
name=template.name,
type=template.type,
positions=template.positions
) for template in self.device_type.rearport_templates.all()
])
FrontPort.objects.bulk_create([
FrontPort(
device=self,
name=template.name,
type=template.type,
rear_port=RearPort.objects.get(device=self, name=template.rear_port.name),
rear_port_position=template.rear_port_position,
) for template in self.device_type.frontport_templates.all()
])
DeviceBay.objects.bulk_create( DeviceBay.objects.bulk_create(
[DeviceBay(device=self, name=template.name) for template in [x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
self.device_type.device_bay_templates.all()]
) )
# Update Site and Rack assignment for any child Devices # Update Site and Rack assignment for any child Devices
Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack) devices = Device.objects.filter(parent_bay__device=self)
for device in devices:
device.site = self.site
device.rack = self.rack
device.save()
def to_csv(self): def to_csv(self):
return ( return (
@@ -2264,27 +2322,20 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def log_change(self, user, request_id, action): def to_objectchange(self, action):
""" # Annotate the parent Device/VM
Include the connected Interface (if any).
"""
# It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve
# the component parent will raise DoesNotExist. For more discussion, see
# https://github.com/netbox-community/netbox/issues/2323
try: try:
parent_obj = self.device or self.virtual_machine parent_obj = self.device or self.virtual_machine
except ObjectDoesNotExist: except ObjectDoesNotExist:
parent_obj = None parent_obj = None
ObjectChange( return ObjectChange(
user=user,
request_id=request_id,
changed_object=self, changed_object=self,
related_object=parent_obj, object_repr=str(self),
action=action, action=action,
related_object=parent_obj,
object_data=serialize_object(self) object_data=serialize_object(self)
).save() )
# TODO: Remove in v2.7 # TODO: Remove in v2.7
@property @property

View File

@@ -10,7 +10,11 @@ def assign_virtualchassis_master(instance, created, **kwargs):
When a VirtualChassis is created, automatically assign its master device to the VC. When a VirtualChassis is created, automatically assign its master device to the VC.
""" """
if created: if created:
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=None) devices = Device.objects.filter(pk=instance.master.pk)
for device in devices:
device.virtual_chassis = instance
device.vc_position = None
device.save()
@receiver(pre_delete, sender=VirtualChassis) @receiver(pre_delete, sender=VirtualChassis)
@@ -18,7 +22,11 @@ def clear_virtualchassis_members(instance, **kwargs):
""" """
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members. When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
""" """
Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None) devices = Device.objects.filter(virtual_chassis=instance.pk)
for device in devices:
device.vc_position = None
device.vc_priority = None
device.save()
@receiver(post_save, sender=Cable) @receiver(post_save, sender=Cable)

View File

@@ -729,6 +729,7 @@ class PowerConnectionTable(BaseTable):
viewname='dcim:device', viewname='dcim:device',
accessor=Accessor('connected_endpoint.device'), accessor=Accessor('connected_endpoint.device'),
args=[Accessor('connected_endpoint.device.pk')], args=[Accessor('connected_endpoint.device.pk')],
order_by='_connected_poweroutlet__device',
verbose_name='PDU' verbose_name='PDU'
) )
outlet = tables.Column( outlet = tables.Column(

View File

@@ -1,6 +1,5 @@
from django.test import TestCase from django.test import TestCase
from dcim.constants import *
from dcim.models import * from dcim.models import *
@@ -152,6 +151,137 @@ class RackTestCase(TestCase):
self.assertTrue(pdu) self.assertTrue(pdu)
class DeviceTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.device_role = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
# Create DeviceType components
ConsolePortTemplate(
device_type=self.device_type,
name='Console Port 1'
).save()
ConsoleServerPortTemplate(
device_type=self.device_type,
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
device_type=self.device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
ppt.save()
PowerOutletTemplate(
device_type=self.device_type,
name='Power Outlet 1',
power_port=ppt,
feed_leg=POWERFEED_LEG_A
).save()
InterfaceTemplate(
device_type=self.device_type,
name='Interface 1',
type=IFACE_TYPE_1GE_FIXED,
mgmt_only=True
).save()
rpt = RearPortTemplate(
device_type=self.device_type,
name='Rear Port 1',
type=PORT_TYPE_8P8C,
positions=8
)
rpt.save()
FrontPortTemplate(
device_type=self.device_type,
name='Front Port 1',
type=PORT_TYPE_8P8C,
rear_port=rpt,
rear_port_position=2
).save()
DeviceBayTemplate(
device_type=self.device_type,
name='Device Bay 1'
).save()
def test_device_creation(self):
"""
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name='Test Device 1'
)
d.save()
ConsolePort.objects.get(
device=d,
name='Console Port 1'
)
ConsoleServerPort.objects.get(
device=d,
name='Console Server Port 1'
)
pp = PowerPort.objects.get(
device=d,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
PowerOutlet.objects.get(
device=d,
name='Power Outlet 1',
power_port=pp,
feed_leg=POWERFEED_LEG_A
)
Interface.objects.get(
device=d,
name='Interface 1',
type=IFACE_TYPE_1GE_FIXED,
mgmt_only=True
)
rp = RearPort.objects.get(
device=d,
name='Rear Port 1',
type=PORT_TYPE_8P8C,
positions=8
)
FrontPort.objects.get(
device=d,
name='Front Port 1',
type=PORT_TYPE_8P8C,
rear_port=rp,
rear_port_position=2
)
DeviceBay.objects.get(
device=d,
name='Device Bay 1'
)
class CableTestCase(TestCase): class CableTestCase(TestCase):
def setUp(self): def setUp(self):

View File

@@ -209,7 +209,6 @@ urlpatterns = [
path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'), path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),

View File

@@ -16,7 +16,8 @@ from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph, TopologyMap
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
@@ -185,7 +186,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class SiteListView(PermissionRequiredMixin, ObjectListView): class SiteListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_site' permission_required = 'dcim.view_site'
queryset = Site.objects.select_related('region', 'tenant') queryset = Site.objects.prefetch_related('region', 'tenant')
filter = filters.SiteFilter filter = filters.SiteFilter
filter_form = forms.SiteFilterForm filter_form = forms.SiteFilterForm
table = tables.SiteTable table = tables.SiteTable
@@ -197,7 +198,7 @@ class SiteView(PermissionRequiredMixin, View):
def get(self, request, slug): def get(self, request, slug):
site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug)
stats = { stats = {
'rack_count': Rack.objects.filter(site=site).count(), 'rack_count': Rack.objects.filter(site=site).count(),
'device_count': Device.objects.filter(site=site).count(), 'device_count': Device.objects.filter(site=site).count(),
@@ -246,7 +247,7 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_site' permission_required = 'dcim.change_site'
queryset = Site.objects.select_related('region', 'tenant') queryset = Site.objects.prefetch_related('region', 'tenant')
filter = filters.SiteFilter filter = filters.SiteFilter
table = tables.SiteTable table = tables.SiteTable
form = forms.SiteBulkEditForm form = forms.SiteBulkEditForm
@@ -255,7 +256,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_site' permission_required = 'dcim.delete_site'
queryset = Site.objects.select_related('region', 'tenant') queryset = Site.objects.prefetch_related('region', 'tenant')
filter = filters.SiteFilter filter = filters.SiteFilter
table = tables.SiteTable table = tables.SiteTable
default_return_url = 'dcim:site_list' default_return_url = 'dcim:site_list'
@@ -267,7 +268,7 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RackGroupListView(PermissionRequiredMixin, ObjectListView): class RackGroupListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackgroup' permission_required = 'dcim.view_rackgroup'
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
filter = filters.RackGroupFilter filter = filters.RackGroupFilter
filter_form = forms.RackGroupFilterForm filter_form = forms.RackGroupFilterForm
table = tables.RackGroupTable table = tables.RackGroupTable
@@ -294,7 +295,7 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup' permission_required = 'dcim.delete_rackgroup'
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
filter = filters.RackGroupFilter filter = filters.RackGroupFilter
table = tables.RackGroupTable table = tables.RackGroupTable
default_return_url = 'dcim:rackgroup_list' default_return_url = 'dcim:rackgroup_list'
@@ -342,10 +343,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RackListView(PermissionRequiredMixin, ObjectListView): class RackListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rack' permission_required = 'dcim.view_rack'
queryset = Rack.objects.select_related( queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role' 'site', 'group', 'tenant', 'role', 'devices__device_type'
).prefetch_related(
'devices__device_type'
).annotate( ).annotate(
device_count=Count('devices') device_count=Count('devices')
) )
@@ -363,11 +362,7 @@ class RackElevationListView(PermissionRequiredMixin, View):
def get(self, request): def get(self, request):
racks = Rack.objects.select_related( racks = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role', 'devices__device_type')
'site', 'group', 'tenant', 'role'
).prefetch_related(
'devices__device_type'
)
racks = filters.RackFilter(request.GET, racks).qs racks = filters.RackFilter(request.GET, racks).qs
total_count = racks.count() total_count = racks.count()
@@ -402,15 +397,18 @@ class RackView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True) \ nonracked_devices = Device.objects.filter(
.select_related('device_type__manufacturer') rack=rack,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
reservations = RackReservation.objects.filter(rack=rack) reservations = RackReservation.objects.filter(rack=rack)
power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel') power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
return render(request, 'dcim/rack.html', { return render(request, 'dcim/rack.html', {
'rack': rack, 'rack': rack,
@@ -451,7 +449,7 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackBulkEditView(PermissionRequiredMixin, BulkEditView): class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rack' permission_required = 'dcim.change_rack'
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filter = filters.RackFilter filter = filters.RackFilter
table = tables.RackTable table = tables.RackTable
form = forms.RackBulkEditForm form = forms.RackBulkEditForm
@@ -460,7 +458,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack' permission_required = 'dcim.delete_rack'
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filter = filters.RackFilter filter = filters.RackFilter
table = tables.RackTable table = tables.RackTable
default_return_url = 'dcim:rack_list' default_return_url = 'dcim:rack_list'
@@ -472,7 +470,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RackReservationListView(PermissionRequiredMixin, ObjectListView): class RackReservationListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackreservation' permission_required = 'dcim.view_rackreservation'
queryset = RackReservation.objects.select_related('rack__site') queryset = RackReservation.objects.prefetch_related('rack__site')
filter = filters.RackReservationFilter filter = filters.RackReservationFilter
filter_form = forms.RackReservationFilterForm filter_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
@@ -508,7 +506,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rackreservation' permission_required = 'dcim.change_rackreservation'
queryset = RackReservation.objects.select_related('rack', 'user') queryset = RackReservation.objects.prefetch_related('rack', 'user')
filter = filters.RackReservationFilter filter = filters.RackReservationFilter
table = tables.RackReservationTable table = tables.RackReservationTable
form = forms.RackReservationBulkEditForm form = forms.RackReservationBulkEditForm
@@ -517,7 +515,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation' permission_required = 'dcim.delete_rackreservation'
queryset = RackReservation.objects.select_related('rack', 'user') queryset = RackReservation.objects.prefetch_related('rack', 'user')
filter = filters.RackReservationFilter filter = filters.RackReservationFilter
table = tables.RackReservationTable table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list' default_return_url = 'dcim:rackreservation_list'
@@ -569,7 +567,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_devicetype' permission_required = 'dcim.view_devicetype'
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter filter = filters.DeviceTypeFilter
filter_form = forms.DeviceTypeFilterForm filter_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@@ -666,7 +664,7 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicetype' permission_required = 'dcim.change_devicetype'
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter filter = filters.DeviceTypeFilter
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
form = forms.DeviceTypeBulkEditForm form = forms.DeviceTypeBulkEditForm
@@ -675,7 +673,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype' permission_required = 'dcim.delete_devicetype'
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter filter = filters.DeviceTypeFilter
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
default_return_url = 'dcim:devicetype_list' default_return_url = 'dcim:devicetype_list'
@@ -907,7 +905,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class DeviceListView(PermissionRequiredMixin, ObjectListView): class DeviceListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_device' permission_required = 'dcim.view_device'
queryset = Device.objects.select_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
) )
filter = filters.DeviceFilter filter = filters.DeviceFilter
@@ -921,7 +919,7 @@ class DeviceView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
device = get_object_or_404(Device.objects.select_related( device = get_object_or_404(Device.objects.prefetch_related(
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
), pk=pk) ), pk=pk)
@@ -934,32 +932,31 @@ class DeviceView(PermissionRequiredMixin, View):
vc_members = [] vc_members = []
# Console ports # Console ports
console_ports = device.consoleports.select_related('connected_endpoint__device', 'cable') console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable')
# Console server ports # Console server ports
consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable')
# Power ports # Power ports
power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable') power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable')
# Power outlets # Power outlets
poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable', 'power_port') poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port')
# Interfaces # Interfaces
interfaces = device.vc_interfaces.select_related( interfaces = device.vc_interfaces.prefetch_related(
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable' 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
).prefetch_related(
'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
) )
# Front ports # Front ports
front_ports = device.frontports.select_related('rear_port', 'cable') front_ports = device.frontports.prefetch_related('rear_port', 'cable')
# Rear ports # Rear ports
rear_ports = device.rearports.select_related('cable') rear_ports = device.rearports.prefetch_related('cable')
# Device bays # Device bays
device_bays = device.device_bays.select_related('installed_device__device_type__manufacturer') device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer')
# Services # Services
services = device.services.all() services = device.services.all()
@@ -972,13 +969,10 @@ class DeviceView(PermissionRequiredMixin, View):
site=device.site, device_role=device.device_role site=device.site, device_role=device.device_role
).exclude( ).exclude(
pk=device.pk pk=device.pk
).select_related( ).prefetch_related(
'rack', 'device_type__manufacturer' 'rack', 'device_type__manufacturer'
)[:10] )[:10]
# Show graph button on interfaces only if at least one graph has been created.
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
return render(request, 'dcim/device.html', { return render(request, 'dcim/device.html', {
'device': device, 'device': device,
'console_ports': console_ports, 'console_ports': console_ports,
@@ -993,7 +987,8 @@ class DeviceView(PermissionRequiredMixin, View):
'secrets': secrets, 'secrets': secrets,
'vc_members': vc_members, 'vc_members': vc_members,
'related_devices': related_devices, 'related_devices': related_devices,
'show_graphs': show_graphs, 'show_graphs': Graph.objects.filter(type=GRAPH_TYPE_DEVICE).exists(),
'show_interface_graphs': Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists(),
}) })
@@ -1005,10 +1000,8 @@ class DeviceInventoryView(PermissionRequiredMixin, View):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
inventory_items = InventoryItem.objects.filter( inventory_items = InventoryItem.objects.filter(
device=device, parent=None device=device, parent=None
).select_related(
'manufacturer'
).prefetch_related( ).prefetch_related(
'child_items' 'manufacturer', 'child_items'
) )
return render(request, 'dcim/device_inventory.html', { return render(request, 'dcim/device_inventory.html', {
@@ -1037,7 +1030,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
interfaces = device.vc_interfaces.connectable().select_related( interfaces = device.vc_interfaces.connectable().prefetch_related(
'_connected_interface__device' '_connected_interface__device'
) )
@@ -1114,7 +1107,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device' permission_required = 'dcim.change_device'
queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filter = filters.DeviceFilter filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
form = forms.DeviceBulkEditForm form = forms.DeviceBulkEditForm
@@ -1123,7 +1116,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device' permission_required = 'dcim.delete_device'
queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filter = filters.DeviceFilter filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@@ -1310,7 +1303,7 @@ class InterfaceView(PermissionRequiredMixin, View):
# Get assigned IP addresses # Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable( ipaddress_table = InterfaceIPAddressTable(
data=interface.ip_addresses.select_related('vrf', 'tenant'), data=interface.ip_addresses.prefetch_related('vrf', 'tenant'),
orderable=False orderable=False
) )
@@ -1319,7 +1312,7 @@ class InterfaceView(PermissionRequiredMixin, View):
if interface.untagged_vlan is not None: if interface.untagged_vlan is not None:
vlans.append(interface.untagged_vlan) vlans.append(interface.untagged_vlan)
vlans[0].tagged = False vlans[0].tagged = False
for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'): for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True vlan.tagged = True
vlans.append(vlan) vlans.append(vlan)
vlan_table = InterfaceVLANTable( vlan_table = InterfaceVLANTable(
@@ -1354,12 +1347,6 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
template_name = 'dcim/interface_edit.html' template_name = 'dcim/interface_edit.html'
class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface'
model = Interface
model_form = forms.InterfaceAssignVLANsForm
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interface' permission_required = 'dcim.delete_interface'
model = Interface model = Interface
@@ -1842,7 +1829,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
queryset = ConsolePort.objects.select_related( queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device' 'device', 'connected_endpoint__device'
).filter( ).filter(
connected_endpoint__isnull=False connected_endpoint__isnull=False
@@ -1873,7 +1860,7 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
queryset = PowerPort.objects.select_related( queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device' 'device', '_connected_poweroutlet__device'
).filter( ).filter(
_connected_poweroutlet__isnull=False _connected_poweroutlet__isnull=False
@@ -1904,7 +1891,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_interface' permission_required = 'dcim.view_interface'
queryset = Interface.objects.select_related( queryset = Interface.objects.prefetch_related(
'device', 'cable', '_connected_interface__device' 'device', 'cable', '_connected_interface__device'
).filter( ).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair # Avoid duplicate connections by only selecting the lower PK in a connected pair
@@ -1947,7 +1934,7 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
class InventoryItemListView(PermissionRequiredMixin, ObjectListView): class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_inventoryitem' permission_required = 'dcim.view_inventoryitem'
queryset = InventoryItem.objects.select_related('device', 'manufacturer') queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
filter = filters.InventoryItemFilter filter = filters.InventoryItemFilter
filter_form = forms.InventoryItemFilterForm filter_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
@@ -1982,7 +1969,7 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_inventoryitem' permission_required = 'dcim.change_inventoryitem'
queryset = InventoryItem.objects.select_related('device', 'manufacturer') queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
filter = filters.InventoryItemFilter filter = filters.InventoryItemFilter
table = tables.InventoryItemTable table = tables.InventoryItemTable
form = forms.InventoryItemBulkEditForm form = forms.InventoryItemBulkEditForm
@@ -1991,7 +1978,7 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_inventoryitem' permission_required = 'dcim.delete_inventoryitem'
queryset = InventoryItem.objects.select_related('device', 'manufacturer') queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
table = tables.InventoryItemTable table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html' template_name = 'dcim/inventoryitem_bulk_delete.html'
default_return_url = 'dcim:inventoryitem_list' default_return_url = 'dcim:inventoryitem_list'
@@ -2003,7 +1990,7 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_virtualchassis' permission_required = 'dcim.view_virtualchassis'
queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')) queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members'))
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filter = filters.VirtualChassisFilter filter = filters.VirtualChassisFilter
filter_form = forms.VirtualChassisFilterForm filter_form = forms.VirtualChassisFilterForm
@@ -2023,7 +2010,7 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
return redirect('dcim:device_list') return redirect('dcim:device_list')
device_queryset = Device.objects.filter( device_queryset = Device.objects.filter(
pk__in=pk_form.cleaned_data.get('pk') pk__in=pk_form.cleaned_data.get('pk')
).select_related('rack').order_by('vc_position') ).prefetch_related('rack').order_by('vc_position')
VCMemberFormSet = modelformset_factory( VCMemberFormSet = modelformset_factory(
model=Device, model=Device,
@@ -2077,7 +2064,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
formset=forms.BaseVCMemberFormSet, formset=forms.BaseVCMemberFormSet,
extra=0 extra=0
) )
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position') members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position')
vc_form = forms.VirtualChassisForm(instance=virtual_chassis) vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset vc_form.fields['master'].queryset = members_queryset
@@ -2098,7 +2085,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
formset=forms.BaseVCMemberFormSet, formset=forms.BaseVCMemberFormSet,
extra=0 extra=0
) )
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position') members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position')
vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis) vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset vc_form.fields['master'].queryset = members_queryset
@@ -2114,7 +2101,10 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
# Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on # Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on
# duplicate positions. Then save each member instance. # duplicate positions. Then save each member instance.
members = formset.save(commit=False) members = formset.save(commit=False)
Device.objects.filter(pk__in=[m.pk for m in members]).update(vc_position=None) devices = Device.objects.filter(pk__in=[m.pk for m in members])
for device in devices:
device.vc_position = None
device.save()
for member in members: for member in members:
member.save() member.save()
@@ -2215,11 +2205,12 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
if form.is_valid(): if form.is_valid():
Device.objects.filter(pk=device.pk).update( devices = Device.objects.filter(pk=device.pk)
virtual_chassis=None, for device in devices:
vc_position=None, device.virtual_chassis = None
vc_priority=None device.vc_position = None
) device.vc_priority = None
device.save()
msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis) msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
messages.success(request, msg) messages.success(request, msg)
@@ -2239,7 +2230,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
class PowerPanelListView(PermissionRequiredMixin, ObjectListView): class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_powerpanel' permission_required = 'dcim.view_powerpanel'
queryset = PowerPanel.objects.select_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
powerfeed_count=Count('powerfeeds') powerfeed_count=Count('powerfeeds')
@@ -2255,9 +2246,9 @@ class PowerPanelView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk) powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk)
powerfeed_table = tables.PowerFeedTable( powerfeed_table = tables.PowerFeedTable(
data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'), data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'),
orderable=False orderable=False
) )
powerfeed_table.exclude = ['power_panel'] powerfeed_table.exclude = ['power_panel']
@@ -2294,7 +2285,7 @@ class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerpanel' permission_required = 'dcim.delete_powerpanel'
queryset = PowerPanel.objects.select_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
rack_count=Count('powerfeeds') rack_count=Count('powerfeeds')
@@ -2310,7 +2301,7 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PowerFeedListView(PermissionRequiredMixin, ObjectListView): class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_powerfeed' permission_required = 'dcim.view_powerfeed'
queryset = PowerFeed.objects.select_related( queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack' 'power_panel', 'rack'
) )
filter = filters.PowerFeedFilter filter = filters.PowerFeedFilter
@@ -2324,7 +2315,7 @@ class PowerFeedView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk) powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk)
return render(request, 'dcim/powerfeed.html', { return render(request, 'dcim/powerfeed.html', {
'powerfeed': powerfeed, 'powerfeed': powerfeed,
@@ -2358,7 +2349,7 @@ class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerfeed' permission_required = 'dcim.change_powerfeed'
queryset = PowerFeed.objects.select_related('power_panel', 'rack') queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filter = filters.PowerFeedFilter filter = filters.PowerFeedFilter
table = tables.PowerFeedTable table = tables.PowerFeedTable
form = forms.PowerFeedBulkEditForm form = forms.PowerFeedBulkEditForm
@@ -2367,7 +2358,7 @@ class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerfeed' permission_required = 'dcim.delete_powerfeed'
queryset = PowerFeed.objects.select_related('power_panel', 'rack') queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filter = filters.PowerFeedFilter filter = filters.PowerFeedFilter
table = tables.PowerFeedTable table = tables.PowerFeedTable
default_return_url = 'dcim:powerfeed_list' default_return_url = 'dcim:powerfeed_list'

View File

@@ -120,7 +120,7 @@ class ExportTemplateViewSet(ModelViewSet):
# #
class TopologyMapViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet):
queryset = TopologyMap.objects.select_related('site') queryset = TopologyMap.objects.prefetch_related('site')
serializer_class = serializers.TopologyMapSerializer serializer_class = serializers.TopologyMapSerializer
filterset_class = filters.TopologyMapFilter filterset_class = filters.TopologyMapFilter
@@ -260,6 +260,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
""" """
Retrieve a list of recent changes. Retrieve a list of recent changes.
""" """
queryset = ObjectChange.objects.select_related('user') queryset = ObjectChange.objects.prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer serializer_class = serializers.ObjectChangeSerializer
filterset_class = filters.ObjectChangeFilter filterset_class = filters.ObjectChangeFilter

View File

@@ -88,10 +88,12 @@ BUTTON_CLASS_CHOICES = (
# Graph types # Graph types
GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_DEVICE = 150
GRAPH_TYPE_PROVIDER = 200 GRAPH_TYPE_PROVIDER = 200
GRAPH_TYPE_SITE = 300 GRAPH_TYPE_SITE = 300
GRAPH_TYPE_CHOICES = ( GRAPH_TYPE_CHOICES = (
(GRAPH_TYPE_INTERFACE, 'Interface'), (GRAPH_TYPE_INTERFACE, 'Interface'),
(GRAPH_TYPE_DEVICE, 'Device'),
(GRAPH_TYPE_PROVIDER, 'Provider'), (GRAPH_TYPE_PROVIDER, 'Provider'),
(GRAPH_TYPE_SITE, 'Site'), (GRAPH_TYPE_SITE, 'Site'),
) )

View File

@@ -207,6 +207,20 @@ class ConfigContextFilter(django_filters.FilterSet):
) )
#
# Filter for Local Config Context Data
#
class LocalConfigContextFilter(django_filters.FilterSet):
local_context_data = django_filters.BooleanFilter(
method='_local_context_data',
label='Has local config context data',
)
def _local_context_data(self, queryset, name, value):
return queryset.exclude(local_context_data__isnull=value)
class ObjectChangeFilter(django_filters.FilterSet): class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@@ -8,9 +8,11 @@ from taggit.forms import TagField
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.constants import COLOR_CHOICES
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
BOOLEAN_WITH_BLANK_CHOICES,
) )
from .constants import ( from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -111,8 +113,10 @@ class CustomFieldForm(forms.ModelForm):
# If editing an existing object, initialize values for all custom fields # If editing an existing object, initialize values for all custom fields
if self.instance.pk: if self.instance.pk:
existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\ existing_values = CustomFieldValue.objects.filter(
.select_related('field') obj_type=self.obj_type,
obj_id=self.instance.pk
).prefetch_related('field')
for cfv in existing_values: for cfv in existing_values:
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
@@ -120,9 +124,11 @@ class CustomFieldForm(forms.ModelForm):
for field_name in self.custom_fields: for field_name in self.custom_fields:
try: try:
cfv = CustomFieldValue.objects.select_related('field').get(field=self.fields[field_name].model, cfv = CustomFieldValue.objects.prefetch_related('field').get(
field=self.fields[field_name].model,
obj_type=self.obj_type, obj_type=self.obj_type,
obj_id=self.instance.pk) obj_id=self.instance.pk
)
except CustomFieldValue.DoesNotExist: except CustomFieldValue.DoesNotExist:
# Skip this field if none exists already and its value is empty # Skip this field if none exists already and its value is empty
if self.cleaned_data[field_name] in [None, '']: if self.cleaned_data[field_name] in [None, '']:
@@ -215,12 +221,29 @@ class TagFilterForm(BootstrapMixin, forms.Form):
) )
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.MultipleHiddenInput
)
color = forms.CharField(
max_length=6,
required=False,
widget=ColorSelect()
)
class Meta:
nullable_fields = []
# #
# Config contexts # Config contexts
# #
class ConfigContextForm(BootstrapMixin, forms.ModelForm): class ConfigContextForm(BootstrapMixin, forms.ModelForm):
data = JSONField() data = JSONField(
label=''
)
class Meta: class Meta:
model = ConfigContext model = ConfigContext
@@ -329,6 +352,20 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
) )
#
# Filter form for local config context data
#
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
label='Has local config context data',
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
# #
# Image attachments # Image attachments
# #
@@ -380,3 +417,34 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
widget=ContentTypeSelect(), widget=ContentTypeSelect(),
label='Object Type' label='Object Type'
) )
#
# Scripts
#
class ScriptForm(BootstrapMixin, forms.Form):
_commit = forms.BooleanField(
required=False,
initial=True,
label="Commit changes",
help_text="Commit changes to the database (uncheck for a dry-run)"
)
def __init__(self, vars, *args, **kwargs):
super().__init__(*args, **kwargs)
# Dynamically populate fields for variables
for name, var in vars.items():
self.fields[name] = var.as_field()
# Move _commit to the end of the form
self.fields.move_to_end('_commit', True)
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the _commit field).
"""
return bool(len(self.fields) > 1)

View File

@@ -5,6 +5,7 @@ import sys
from django import get_version from django import get_version
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
@@ -50,6 +51,9 @@ class Command(BaseCommand):
except KeyError: except KeyError:
pass pass
# Additional objects to include
namespace['User'] = User
# Load convenience commands # Load convenience commands
namespace.update({ namespace.update({
'lsmodels': self._lsmodels, 'lsmodels': self._lsmodels,

View File

@@ -9,30 +9,39 @@ from django.utils import timezone
from django.utils.functional import curry from django.utils.functional import curry
from django_prometheus.models import model_deletes, model_inserts, model_updates from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.webhooks import enqueue_webhooks
from .constants import ( from .constants import (
OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
) )
from .models import ObjectChange from .models import ObjectChange
from .signals import purge_changelog
from .webhooks import enqueue_webhooks
_thread_locals = threading.local() _thread_locals = threading.local()
def cache_changed_object(instance, **kwargs): def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated
"""
# Queue the object and a new ObjectChange for processing once the request completes
if hasattr(instance, 'to_objectchange'):
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
objectchange = instance.to_objectchange(action)
# Cache the object for further processing was the response has completed.
_thread_locals.changed_objects.append( _thread_locals.changed_objects.append(
(instance, action) (instance, objectchange)
) )
def _record_object_deleted(request, instance, **kwargs): def _handle_deleted_object(request, sender, instance, **kwargs):
"""
# Record that the object was deleted Fires when an object is deleted
if hasattr(instance, 'log_change'): """
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) # Record an Object Change
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks # Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE) enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
@@ -41,13 +50,20 @@ def _record_object_deleted(request, instance, **kwargs):
model_deletes.labels(instance._meta.model_name).inc() model_deletes.labels(instance._meta.model_name).inc()
def purge_objectchange_cache(sender, **kwargs):
"""
Delete any queued object changes waiting to be written.
"""
_thread_locals.changed_objects = []
class ObjectChangeMiddleware(object): class ObjectChangeMiddleware(object):
""" """
This middleware performs three functions in response to an object being created, updated, or deleted: This middleware performs three functions in response to an object being created, updated, or deleted:
1. Create an ObjectChange to reflect the modification to the object in the changelog. 1. Create an ObjectChange to reflect the modification to the object in the changelog.
2. Enqueue any relevant webhooks. 2. Enqueue any relevant webhooks.
3. Increment metric counter for the event type 3. Increment the metric counter for the event type.
The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
differently for each. Objects being saved are cached into thread-local storage for action *after* the response has differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
@@ -68,33 +84,42 @@ class ObjectChangeMiddleware(object):
request.id = uuid.uuid4() request.id = uuid.uuid4()
# Signals don't include the request context, so we're currying it into the post_delete function ahead of time. # Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
record_object_deleted = curry(_record_object_deleted, request) handle_deleted_object = curry(_handle_deleted_object, request)
# Connect our receivers to the post_save and post_delete signals. # Connect our receivers to the post_save and post_delete signals.
post_save.connect(cache_changed_object, dispatch_uid='record_object_saved') post_save.connect(handle_changed_object, dispatch_uid='cache_changed_object')
post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted') post_delete.connect(handle_deleted_object, dispatch_uid='cache_deleted_object')
# Provide a hook for purging the change cache
purge_changelog.connect(purge_objectchange_cache)
# Process the request # Process the request
response = self.get_response(request) response = self.get_response(request)
# If the change cache is empty, there's nothing more we need to do.
if not _thread_locals.changed_objects:
return response
# Create records for any cached objects that were created/updated. # Create records for any cached objects that were created/updated.
for obj, action in _thread_locals.changed_objects: for obj, objectchange in _thread_locals.changed_objects:
# Record the change # Record the change
if hasattr(obj, 'log_change'): objectchange.user = request.user
obj.log_change(request.user, request.id, action) objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks # Enqueue webhooks
enqueue_webhooks(obj, request.user, request.id, action) enqueue_webhooks(obj, request.user, request.id, objectchange.action)
# Increment metric counters # Increment metric counters
if action == OBJECTCHANGE_ACTION_CREATE: if objectchange.action == OBJECTCHANGE_ACTION_CREATE:
model_inserts.labels(obj._meta.model_name).inc() model_inserts.labels(obj._meta.model_name).inc()
elif action == OBJECTCHANGE_ACTION_UPDATE: elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
model_updates.labels(obj._meta.model_name).inc() model_updates.labels(obj._meta.model_name).inc()
# Housekeeping: 1% chance of clearing out expired ObjectChanges # Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: # one or more changes being logged.
if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
purged_count, _ = ObjectChange.objects.filter( purged_count, _ = ObjectChange.objects.filter(
time__lt=cutoff time__lt=cutoff

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2 on 2019-08-12 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0023_fix_tag_sequences'),
]
operations = [
migrations.CreateModel(
name='Script',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
'permissions': (('run_script', 'Can run script'),),
'managed': False,
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-08-28 14:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0024_scripts'),
]
operations = [
migrations.AlterField(
model_name='objectchange',
name='time',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View File

@@ -569,7 +569,7 @@ class TopologyMap(models.Model):
# Add each device to the graph # Add each device to the graph
devices = [] devices = []
for query in device_set.strip(';').split(';'): # Split regexes on semicolons for query in device_set.strip(';').split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role') devices += Device.objects.filter(name__regex=query).prefetch_related('device_role')
# Remove duplicate devices # Remove duplicate devices
devices = [d for d in devices if d.id not in seen] devices = [d for d in devices if d.id not in seen]
seen.update([d.id for d in devices]) seen.update([d.id for d in devices])
@@ -607,7 +607,7 @@ class TopologyMap(models.Model):
from dcim.models import Interface from dcim.models import Interface
# Add all interface connections to the graph # Add all interface connections to the graph
connected_interfaces = Interface.objects.select_related( connected_interfaces = Interface.objects.prefetch_related(
'_connected_interface__device' '_connected_interface__device'
).filter( ).filter(
Q(device__in=devices) | Q(_connected_interface__device__in=devices), Q(device__in=devices) | Q(_connected_interface__device__in=devices),
@@ -826,6 +826,21 @@ class ConfigContextModel(models.Model):
return data return data
#
# Custom scripts
#
class Script(models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
class Meta:
managed = False
permissions = (
('run_script', 'Can run script'),
)
# #
# Report results # Report results
# #
@@ -867,7 +882,8 @@ class ObjectChange(models.Model):
""" """
time = models.DateTimeField( time = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
editable=False editable=False,
db_index=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=User,
@@ -938,7 +954,9 @@ class ObjectChange(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings # Record the user's name and the object's representation as static strings
if not self.user_name:
self.user_name = self.user.username self.user_name = self.user.username
if not self.object_repr:
self.object_repr = str(self.changed_object) self.object_repr = str(self.changed_object)
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

360
netbox/extras/scripts.py Normal file
View File

@@ -0,0 +1,360 @@
from collections import OrderedDict
import inspect
import json
import os
import pkgutil
import time
import traceback
import yaml
from django import forms
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import transaction
from mptt.forms import TreeNodeChoiceField
from mptt.models import MPTTModel
from ipam.formfields import IPFormField
from utilities.exceptions import AbortTransaction
from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from .forms import ScriptForm
from .signals import purge_changelog
__all__ = [
'BaseScript',
'BooleanVar',
'FileVar',
'IntegerVar',
'IPNetworkVar',
'ObjectVar',
'Script',
'StringVar',
'TextVar',
]
#
# Script variables
#
class ScriptVariable:
"""
Base model for script variables
"""
form_field = forms.CharField
def __init__(self, label='', description='', default=None, required=True):
# Default field attributes
self.field_attrs = {
'help_text': description,
'required': required
}
if label:
self.field_attrs['label'] = label
if default:
self.field_attrs['initial'] = default
def as_field(self):
"""
Render the variable as a Django form field.
"""
form_field = self.form_field(**self.field_attrs)
if not isinstance(form_field.widget, forms.CheckboxInput):
form_field.widget.attrs['class'] = 'form-control'
return form_field
class StringVar(ScriptVariable):
"""
Character string representation. Can enforce minimum/maximum length and/or regex validation.
"""
def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum lengths
if min_length:
self.field_attrs['min_length'] = min_length
if max_length:
self.field_attrs['max_length'] = max_length
# Optional regular expression validation
if regex:
self.field_attrs['validators'] = [
RegexValidator(
regex=regex,
message='Invalid value. Must match regex: {}'.format(regex),
code='invalid'
)
]
class TextVar(ScriptVariable):
"""
Free-form text data. Renders as a <textarea>.
"""
form_field = forms.CharField
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['widget'] = forms.Textarea
class IntegerVar(ScriptVariable):
"""
Integer representation. Can enforce minimum/maximum values.
"""
form_field = forms.IntegerField
def __init__(self, min_value=None, max_value=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum values
if min_value:
self.field_attrs['min_value'] = min_value
if max_value:
self.field_attrs['max_value'] = max_value
class BooleanVar(ScriptVariable):
"""
Boolean representation (true/false). Renders as a checkbox.
"""
form_field = forms.BooleanField
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Boolean fields cannot be required
self.field_attrs['required'] = False
class ObjectVar(ScriptVariable):
"""
NetBox object representation. The provided QuerySet will determine the choices available.
"""
form_field = forms.ModelChoiceField
def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs)
# Queryset for field choices
self.field_attrs['queryset'] = queryset
# Update form field for MPTT (nested) objects
if issubclass(queryset.model, MPTTModel):
self.form_field = TreeNodeChoiceField
class FileVar(ScriptVariable):
"""
An uploaded file.
"""
form_field = forms.FileField
class IPNetworkVar(ScriptVariable):
"""
An IPv4 or IPv6 prefix.
"""
form_field = IPFormField
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['validators'] = list()
# Optional minimum/maximum prefix lengths
if min_prefix_length is not None:
self.field_attrs['validators'].append(
MinPrefixLengthValidator(min_prefix_length)
)
if max_prefix_length is not None:
self.field_attrs['validators'].append(
MaxPrefixLengthValidator(max_prefix_length)
)
#
# Scripts
#
class BaseScript:
"""
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
functionality for use in other subclasses.
"""
class Meta:
pass
def __init__(self):
# Initiate the log
self.log = []
# Grab some info about the script
self.filename = inspect.getfile(self.__class__)
self.source = inspect.getsource(self.__class__)
def __str__(self):
return getattr(self.Meta, 'name', self.__class__.__name__)
def _get_vars(self):
vars = OrderedDict()
# Infer order from Meta.field_order (Python 3.5 and lower)
field_order = getattr(self.Meta, 'field_order', [])
for name in field_order:
vars[name] = getattr(self, name)
# Default to order of declaration on class
for name, attr in self.__class__.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
return vars
def run(self, data):
raise NotImplementedError("The script must define a run() method.")
def as_form(self, data=None, files=None):
"""
Return a Django form suitable for populating the context data required to run this Script.
"""
vars = self._get_vars()
form = ScriptForm(vars, data, files)
return form
# Logging
def log_debug(self, message):
self.log.append((LOG_DEFAULT, message))
def log_success(self, message):
self.log.append((LOG_SUCCESS, message))
def log_info(self, message):
self.log.append((LOG_INFO, message))
def log_warning(self, message):
self.log.append((LOG_WARNING, message))
def log_failure(self, message):
self.log.append((LOG_FAILURE, message))
# Convenience functions
def load_yaml(self, filename):
"""
Return data from a YAML file
"""
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile)
return data
def load_json(self, filename):
"""
Return data from a JSON file
"""
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = json.load(datafile)
return data
class Script(BaseScript):
"""
Classes which inherit this model will appear in the list of available scripts.
"""
pass
#
# Functions
#
def is_script(obj):
"""
Returns True if the object is a Script.
"""
try:
return issubclass(obj, Script) and obj != Script
except TypeError:
return False
def is_variable(obj):
"""
Returns True if the object is a ScriptVariable.
"""
return isinstance(obj, ScriptVariable)
def run_script(script, data, files, commit=True):
"""
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
"""
output = None
start_time = None
end_time = None
# Add files to form data
for field_name, fileobj in files.items():
data[field_name] = fileobj
try:
with transaction.atomic():
start_time = time.time()
output = script.run(data)
end_time = time.time()
if not commit:
raise AbortTransaction()
except AbortTransaction:
pass
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
)
commit = False
finally:
if not commit:
# Delete all pending changelog entries
purge_changelog.send(Script)
script.log_info(
"Database changes have been reverted automatically."
)
# Calculate execution time
if end_time is not None:
execution_time = end_time - start_time
else:
execution_time = None
return output, execution_time
def get_scripts():
scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
module = importer.find_module(module_name).load_module(module_name)
if hasattr(module, 'name'):
module_name = module.name
module_scripts = OrderedDict()
for name, cls in inspect.getmembers(module, is_script):
module_scripts[name] = cls
scripts[module_name] = module_scripts
return scripts

View File

@@ -1,7 +1,12 @@
from cacheops.signals import cache_invalidated, cache_read from cacheops.signals import cache_invalidated, cache_read
from django.dispatch import Signal
from prometheus_client import Counter from prometheus_client import Counter
#
# Caching
#
cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits') cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses') cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations') cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
@@ -20,3 +25,10 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
cache_read.connect(cache_read_collector) cache_read.connect(cache_read_collector)
cache_invalidated.connect(cache_invalidated_collector) cache_invalidated.connect(cache_invalidated_collector)
#
# Change logging
#
purge_changelog = Signal()

View File

@@ -0,0 +1,37 @@
from django import template
from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
register = template.Library()
@register.inclusion_tag('extras/templatetags/log_level.html')
def log_level(level):
"""
Display a label indicating a syslog severity (e.g. info, warning, etc.).
"""
levels = {
LOG_DEFAULT: {
'name': 'Default',
'class': 'default'
},
LOG_SUCCESS: {
'name': 'Success',
'class': 'success',
},
LOG_INFO: {
'name': 'Info',
'class': 'info'
},
LOG_WARNING: {
'name': 'Warning',
'class': 'warning'
},
LOG_FAILURE: {
'name': 'Failure',
'class': 'danger'
}
}
return levels[level]

View File

@@ -0,0 +1,72 @@
from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_DELETE
from extras.models import ObjectChange
from utilities.testing import APITestCase
class ChangeLogTest(APITestCase):
def test_create_object(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ObjectChange.objects.count(), 1)
oc = ObjectChange.objects.first()
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ObjectChange.objects.count(), 1)
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.name, data['name'])
oc = ObjectChange.objects.first()
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
def test_delete_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Site.objects.count(), 0)
oc = ObjectChange.objects.first()
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)

View File

@@ -0,0 +1,157 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPNetwork
from dcim.models import DeviceRole
from extras.scripts import *
class ScriptVariablesTest(TestCase):
def test_stringvar(self):
class TestScript(Script):
var1 = StringVar(
min_length=3,
max_length=3,
regex=r'[a-z]+'
)
# Validate min_length enforcement
data = {'var1': 'xx'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate max_length enforcement
data = {'var1': 'xxxx'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate regex enforcement
data = {'var1': 'ABC'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': 'abc'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], data['var1'])
def test_textvar(self):
class TestScript(Script):
var1 = TextVar()
# Validate valid data
data = {'var1': 'This is a test string'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], data['var1'])
def test_integervar(self):
class TestScript(Script):
var1 = IntegerVar(
min_value=5,
max_value=10
)
# Validate min_value enforcement
data = {'var1': 4}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate max_value enforcement
data = {'var1': 11}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': 7}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], data['var1'])
def test_booleanvar(self):
class TestScript(Script):
var1 = BooleanVar()
# Validate True
data = {'var1': True}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], True)
# Validate False
data = {'var1': False}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], False)
def test_objectvar(self):
class TestScript(Script):
var1 = ObjectVar(
queryset=DeviceRole.objects.all()
)
# Populate some objects
for i in range(1, 6):
DeviceRole(
name='Device Role {}'.format(i),
slug='device-role-{}'.format(i)
).save()
# Validate valid data
data = {'var1': DeviceRole.objects.first().pk}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'].pk, data['var1'])
def test_filevar(self):
class TestScript(Script):
var1 = FileVar()
# Dummy file
testfile = SimpleUploadedFile(
name='test_file.txt',
content=b'This is a dummy file for testing'
)
# Validate valid data
file_data = {'var1': testfile}
form = TestScript().as_form(None, file_data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], testfile)
def test_ipnetworkvar(self):
class TestScript(Script):
var1 = IPNetworkVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))

View File

@@ -35,9 +35,9 @@ class TaggedItemTest(APITestCase):
site = Site.objects.create( site = Site.objects.create(
name='Test Site', name='Test Site',
slug='test-site', slug='test-site'
tags=['Foo', 'Bar', 'Baz']
) )
site.tags.add('Foo', 'Bar', 'Baz')
data = { data = {
'tags': ['Foo', 'Bar', 'New Tag'] 'tags': ['Foo', 'Bar', 'New Tag']

View File

@@ -6,6 +6,7 @@ from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import Site
from extras.constants import OBJECTCHANGE_ACTION_UPDATE
from extras.models import ConfigContext, ObjectChange, Tag from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import create_test_user from utilities.testing import create_test_user
@@ -82,11 +83,10 @@ class ObjectChangeTestCase(TestCase):
# Create three ObjectChanges # Create three ObjectChanges
for i in range(1, 4): for i in range(1, 4):
site.log_change( oc = site.to_objectchange(action=OBJECTCHANGE_ACTION_UPDATE)
user=user, oc.user = user
request_id=uuid.uuid4(), oc.request_id = uuid.uuid4()
action=2 oc.save()
)
def test_objectchange_list(self): def test_objectchange_list(self):

View File

@@ -9,6 +9,7 @@ urlpatterns = [
# Tags # Tags
path(r'tags/', views.TagListView.as_view(), name='tag_list'), path(r'tags/', views.TagListView.as_view(), name='tag_list'),
path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'), path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'), path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
@@ -28,13 +29,17 @@ urlpatterns = [
path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Change logging
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
# Reports # Reports
path(r'reports/', views.ReportListView.as_view(), name='report_list'), path(r'reports/', views.ReportListView.as_view(), name='report_list'),
path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'), path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'), path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
# Change logging # Scripts
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'), path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
] ]

View File

@@ -4,7 +4,7 @@ from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q from django.db.models import Count, Q
from django.http import Http404 from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
@@ -13,13 +13,10 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters from . import filters, forms
from .forms import (
ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
TagFilterForm, TagForm,
)
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .scripts import get_scripts, run_script
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
@@ -35,7 +32,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
'name' 'name'
) )
filter = filters.TagFilter filter = filters.TagFilter
filter_form = TagFilterForm filter_form = forms.TagFilterForm
table = TagTable table = TagTable
template_name = 'extras/tag_list.html' template_name = 'extras/tag_list.html'
@@ -47,10 +44,8 @@ class TagView(View):
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(Tag, slug=slug)
tagged_items = TaggedItem.objects.filter( tagged_items = TaggedItem.objects.filter(
tag=tag tag=tag
).select_related(
'content_type'
).prefetch_related( ).prefetch_related(
'content_object' 'content_type', 'content_object'
) )
# Generate a table of all items tagged with this Tag # Generate a table of all items tagged with this Tag
@@ -71,7 +66,7 @@ class TagView(View):
class TagEditView(PermissionRequiredMixin, ObjectEditView): class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_tag' permission_required = 'extras.change_tag'
model = Tag model = Tag
model_form = TagForm model_form = forms.TagForm
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html' template_name = 'extras/tag_edit.html'
@@ -82,6 +77,19 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_tag'
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
)
# filter = filters.ProviderFilter
table = TagTable
form = forms.TagBulkEditForm
default_return_url = 'circuits:provider_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'extras.delete_tag' permission_required = 'extras.delete_tag'
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
@@ -101,7 +109,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_configcontext' permission_required = 'extras.view_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm filter_form = forms.ConfigContextFilterForm
table = ConfigContextTable table = ConfigContextTable
template_name = 'extras/configcontext_list.html' template_name = 'extras/configcontext_list.html'
@@ -121,7 +129,7 @@ class ConfigContextView(PermissionRequiredMixin, View):
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.add_configcontext' permission_required = 'extras.add_configcontext'
model = ConfigContext model = ConfigContext
model_form = ConfigContextForm model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html' template_name = 'extras/configcontext_edit.html'
@@ -135,7 +143,7 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter filter = filters.ConfigContextFilter
table = ConfigContextTable table = ConfigContextTable
form = ConfigContextBulkEditForm form = forms.ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
@@ -178,9 +186,9 @@ class ObjectConfigContextView(View):
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_objectchange' permission_required = 'extras.view_objectchange'
queryset = ObjectChange.objects.select_related('user', 'changed_object_type') queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filter = filters.ObjectChangeFilter filter = filters.ObjectChangeFilter
filter_form = ObjectChangeFilterForm filter_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable table = ObjectChangeTable
template_name = 'extras/objectchange_list.html' template_name = 'extras/objectchange_list.html'
@@ -217,7 +225,7 @@ class ObjectChangeLogView(View):
# Gather all changes for this object (and its related objects) # Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model) content_type = ContentType.objects.get_for_model(model)
objectchanges = ObjectChange.objects.select_related( objectchanges = ObjectChange.objects.prefetch_related(
'user', 'changed_object_type' 'user', 'changed_object_type'
).filter( ).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(changed_object_type=content_type, changed_object_id=obj.pk) |
@@ -259,7 +267,7 @@ class ObjectChangeLogView(View):
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_imageattachment' permission_required = 'extras.change_imageattachment'
model = ImageAttachment model = ImageAttachment
model_form = ImageAttachmentForm model_form = forms.ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs): def alter_obj(self, imageattachment, request, args, kwargs):
if not imageattachment.pk: if not imageattachment.pk:
@@ -355,3 +363,62 @@ class ReportRunView(PermissionRequiredMixin, View):
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
return redirect('extras:report', name=report.full_name) return redirect('extras:report', name=report.full_name)
#
# Scripts
#
class ScriptListView(PermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get(self, request):
return render(request, 'extras/script_list.html', {
'scripts': get_scripts(),
})
class ScriptView(PermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def _get_script(self, module, name):
scripts = get_scripts()
try:
return scripts[module][name]()
except KeyError:
raise Http404
def get(self, request, module, name):
script = self._get_script(module, name)
form = script.as_form()
return render(request, 'extras/script.html', {
'module': module,
'script': script,
'form': form,
})
def post(self, request, module, name):
# Permissions check
if not request.user.has_perm('extras.run_script'):
return HttpResponseForbidden()
script = self._get_script(module, name)
form = script.as_form(request.POST, request.FILES)
output = None
execution_time = None
if form.is_valid():
commit = form.cleaned_data.pop('_commit')
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
return render(request, 'extras/script.html', {
'module': module,
'script': script,
'form': form,
'output': output,
'execution_time': execution_time,
})

View File

@@ -33,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
# #
class VRFViewSet(CustomFieldModelViewSet): class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.select_related('tenant').prefetch_related('tags').annotate( queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate(
ipaddress_count=get_subquery(IPAddress, 'vrf'), ipaddress_count=get_subquery(IPAddress, 'vrf'),
prefix_count=get_subquery(Prefix, 'vrf') prefix_count=get_subquery(Prefix, 'vrf')
) )
@@ -58,7 +58,7 @@ class RIRViewSet(ModelViewSet):
# #
class AggregateViewSet(CustomFieldModelViewSet): class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.select_related('rir').prefetch_related('tags') queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
serializer_class = serializers.AggregateSerializer serializer_class = serializers.AggregateSerializer
filterset_class = filters.AggregateFilter filterset_class = filters.AggregateFilter
@@ -81,11 +81,7 @@ class RoleViewSet(ModelViewSet):
# #
class PrefixViewSet(CustomFieldModelViewSet): class PrefixViewSet(CustomFieldModelViewSet):
queryset = Prefix.objects.select_related( queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags')
'site', 'vrf__tenant', 'tenant', 'vlan', 'role'
).prefetch_related(
'tags'
)
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
filterset_class = filters.PrefixFilter filterset_class = filters.PrefixFilter
@@ -263,9 +259,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
# #
class IPAddressViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine' 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
).prefetch_related(
'nat_outside', 'tags', 'nat_outside', 'tags',
) )
serializer_class = serializers.IPAddressSerializer serializer_class = serializers.IPAddressSerializer
@@ -277,7 +272,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
# #
class VLANGroupViewSet(ModelViewSet): class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.select_related('site').annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans') vlan_count=Count('vlans')
) )
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
@@ -289,10 +284,8 @@ class VLANGroupViewSet(ModelViewSet):
# #
class VLANViewSet(CustomFieldModelViewSet): class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.select_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role' 'site', 'group', 'tenant', 'role', 'tags'
).prefetch_related(
'tags'
).annotate( ).annotate(
prefix_count=get_subquery(Prefix, 'role') prefix_count=get_subquery(Prefix, 'role')
) )
@@ -305,6 +298,6 @@ class VLANViewSet(CustomFieldModelViewSet):
# #
class ServiceViewSet(ModelViewSet): class ServiceViewSet(ModelViewSet):
queryset = Service.objects.select_related('device').prefetch_related('tags') queryset = Service.objects.prefetch_related('device').prefetch_related('tags')
serializer_class = serializers.ServiceSerializer serializer_class = serializers.ServiceSerializer
filterset_class = filters.ServiceFilter filterset_class = filters.ServiceFilter

View File

@@ -360,7 +360,7 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
try: try:
device = Device.objects.select_related('device_type').get(**{name: value}) device = Device.objects.prefetch_related('device_type').get(**{name: value})
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
return queryset.filter(interface_id__in=vc_interface_ids) return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:

View File

@@ -647,26 +647,20 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def log_change(self, user, request_id, action): def to_objectchange(self, action):
""" # Annotate the assigned Interface (if any)
Include the connected Interface (if any).
"""
# It's possible that an IPAddress can be deleted _after_ its parent Interface, in which case trying to resolve
# the interface will raise DoesNotExist.
try: try:
parent_obj = self.interface parent_obj = self.interface
except ObjectDoesNotExist: except ObjectDoesNotExist:
parent_obj = None parent_obj = None
ObjectChange( return ObjectChange(
user=user,
request_id=request_id,
changed_object=self, changed_object=self,
related_object=parent_obj, object_repr=str(self),
action=action, action=action,
related_object=parent_obj,
object_data=serialize_object(self) object_data=serialize_object(self)
).save() )
def to_csv(self): def to_csv(self):

View File

@@ -115,7 +115,7 @@ def add_available_vlans(vlan_group, vlans):
class VRFListView(PermissionRequiredMixin, ObjectListView): class VRFListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_vrf' permission_required = 'ipam.view_vrf'
queryset = VRF.objects.select_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filter = filters.VRFFilter filter = filters.VRFFilter
filter_form = forms.VRFFilterForm filter_form = forms.VRFFilterForm
table = tables.VRFTable table = tables.VRFTable
@@ -163,7 +163,7 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf' permission_required = 'ipam.change_vrf'
queryset = VRF.objects.select_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filter = filters.VRFFilter filter = filters.VRFFilter
table = tables.VRFTable table = tables.VRFTable
form = forms.VRFBulkEditForm form = forms.VRFBulkEditForm
@@ -172,7 +172,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf' permission_required = 'ipam.delete_vrf'
queryset = VRF.objects.select_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filter = filters.VRFFilter filter = filters.VRFFilter
table = tables.VRFTable table = tables.VRFTable
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
@@ -291,7 +291,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class AggregateListView(PermissionRequiredMixin, ObjectListView): class AggregateListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_aggregate' permission_required = 'ipam.view_aggregate'
queryset = Aggregate.objects.select_related('rir').extra(select={ queryset = Aggregate.objects.prefetch_related('rir').extra(select={
'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', 'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
}) })
filter = filters.AggregateFilter filter = filters.AggregateFilter
@@ -326,7 +326,7 @@ class AggregateView(PermissionRequiredMixin, View):
# Find all child prefixes contained by this aggregate # Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter( child_prefixes = Prefix.objects.filter(
prefix__net_contained_or_equal=str(aggregate.prefix) prefix__net_contained_or_equal=str(aggregate.prefix)
).select_related( ).prefetch_related(
'site', 'role' 'site', 'role'
).annotate_depth( ).annotate_depth(
limit=0 limit=0
@@ -384,7 +384,7 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate' permission_required = 'ipam.change_aggregate'
queryset = Aggregate.objects.select_related('rir') queryset = Aggregate.objects.prefetch_related('rir')
filter = filters.AggregateFilter filter = filters.AggregateFilter
table = tables.AggregateTable table = tables.AggregateTable
form = forms.AggregateBulkEditForm form = forms.AggregateBulkEditForm
@@ -393,7 +393,7 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate' permission_required = 'ipam.delete_aggregate'
queryset = Aggregate.objects.select_related('rir') queryset = Aggregate.objects.prefetch_related('rir')
filter = filters.AggregateFilter filter = filters.AggregateFilter
table = tables.AggregateTable table = tables.AggregateTable
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
@@ -441,7 +441,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PrefixListView(PermissionRequiredMixin, ObjectListView): class PrefixListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_prefix' permission_required = 'ipam.view_prefix'
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filter = filters.PrefixFilter filter = filters.PrefixFilter
filter_form = forms.PrefixFilterForm filter_form = forms.PrefixFilterForm
table = tables.PrefixDetailTable table = tables.PrefixDetailTable
@@ -458,7 +458,7 @@ class PrefixView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.select_related( prefix = get_object_or_404(Prefix.objects.prefetch_related(
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk) ), pk=pk)
@@ -472,7 +472,7 @@ class PrefixView(PermissionRequiredMixin, View):
Q(vrf=prefix.vrf) | Q(vrf__isnull=True) Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
).filter( ).filter(
prefix__net_contains=str(prefix.prefix) prefix__net_contains=str(prefix.prefix)
).select_related( ).prefetch_related(
'site', 'role' 'site', 'role'
).annotate_depth() ).annotate_depth()
parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
@@ -483,7 +483,7 @@ class PrefixView(PermissionRequiredMixin, View):
vrf=prefix.vrf, prefix=str(prefix.prefix) vrf=prefix.vrf, prefix=str(prefix.prefix)
).exclude( ).exclude(
pk=prefix.pk pk=prefix.pk
).select_related( ).prefetch_related(
'site', 'role' 'site', 'role'
) )
duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False) duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
@@ -505,7 +505,7 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk) prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Child prefixes table # Child prefixes table
child_prefixes = prefix.get_child_prefixes().select_related( child_prefixes = prefix.get_child_prefixes().prefetch_related(
'site', 'vlan', 'role', 'site', 'vlan', 'role',
).annotate_depth(limit=0) ).annotate_depth(limit=0)
@@ -548,7 +548,7 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk) prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix # Find all IPAddresses belonging to this Prefix
ipaddresses = prefix.get_child_ips().select_related( ipaddresses = prefix.get_child_ips().prefetch_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
) )
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool) ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
@@ -608,7 +608,7 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix' permission_required = 'ipam.change_prefix'
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filter = filters.PrefixFilter filter = filters.PrefixFilter
table = tables.PrefixTable table = tables.PrefixTable
form = forms.PrefixBulkEditForm form = forms.PrefixBulkEditForm
@@ -617,7 +617,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix' permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filter = filters.PrefixFilter filter = filters.PrefixFilter
table = tables.PrefixTable table = tables.PrefixTable
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
@@ -629,10 +629,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class IPAddressListView(PermissionRequiredMixin, ObjectListView): class IPAddressListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_ipaddress' permission_required = 'ipam.view_ipaddress'
queryset = IPAddress.objects.select_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside' 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
).prefetch_related(
'interface__device', 'interface__virtual_machine'
) )
filter = filters.IPAddressFilter filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm filter_form = forms.IPAddressFilterForm
@@ -645,12 +643,12 @@ class IPAddressView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk) ipaddress = get_object_or_404(IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), pk=pk)
# Parent prefixes table # Parent prefixes table
parent_prefixes = Prefix.objects.filter( parent_prefixes = Prefix.objects.filter(
vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
).select_related( ).prefetch_related(
'site', 'role' 'site', 'role'
) )
parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
@@ -661,10 +659,8 @@ class IPAddressView(PermissionRequiredMixin, View):
vrf=ipaddress.vrf, address=str(ipaddress.address) vrf=ipaddress.vrf, address=str(ipaddress.address)
).exclude( ).exclude(
pk=ipaddress.pk pk=ipaddress.pk
).select_related(
'nat_inside'
).prefetch_related( ).prefetch_related(
'interface__device' 'nat_inside', 'interface__device'
) )
# Exclude anycast IPs if this IP is anycast # Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPADDRESS_ROLE_ANYCAST: if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
@@ -742,7 +738,7 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
if form.is_valid(): if form.is_valid():
queryset = IPAddress.objects.select_related( queryset = IPAddress.objects.prefetch_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
).filter( ).filter(
vrf=form.cleaned_data['vrf'], vrf=form.cleaned_data['vrf'],
@@ -781,7 +777,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress' permission_required = 'ipam.change_ipaddress'
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filter = filters.IPAddressFilter filter = filters.IPAddressFilter
table = tables.IPAddressTable table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm form = forms.IPAddressBulkEditForm
@@ -790,7 +786,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress' permission_required = 'ipam.delete_ipaddress'
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filter = filters.IPAddressFilter filter = filters.IPAddressFilter
table = tables.IPAddressTable table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
@@ -802,7 +798,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class VLANGroupListView(PermissionRequiredMixin, ObjectListView): class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_vlangroup' permission_required = 'ipam.view_vlangroup'
queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filter = filters.VLANGroupFilter filter = filters.VLANGroupFilter
filter_form = forms.VLANGroupFilterForm filter_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
@@ -829,7 +825,7 @@ class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup' permission_required = 'ipam.delete_vlangroup'
queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filter = filters.VLANGroupFilter filter = filters.VLANGroupFilter
table = tables.VLANGroupTable table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
@@ -878,7 +874,7 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View):
class VLANListView(PermissionRequiredMixin, ObjectListView): class VLANListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_vlan' permission_required = 'ipam.view_vlan'
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
filter = filters.VLANFilter filter = filters.VLANFilter
filter_form = forms.VLANFilterForm filter_form = forms.VLANFilterForm
table = tables.VLANDetailTable table = tables.VLANDetailTable
@@ -890,10 +886,10 @@ class VLANView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.select_related( vlan = get_object_or_404(VLAN.objects.prefetch_related(
'site__region', 'tenant__group', 'role' 'site__region', 'tenant__group', 'role'
), pk=pk) ), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role')
prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
prefix_table.exclude = ('vlan',) prefix_table.exclude = ('vlan',)
@@ -909,7 +905,7 @@ class VLANMembersView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.all(), pk=pk) vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
members = vlan.get_members().select_related('device', 'virtual_machine') members = vlan.get_members().prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members) members_table = tables.VLANMemberTable(members)
@@ -953,7 +949,7 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan' permission_required = 'ipam.change_vlan'
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filter = filters.VLANFilter filter = filters.VLANFilter
table = tables.VLANTable table = tables.VLANTable
form = forms.VLANBulkEditForm form = forms.VLANBulkEditForm
@@ -962,7 +958,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan' permission_required = 'ipam.delete_vlan'
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filter = filters.VLANFilter filter = filters.VLANFilter
table = tables.VLANTable table = tables.VLANTable
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
@@ -974,7 +970,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ServiceListView(PermissionRequiredMixin, ObjectListView): class ServiceListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_service' permission_required = 'ipam.view_service'
queryset = Service.objects.select_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filter = filters.ServiceFilter filter = filters.ServiceFilter
filter_form = forms.ServiceFilterForm filter_form = forms.ServiceFilterForm
table = tables.ServiceTable table = tables.ServiceTable
@@ -1021,7 +1017,7 @@ class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_service' permission_required = 'ipam.change_service'
queryset = Service.objects.select_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filter = filters.ServiceFilter filter = filters.ServiceFilter
table = tables.ServiceTable table = tables.ServiceTable
form = forms.ServiceBulkEditForm form = forms.ServiceBulkEditForm
@@ -1030,7 +1026,7 @@ class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_service' permission_required = 'ipam.delete_service'
queryset = Service.objects.select_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filter = filters.ServiceFilter filter = filters.ServiceFilter
table = tables.ServiceTable table = tables.ServiceTable
default_return_url = 'ipam:service_list' default_return_url = 'ipam:service_list'

View File

@@ -37,7 +37,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
def authenticate_credentials(self, key): def authenticate_credentials(self, key):
model = self.get_model() model = self.get_model()
try: try:
token = model.objects.select_related('user').get(key=key) token = model.objects.prefetch_related('user').get(key=key)
except model.DoesNotExist: except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token") raise exceptions.AuthenticationFailed("Invalid token")

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.6.2' VERSION = '2.6.4'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@@ -85,6 +85,7 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')

View File

@@ -15,7 +15,6 @@ schema_view = get_schema_view(
default_version='v2', default_version='v2',
description="API to access NetBox", description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox", terms_of_service="https://github.com/netbox-community/netbox",
contact=openapi.Contact(email="netbox@digitalocean.com"),
license=openapi.License(name="Apache v2 License"), license=openapi.License(name="Apache v2 License"),
), ),
validators=['flex', 'ssv'], validators=['flex', 'ssv'],

View File

@@ -46,38 +46,38 @@ SEARCH_TYPES = OrderedDict((
'url': 'circuits:provider_list', 'url': 'circuits:provider_list',
}), }),
('circuit', { ('circuit', {
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), 'queryset': Circuit.objects.prefetch_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
'filter': CircuitFilter, 'filter': CircuitFilter,
'table': CircuitTable, 'table': CircuitTable,
'url': 'circuits:circuit_list', 'url': 'circuits:circuit_list',
}), }),
# DCIM # DCIM
('site', { ('site', {
'queryset': Site.objects.select_related('region', 'tenant'), 'queryset': Site.objects.prefetch_related('region', 'tenant'),
'filter': SiteFilter, 'filter': SiteFilter,
'table': SiteTable, 'table': SiteTable,
'url': 'dcim:site_list', 'url': 'dcim:site_list',
}), }),
('rack', { ('rack', {
'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), 'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filter': RackFilter, 'filter': RackFilter,
'table': RackTable, 'table': RackTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}), }),
('rackgroup', { ('rackgroup', {
'queryset': RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')), 'queryset': RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')),
'filter': RackGroupFilter, 'filter': RackGroupFilter,
'table': RackGroupTable, 'table': RackGroupTable,
'url': 'dcim:rackgroup_list', 'url': 'dcim:rackgroup_list',
}), }),
('devicetype', { ('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')), 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')),
'filter': DeviceTypeFilter, 'filter': DeviceTypeFilter,
'table': DeviceTypeTable, 'table': DeviceTypeTable,
'url': 'dcim:devicetype_list', 'url': 'dcim:devicetype_list',
}), }),
('device', { ('device', {
'queryset': Device.objects.select_related( 'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
), ),
'filter': DeviceFilter, 'filter': DeviceFilter,
@@ -85,7 +85,7 @@ SEARCH_TYPES = OrderedDict((
'url': 'dcim:device_list', 'url': 'dcim:device_list',
}), }),
('virtualchassis', { ('virtualchassis', {
'queryset': VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')), 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')),
'filter': VirtualChassisFilter, 'filter': VirtualChassisFilter,
'table': VirtualChassisTable, 'table': VirtualChassisTable,
'url': 'dcim:virtualchassis_list', 'url': 'dcim:virtualchassis_list',
@@ -104,58 +104,58 @@ SEARCH_TYPES = OrderedDict((
}), }),
# IPAM # IPAM
('vrf', { ('vrf', {
'queryset': VRF.objects.select_related('tenant'), 'queryset': VRF.objects.prefetch_related('tenant'),
'filter': VRFFilter, 'filter': VRFFilter,
'table': VRFTable, 'table': VRFTable,
'url': 'ipam:vrf_list', 'url': 'ipam:vrf_list',
}), }),
('aggregate', { ('aggregate', {
'queryset': Aggregate.objects.select_related('rir'), 'queryset': Aggregate.objects.prefetch_related('rir'),
'filter': AggregateFilter, 'filter': AggregateFilter,
'table': AggregateTable, 'table': AggregateTable,
'url': 'ipam:aggregate_list', 'url': 'ipam:aggregate_list',
}), }),
('prefix', { ('prefix', {
'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filter': PrefixFilter, 'filter': PrefixFilter,
'table': PrefixTable, 'table': PrefixTable,
'url': 'ipam:prefix_list', 'url': 'ipam:prefix_list',
}), }),
('ipaddress', { ('ipaddress', {
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'), 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
'filter': IPAddressFilter, 'filter': IPAddressFilter,
'table': IPAddressTable, 'table': IPAddressTable,
'url': 'ipam:ipaddress_list', 'url': 'ipam:ipaddress_list',
}), }),
('vlan', { ('vlan', {
'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filter': VLANFilter, 'filter': VLANFilter,
'table': VLANTable, 'table': VLANTable,
'url': 'ipam:vlan_list', 'url': 'ipam:vlan_list',
}), }),
# Secrets # Secrets
('secret', { ('secret', {
'queryset': Secret.objects.select_related('role', 'device'), 'queryset': Secret.objects.prefetch_related('role', 'device'),
'filter': SecretFilter, 'filter': SecretFilter,
'table': SecretTable, 'table': SecretTable,
'url': 'secrets:secret_list', 'url': 'secrets:secret_list',
}), }),
# Tenancy # Tenancy
('tenant', { ('tenant', {
'queryset': Tenant.objects.select_related('group'), 'queryset': Tenant.objects.prefetch_related('group'),
'filter': TenantFilter, 'filter': TenantFilter,
'table': TenantTable, 'table': TenantTable,
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}), }),
# Virtualization # Virtualization
('cluster', { ('cluster', {
'queryset': Cluster.objects.select_related('type', 'group'), 'queryset': Cluster.objects.prefetch_related('type', 'group'),
'filter': ClusterFilter, 'filter': ClusterFilter,
'table': ClusterTable, 'table': ClusterTable,
'url': 'virtualization:cluster_list', 'url': 'virtualization:cluster_list',
}), }),
('virtualmachine', { ('virtualmachine', {
'queryset': VirtualMachine.objects.select_related( 'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
), ),
'filter': VirtualMachineFilter, 'filter': VirtualMachineFilter,
@@ -224,7 +224,7 @@ class HomeView(View):
'stats': stats, 'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True), 'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'report_results': ReportResult.objects.order_by('-created')[:10], 'report_results': ReportResult.objects.order_by('-created')[:10],
'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50] 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50]
}) })

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

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

@@ -1,6 +1,6 @@
/*! /*!
* Bootstrap v3.3.7 (http://getbootstrap.com) * Bootstrap v3.4.1 (https://getbootstrap.com/)
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/ */
.btn-default, .btn-default,
@@ -9,9 +9,9 @@
.btn-info, .btn-info,
.btn-warning, .btn-warning,
.btn-danger { .btn-danger {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
} }
.btn-default:active, .btn-default:active,
.btn-primary:active, .btn-primary:active,
@@ -25,8 +25,8 @@
.btn-info.active, .btn-info.active,
.btn-warning.active, .btn-warning.active,
.btn-danger.active { .btn-danger.active {
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
} }
.btn-default.disabled, .btn-default.disabled,
.btn-primary.disabled, .btn-primary.disabled,
@@ -62,7 +62,6 @@ fieldset[disabled] .btn-danger {
background-image: none; background-image: none;
} }
.btn-default { .btn-default {
text-shadow: 0 1px 0 #fff;
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%); background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0)); background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
@@ -71,6 +70,7 @@ fieldset[disabled] .btn-danger {
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x; background-repeat: repeat-x;
border-color: #dbdbdb; border-color: #dbdbdb;
text-shadow: 0 1px 0 #fff;
border-color: #ccc; border-color: #ccc;
} }
.btn-default:hover, .btn-default:hover,
@@ -311,41 +311,41 @@ fieldset[disabled] .btn-danger.active {
} }
.thumbnail, .thumbnail,
.img-thumbnail { .img-thumbnail {
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
box-shadow: 0 1px 2px rgba(0, 0, 0, .075); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
} }
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:hover,
.dropdown-menu > li > a:focus { .dropdown-menu > li > a:focus {
background-color: #e8e8e8;
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
background-repeat: repeat-x; background-repeat: repeat-x;
background-color: #e8e8e8;
} }
.dropdown-menu > .active > a, .dropdown-menu > .active > a,
.dropdown-menu > .active > a:hover, .dropdown-menu > .active > a:hover,
.dropdown-menu > .active > a:focus { .dropdown-menu > .active > a:focus {
background-color: #2e6da4;
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
background-repeat: repeat-x; background-repeat: repeat-x;
background-color: #2e6da4;
} }
.navbar-default { .navbar-default {
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%); background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8)); background-image: -webkit-gradient(linear, left top, left bottom, from(#ffffff), to(#f8f8f8));
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x; background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
border-radius: 4px; border-radius: 4px;
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);
} }
.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a,
.navbar-default .navbar-nav > .active > a { .navbar-default .navbar-nav > .active > a {
@@ -355,12 +355,12 @@ fieldset[disabled] .btn-danger.active {
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
background-repeat: repeat-x; background-repeat: repeat-x;
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);
} }
.navbar-brand, .navbar-brand,
.navbar-nav > li > a { .navbar-nav > li > a {
text-shadow: 0 1px 0 rgba(255, 255, 255, .25); text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
} }
.navbar-inverse { .navbar-inverse {
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
@@ -368,8 +368,8 @@ fieldset[disabled] .btn-danger.active {
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222)); background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x; background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
border-radius: 4px; border-radius: 4px;
} }
.navbar-inverse .navbar-nav > .open > a, .navbar-inverse .navbar-nav > .open > a,
@@ -380,12 +380,12 @@ fieldset[disabled] .btn-danger.active {
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
background-repeat: repeat-x; background-repeat: repeat-x;
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);
} }
.navbar-inverse .navbar-brand, .navbar-inverse .navbar-brand,
.navbar-inverse .navbar-nav > li > a { .navbar-inverse .navbar-nav > li > a {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
} }
.navbar-static-top, .navbar-static-top,
.navbar-fixed-top, .navbar-fixed-top,
@@ -406,9 +406,9 @@ fieldset[disabled] .btn-danger.active {
} }
} }
.alert { .alert {
text-shadow: 0 1px 0 rgba(255, 255, 255, .2); text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);
} }
.alert-success { .alert-success {
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
@@ -495,14 +495,14 @@ fieldset[disabled] .btn-danger.active {
background-repeat: repeat-x; background-repeat: repeat-x;
} }
.progress-bar-striped { .progress-bar-striped {
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
} }
.list-group { .list-group {
border-radius: 4px; border-radius: 4px;
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
box-shadow: 0 1px 2px rgba(0, 0, 0, .075); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
} }
.list-group-item.active, .list-group-item.active,
.list-group-item.active:hover, .list-group-item.active:hover,
@@ -522,8 +522,8 @@ fieldset[disabled] .btn-danger.active {
text-shadow: none; text-shadow: none;
} }
.panel { .panel {
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
box-shadow: 0 1px 2px rgba(0, 0, 0, .05); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
} }
.panel-default > .panel-heading { .panel-default > .panel-heading {
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
@@ -581,7 +581,7 @@ fieldset[disabled] .btn-danger.active {
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
background-repeat: repeat-x; background-repeat: repeat-x;
border-color: #dcdcdc; border-color: #dcdcdc;
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);
} }
/*# sourceMappingURL=bootstrap-theme.css.map */ /*# sourceMappingURL=bootstrap-theme.css.map */

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
/*! /*!
* Bootstrap v3.3.7 (http://getbootstrap.com) * Bootstrap v3.4.1 (https://getbootstrap.com/)
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under the MIT license * Licensed under the MIT license
*/ */
@@ -17,10 +17,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: transition.js v3.3.7 * Bootstrap: transition.js v3.4.1
* http://getbootstrap.com/javascript/#transitions * https://getbootstrap.com/docs/3.4/javascript/#transitions
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -28,7 +28,7 @@ if (typeof jQuery === 'undefined') {
+function ($) { +function ($) {
'use strict'; 'use strict';
// CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) // CSS TRANSITION SUPPORT (Shoutout: https://modernizr.com/)
// ============================================================ // ============================================================
function transitionEnd() { function transitionEnd() {
@@ -50,7 +50,7 @@ if (typeof jQuery === 'undefined') {
return false // explicit for ie8 ( ._.) return false // explicit for ie8 ( ._.)
} }
// http://blog.alexmaccaw.com/css-transitions // https://blog.alexmaccaw.com/css-transitions
$.fn.emulateTransitionEnd = function (duration) { $.fn.emulateTransitionEnd = function (duration) {
var called = false var called = false
var $el = this var $el = this
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: alert.js v3.3.7 * Bootstrap: alert.js v3.4.1
* http://getbootstrap.com/javascript/#alerts * https://getbootstrap.com/docs/3.4/javascript/#alerts
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -96,7 +96,7 @@ if (typeof jQuery === 'undefined') {
$(el).on('click', dismiss, this.close) $(el).on('click', dismiss, this.close)
} }
Alert.VERSION = '3.3.7' Alert.VERSION = '3.4.1'
Alert.TRANSITION_DURATION = 150 Alert.TRANSITION_DURATION = 150
@@ -109,7 +109,8 @@ if (typeof jQuery === 'undefined') {
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
} }
var $parent = $(selector === '#' ? [] : selector) selector = selector === '#' ? [] : selector
var $parent = $(document).find(selector)
if (e) e.preventDefault() if (e) e.preventDefault()
@@ -172,10 +173,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: button.js v3.3.7 * Bootstrap: button.js v3.4.1
* http://getbootstrap.com/javascript/#buttons * https://getbootstrap.com/docs/3.4/javascript/#buttons
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -192,7 +193,7 @@ if (typeof jQuery === 'undefined') {
this.isLoading = false this.isLoading = false
} }
Button.VERSION = '3.3.7' Button.VERSION = '3.4.1'
Button.DEFAULTS = { Button.DEFAULTS = {
loadingText: 'loading...' loadingText: 'loading...'
@@ -298,10 +299,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: carousel.js v3.3.7 * Bootstrap: carousel.js v3.4.1
* http://getbootstrap.com/javascript/#carousel * https://getbootstrap.com/docs/3.4/javascript/#carousel
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -329,7 +330,7 @@ if (typeof jQuery === 'undefined') {
.on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) .on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
} }
Carousel.VERSION = '3.3.7' Carousel.VERSION = '3.4.1'
Carousel.TRANSITION_DURATION = 600 Carousel.TRANSITION_DURATION = 600
@@ -443,7 +444,9 @@ if (typeof jQuery === 'undefined') {
var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid"
if ($.support.transition && this.$element.hasClass('slide')) { if ($.support.transition && this.$element.hasClass('slide')) {
$next.addClass(type) $next.addClass(type)
if (typeof $next === 'object' && $next.length) {
$next[0].offsetWidth // force reflow $next[0].offsetWidth // force reflow
}
$active.addClass(direction) $active.addClass(direction)
$next.addClass(direction) $next.addClass(direction)
$active $active
@@ -505,10 +508,17 @@ if (typeof jQuery === 'undefined') {
// ================= // =================
var clickHandler = function (e) { var clickHandler = function (e) {
var href
var $this = $(this) var $this = $(this)
var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 var href = $this.attr('href')
if (href) {
href = href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
}
var target = $this.attr('data-target') || href
var $target = $(document).find(target)
if (!$target.hasClass('carousel')) return if (!$target.hasClass('carousel')) return
var options = $.extend({}, $target.data(), $this.data()) var options = $.extend({}, $target.data(), $this.data())
var slideIndex = $this.attr('data-slide-to') var slideIndex = $this.attr('data-slide-to')
if (slideIndex) options.interval = false if (slideIndex) options.interval = false
@@ -536,10 +546,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: collapse.js v3.3.7 * Bootstrap: collapse.js v3.4.1
* http://getbootstrap.com/javascript/#collapse * https://getbootstrap.com/docs/3.4/javascript/#collapse
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -567,7 +577,7 @@ if (typeof jQuery === 'undefined') {
if (this.options.toggle) this.toggle() if (this.options.toggle) this.toggle()
} }
Collapse.VERSION = '3.3.7' Collapse.VERSION = '3.4.1'
Collapse.TRANSITION_DURATION = 350 Collapse.TRANSITION_DURATION = 350
@@ -674,7 +684,7 @@ if (typeof jQuery === 'undefined') {
} }
Collapse.prototype.getParent = function () { Collapse.prototype.getParent = function () {
return $(this.options.parent) return $(document).find(this.options.parent)
.find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]')
.each($.proxy(function (i, element) { .each($.proxy(function (i, element) {
var $element = $(element) var $element = $(element)
@@ -697,7 +707,7 @@ if (typeof jQuery === 'undefined') {
var target = $trigger.attr('data-target') var target = $trigger.attr('data-target')
|| (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
return $(target) return $(document).find(target)
} }
@@ -749,10 +759,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: dropdown.js v3.3.7 * Bootstrap: dropdown.js v3.4.1
* http://getbootstrap.com/javascript/#dropdowns * https://getbootstrap.com/docs/3.4/javascript/#dropdowns
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -769,7 +779,7 @@ if (typeof jQuery === 'undefined') {
$(element).on('click.bs.dropdown', this.toggle) $(element).on('click.bs.dropdown', this.toggle)
} }
Dropdown.VERSION = '3.3.7' Dropdown.VERSION = '3.4.1'
function getParent($this) { function getParent($this) {
var selector = $this.attr('data-target') var selector = $this.attr('data-target')
@@ -779,7 +789,7 @@ if (typeof jQuery === 'undefined') {
selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
} }
var $parent = selector && $(selector) var $parent = selector !== '#' ? $(document).find(selector) : null
return $parent && $parent.length ? $parent : $this.parent() return $parent && $parent.length ? $parent : $this.parent()
} }
@@ -915,10 +925,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: modal.js v3.3.7 * Bootstrap: modal.js v3.4.1
* http://getbootstrap.com/javascript/#modals * https://getbootstrap.com/docs/3.4/javascript/#modals
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -939,6 +949,7 @@ if (typeof jQuery === 'undefined') {
this.originalBodyPad = null this.originalBodyPad = null
this.scrollbarWidth = 0 this.scrollbarWidth = 0
this.ignoreBackdropClick = false this.ignoreBackdropClick = false
this.fixedContent = '.navbar-fixed-top, .navbar-fixed-bottom'
if (this.options.remote) { if (this.options.remote) {
this.$element this.$element
@@ -949,7 +960,7 @@ if (typeof jQuery === 'undefined') {
} }
} }
Modal.VERSION = '3.3.7' Modal.VERSION = '3.4.1'
Modal.TRANSITION_DURATION = 300 Modal.TRANSITION_DURATION = 300
Modal.BACKDROP_TRANSITION_DURATION = 150 Modal.BACKDROP_TRANSITION_DURATION = 150
@@ -1185,11 +1196,26 @@ if (typeof jQuery === 'undefined') {
Modal.prototype.setScrollbar = function () { Modal.prototype.setScrollbar = function () {
var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10) var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10)
this.originalBodyPad = document.body.style.paddingRight || '' this.originalBodyPad = document.body.style.paddingRight || ''
if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth) var scrollbarWidth = this.scrollbarWidth
if (this.bodyIsOverflowing) {
this.$body.css('padding-right', bodyPad + scrollbarWidth)
$(this.fixedContent).each(function (index, element) {
var actualPadding = element.style.paddingRight
var calculatedPadding = $(element).css('padding-right')
$(element)
.data('padding-right', actualPadding)
.css('padding-right', parseFloat(calculatedPadding) + scrollbarWidth + 'px')
})
}
} }
Modal.prototype.resetScrollbar = function () { Modal.prototype.resetScrollbar = function () {
this.$body.css('padding-right', this.originalBodyPad) this.$body.css('padding-right', this.originalBodyPad)
$(this.fixedContent).each(function (index, element) {
var padding = $(element).data('padding-right')
$(element).removeData('padding-right')
element.style.paddingRight = padding ? padding : ''
})
} }
Modal.prototype.measureScrollbar = function () { // thx walsh Modal.prototype.measureScrollbar = function () { // thx walsh
@@ -1238,7 +1264,10 @@ if (typeof jQuery === 'undefined') {
$(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
var $this = $(this) var $this = $(this)
var href = $this.attr('href') var href = $this.attr('href')
var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 var target = $this.attr('data-target') ||
(href && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7
var $target = $(document).find(target)
var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
if ($this.is('a')) e.preventDefault() if ($this.is('a')) e.preventDefault()
@@ -1255,18 +1284,148 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: tooltip.js v3.3.7 * Bootstrap: tooltip.js v3.4.1
* http://getbootstrap.com/javascript/#tooltip * https://getbootstrap.com/docs/3.4/javascript/#tooltip
* Inspired by the original jQuery.tipsy by Jason Frame * Inspired by the original jQuery.tipsy by Jason Frame
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
+function ($) { +function ($) {
'use strict'; 'use strict';
var DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn']
var uriAttrs = [
'background',
'cite',
'href',
'itemtype',
'longdesc',
'poster',
'src',
'xlink:href'
]
var ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
var DefaultWhitelist = {
// Global attributes allowed on any supplied element below.
'*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],
a: ['target', 'href', 'title', 'rel'],
area: [],
b: [],
br: [],
col: [],
code: [],
div: [],
em: [],
hr: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
i: [],
img: ['src', 'alt', 'title', 'width', 'height'],
li: [],
ol: [],
p: [],
pre: [],
s: [],
small: [],
span: [],
sub: [],
sup: [],
strong: [],
u: [],
ul: []
}
/**
* A pattern that recognizes a commonly useful subset of URLs that are safe.
*
* Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts
*/
var SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi
/**
* A pattern that matches safe data URLs. Only matches image, video and audio types.
*
* Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts
*/
var DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
function allowedAttribute(attr, allowedAttributeList) {
var attrName = attr.nodeName.toLowerCase()
if ($.inArray(attrName, allowedAttributeList) !== -1) {
if ($.inArray(attrName, uriAttrs) !== -1) {
return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN))
}
return true
}
var regExp = $(allowedAttributeList).filter(function (index, value) {
return value instanceof RegExp
})
// Check if a regular expression validates the attribute.
for (var i = 0, l = regExp.length; i < l; i++) {
if (attrName.match(regExp[i])) {
return true
}
}
return false
}
function sanitizeHtml(unsafeHtml, whiteList, sanitizeFn) {
if (unsafeHtml.length === 0) {
return unsafeHtml
}
if (sanitizeFn && typeof sanitizeFn === 'function') {
return sanitizeFn(unsafeHtml)
}
// IE 8 and below don't support createHTMLDocument
if (!document.implementation || !document.implementation.createHTMLDocument) {
return unsafeHtml
}
var createdDocument = document.implementation.createHTMLDocument('sanitization')
createdDocument.body.innerHTML = unsafeHtml
var whitelistKeys = $.map(whiteList, function (el, i) { return i })
var elements = $(createdDocument.body).find('*')
for (var i = 0, len = elements.length; i < len; i++) {
var el = elements[i]
var elName = el.nodeName.toLowerCase()
if ($.inArray(elName, whitelistKeys) === -1) {
el.parentNode.removeChild(el)
continue
}
var attributeList = $.map(el.attributes, function (el) { return el })
var whitelistedAttributes = [].concat(whiteList['*'] || [], whiteList[elName] || [])
for (var j = 0, len2 = attributeList.length; j < len2; j++) {
if (!allowedAttribute(attributeList[j], whitelistedAttributes)) {
el.removeAttribute(attributeList[j].nodeName)
}
}
}
return createdDocument.body.innerHTML
}
// TOOLTIP PUBLIC CLASS DEFINITION // TOOLTIP PUBLIC CLASS DEFINITION
// =============================== // ===============================
@@ -1282,7 +1441,7 @@ if (typeof jQuery === 'undefined') {
this.init('tooltip', element, options) this.init('tooltip', element, options)
} }
Tooltip.VERSION = '3.3.7' Tooltip.VERSION = '3.4.1'
Tooltip.TRANSITION_DURATION = 150 Tooltip.TRANSITION_DURATION = 150
@@ -1299,7 +1458,10 @@ if (typeof jQuery === 'undefined') {
viewport: { viewport: {
selector: 'body', selector: 'body',
padding: 0 padding: 0
} },
sanitize : true,
sanitizeFn : null,
whiteList : DefaultWhitelist
} }
Tooltip.prototype.init = function (type, element, options) { Tooltip.prototype.init = function (type, element, options) {
@@ -1307,7 +1469,7 @@ if (typeof jQuery === 'undefined') {
this.type = type this.type = type
this.$element = $(element) this.$element = $(element)
this.options = this.getOptions(options) this.options = this.getOptions(options)
this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)) this.$viewport = this.options.viewport && $(document).find($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport))
this.inState = { click: false, hover: false, focus: false } this.inState = { click: false, hover: false, focus: false }
if (this.$element[0] instanceof document.constructor && !this.options.selector) { if (this.$element[0] instanceof document.constructor && !this.options.selector) {
@@ -1340,7 +1502,15 @@ if (typeof jQuery === 'undefined') {
} }
Tooltip.prototype.getOptions = function (options) { Tooltip.prototype.getOptions = function (options) {
options = $.extend({}, this.getDefaults(), this.$element.data(), options) var dataAttributes = this.$element.data()
for (var dataAttr in dataAttributes) {
if (dataAttributes.hasOwnProperty(dataAttr) && $.inArray(dataAttr, DISALLOWED_ATTRIBUTES) !== -1) {
delete dataAttributes[dataAttr]
}
}
options = $.extend({}, this.getDefaults(), dataAttributes, options)
if (options.delay && typeof options.delay == 'number') { if (options.delay && typeof options.delay == 'number') {
options.delay = { options.delay = {
@@ -1349,6 +1519,10 @@ if (typeof jQuery === 'undefined') {
} }
} }
if (options.sanitize) {
options.template = sanitizeHtml(options.template, options.whiteList, options.sanitizeFn)
}
return options return options
} }
@@ -1460,7 +1634,7 @@ if (typeof jQuery === 'undefined') {
.addClass(placement) .addClass(placement)
.data('bs.' + this.type, this) .data('bs.' + this.type, this)
this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) this.options.container ? $tip.appendTo($(document).find(this.options.container)) : $tip.insertAfter(this.$element)
this.$element.trigger('inserted.bs.' + this.type) this.$element.trigger('inserted.bs.' + this.type)
var pos = this.getPosition() var pos = this.getPosition()
@@ -1562,7 +1736,16 @@ if (typeof jQuery === 'undefined') {
var $tip = this.tip() var $tip = this.tip()
var title = this.getTitle() var title = this.getTitle()
$tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) if (this.options.html) {
if (this.options.sanitize) {
title = sanitizeHtml(title, this.options.whiteList, this.options.sanitizeFn)
}
$tip.find('.tooltip-inner').html(title)
} else {
$tip.find('.tooltip-inner').text(title)
}
$tip.removeClass('fade in top bottom left right') $tip.removeClass('fade in top bottom left right')
} }
@@ -1743,6 +1926,9 @@ if (typeof jQuery === 'undefined') {
}) })
} }
Tooltip.prototype.sanitizeHtml = function (unsafeHtml) {
return sanitizeHtml(unsafeHtml, this.options.whiteList, this.options.sanitizeFn)
}
// TOOLTIP PLUGIN DEFINITION // TOOLTIP PLUGIN DEFINITION
// ========================= // =========================
@@ -1776,10 +1962,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: popover.js v3.3.7 * Bootstrap: popover.js v3.4.1
* http://getbootstrap.com/javascript/#popovers * https://getbootstrap.com/docs/3.4/javascript/#popovers
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -1796,7 +1982,7 @@ if (typeof jQuery === 'undefined') {
if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js')
Popover.VERSION = '3.3.7' Popover.VERSION = '3.4.1'
Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
placement: 'right', placement: 'right',
@@ -1822,10 +2008,25 @@ if (typeof jQuery === 'undefined') {
var title = this.getTitle() var title = this.getTitle()
var content = this.getContent() var content = this.getContent()
$tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) if (this.options.html) {
$tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events var typeContent = typeof content
this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text'
if (this.options.sanitize) {
title = this.sanitizeHtml(title)
if (typeContent === 'string') {
content = this.sanitizeHtml(content)
}
}
$tip.find('.popover-title').html(title)
$tip.find('.popover-content').children().detach().end()[
typeContent === 'string' ? 'html' : 'append'
](content) ](content)
} else {
$tip.find('.popover-title').text(title)
$tip.find('.popover-content').children().detach().end().text(content)
}
$tip.removeClass('fade top bottom left right in') $tip.removeClass('fade top bottom left right in')
@@ -1885,10 +2086,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: scrollspy.js v3.3.7 * Bootstrap: scrollspy.js v3.4.1
* http://getbootstrap.com/javascript/#scrollspy * https://getbootstrap.com/docs/3.4/javascript/#scrollspy
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -1914,7 +2115,7 @@ if (typeof jQuery === 'undefined') {
this.process() this.process()
} }
ScrollSpy.VERSION = '3.3.7' ScrollSpy.VERSION = '3.4.1'
ScrollSpy.DEFAULTS = { ScrollSpy.DEFAULTS = {
offset: 10 offset: 10
@@ -2058,10 +2259,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: tab.js v3.3.7 * Bootstrap: tab.js v3.4.1
* http://getbootstrap.com/javascript/#tabs * https://getbootstrap.com/docs/3.4/javascript/#tabs
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -2078,7 +2279,7 @@ if (typeof jQuery === 'undefined') {
// jscs:enable requireDollarBeforejQueryAssignment // jscs:enable requireDollarBeforejQueryAssignment
} }
Tab.VERSION = '3.3.7' Tab.VERSION = '3.4.1'
Tab.TRANSITION_DURATION = 150 Tab.TRANSITION_DURATION = 150
@@ -2107,7 +2308,7 @@ if (typeof jQuery === 'undefined') {
if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return
var $target = $(selector) var $target = $(document).find(selector)
this.activate($this.closest('li'), $ul) this.activate($this.closest('li'), $ul)
this.activate($target, $target.parent(), function () { this.activate($target, $target.parent(), function () {
@@ -2214,10 +2415,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery); }(jQuery);
/* ======================================================================== /* ========================================================================
* Bootstrap: affix.js v3.3.7 * Bootstrap: affix.js v3.4.1
* http://getbootstrap.com/javascript/#affix * https://getbootstrap.com/docs/3.4/javascript/#affix
* ======================================================================== * ========================================================================
* Copyright 2011-2016 Twitter, Inc. * Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */ * ======================================================================== */
@@ -2231,7 +2432,9 @@ if (typeof jQuery === 'undefined') {
var Affix = function (element, options) { var Affix = function (element, options) {
this.options = $.extend({}, Affix.DEFAULTS, options) this.options = $.extend({}, Affix.DEFAULTS, options)
this.$target = $(this.options.target) var target = this.options.target === Affix.DEFAULTS.target ? $(this.options.target) : $(document).find(this.options.target)
this.$target = target
.on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this)) .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this))
.on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this)) .on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this))
@@ -2243,7 +2446,7 @@ if (typeof jQuery === 'undefined') {
this.checkPosition() this.checkPosition()
} }
Affix.VERSION = '3.3.7' Affix.VERSION = '3.4.1'
Affix.RESET = 'affix affix-top affix-bottom' Affix.RESET = 'affix affix-top affix-bottom'

File diff suppressed because one or more lines are too long

View File

@@ -42,8 +42,8 @@ footer p {
} }
} }
/* Hide the search bar in the navigation menu on displays less than 1200px wide */ /* Hide the search bar in the navigation menu on displays less than 1250px wide */
@media (max-width: 1199px) { @media (max-width: 1249px) {
#navbar_search { #navbar_search {
display: none; display: none;
} }
@@ -62,8 +62,8 @@ footer p {
} }
} }
/* Collapse the nav menu on displays less than 960px wide */ /* Collapse the nav menu on displays less than 980px wide */
@media (max-width: 959px) { @media (max-width: 979px) {
.navbar-header { .navbar-header {
float: none; float: none;
} }
@@ -529,6 +529,9 @@ table.report th a {
border-top: 1px solid #dddddd; border-top: 1px solid #dddddd;
padding: 8px; padding: 8px;
} }
.rendered-markdown :last-child {
margin-bottom: 0;
}
/* AJAX loader */ /* AJAX loader */
.loading { .loading {

View File

@@ -47,9 +47,10 @@ $(document).ready(function() {
}); });
if (slug_field) { if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source')); var slug_source = $('#id_' + slug_field.attr('slug-source'));
var slug_length = slug_field.attr('maxlength');
slug_source.on('keyup change', function() { slug_source.on('keyup change', function() {
if (slug_field && !slug_field.attr('_changed')) { if (slug_field && !slug_field.attr('_changed')) {
slug_field.val(slugify($(this).val(), 50)); slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
} }
}) })
} }
@@ -74,7 +75,7 @@ $(document).ready(function() {
var rendered_url = url; var rendered_url = url;
var filter_field; var filter_field;
while (match = filter_regex.exec(url)) { while (match = filter_regex.exec(url)) {
filter_field = $('#id_' + match[1]); filter_field = $('#id_' + match[1]);untagged
var custom_attr = $('option:selected', filter_field).attr('api-value'); var custom_attr = $('option:selected', filter_field).attr('api-value');
if (custom_attr) { if (custom_attr) {
rendered_url = rendered_url.replace(match[0], custom_attr); rendered_url = rendered_url.replace(match[0], custom_attr);
@@ -143,11 +144,13 @@ $(document).ready(function() {
// Base query params // Base query params
var parameters = { var parameters = {
q: params.term, q: params.term,
brief: 1,
limit: 50, limit: 50,
offset: offset, offset: offset,
}; };
// Allow for controlling the brief setting from within APISelect
parameters.brief = ( $(element).is('[data-full]') ? undefined : true );
// filter-for fields from a chain // filter-for fields from a chain
var attr_name = "data-filter-for-" + $(element).attr("name"); var attr_name = "data-filter-for-" + $(element).attr("name");
var form = $(element).closest('form'); var form = $(element).closest('form');
@@ -194,18 +197,41 @@ $(document).ready(function() {
processResults: function (data) { processResults: function (data) {
var element = this.$element[0]; var element = this.$element[0];
// Clear any disabled options
$(element).children('option').attr('disabled', false); $(element).children('option').attr('disabled', false);
var results = $.map(data.results, function (obj) { var results = data.results;
obj.text = obj[element.getAttribute('display-field')] || obj.name;
obj.id = obj[element.getAttribute('value-field')] || obj.id;
if(element.getAttribute('disabled-indicator') && obj[element.getAttribute('disabled-indicator')]) { results = results.reduce((results,record) => {
record.text = record[element.getAttribute('display-field')] || record.name;
record.id = record[element.getAttribute('value-field')] || record.id;
if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
// The disabled-indicator equated to true, so we disable this option // The disabled-indicator equated to true, so we disable this option
obj.disabled = true; record.disabled = true;
} }
return obj;
}); if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }
results[record.site.name + ":" + record.group.name].children.push(record);
}
else if( record.group !== undefined && record.group !== null ) {
results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }
results[record.group.name].children.push(record);
}
else if( record.site !== undefined && record.site !== null ) {
results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }
results[record.site.name].children.push(record);
}
else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
results['global'] = results['global'] || { text: 'Global', children: [] }
results['global'].children.push(record);
}
else {
results[record.id] = record
}
return results;
},Object.create(null));
results = Object.values(results);
// Handle the null option, but only add it once // Handle the null option, but only add it once
if (element.getAttribute('data-null-option') && data.previous === null) { if (element.getAttribute('data-null-option') && data.previous === null) {
@@ -300,4 +326,34 @@ $(document).ready(function() {
$('#id_tags').append(option).trigger('change'); $('#id_tags').append(option).trigger('change');
} }
}); });
if( $('select#id_mode').length > 0 ) {
$('select#id_mode').on('change', function () {
if ($(this).val() == '') {
$('select#id_untagged_vlan').val();
$('select#id_untagged_vlan').trigger('change');
$('select#id_tagged_vlans').val([]);
$('select#id_tagged_vlans').trigger('change');
$('select#id_untagged_vlan').parent().parent().hide();
$('select#id_tagged_vlans').parent().parent().hide();
}
else if ($(this).val() == 100) {
$('select#id_tagged_vlans').val([]);
$('select#id_tagged_vlans').trigger('change');
$('select#id_untagged_vlan').parent().parent().show();
$('select#id_tagged_vlans').parent().parent().hide();
}
else if ($(this).val() == 200) {
$('select#id_untagged_vlan').parent().parent().show();
$('select#id_tagged_vlans').parent().parent().show();
}
else if ($(this).val() == 300) {
$('select#id_tagged_vlans').val([]);
$('select#id_tagged_vlans').trigger('change');
$('select#id_untagged_vlan').parent().parent().show();
$('select#id_tagged_vlans').parent().parent().hide();
}
});
$('select#id_mode').trigger('change');
}
}); });

View File

View File

@@ -0,0 +1,66 @@
from django.utils.text import slugify
from dcim.constants import *
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import *
class NewBranchScript(Script):
script_name = "New Branch"
script_description = "Provision a new branch site"
script_fields = ['site_name', 'switch_count', 'switch_model']
site_name = StringVar(
description="Name of the new site"
)
switch_count = IntegerVar(
description="Number of access switches to create"
)
switch_model = ObjectVar(
description="Access switch model",
queryset=DeviceType.objects.filter(
manufacturer__name='Cisco',
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
)
)
x = BooleanVar(
description="Check me out"
)
def run(self, data):
# Create the new site
site = Site(
name=data['site_name'],
slug=slugify(data['site_name']),
status=SITE_STATUS_PLANNED
)
site.save()
self.log_success("Created new site: {}".format(site))
# Create access switches
switch_role = DeviceRole.objects.get(name='Access Switch')
for i in range(1, data['switch_count'] + 1):
switch = Device(
device_type=data['switch_model'],
name='{}-switch{}'.format(site.slug, i),
site=site,
status=DEVICE_STATUS_PLANNED,
device_role=switch_role
)
switch.save()
self.log_success("Created new switch: {}".format(switch))
# Generate a CSV table of new devices
output = [
'name,make,model'
]
for switch in Device.objects.filter(site=site):
attrs = [
switch.name,
switch.device_type.manufacturer.name,
switch.device_type.model
]
output.append(','.join(attrs))
return '\n'.join(output)

View File

@@ -0,0 +1,54 @@
from dcim.models import Site
from extras.scripts import Script, BooleanVar, IntegerVar, ObjectVar, StringVar
class NoInputScript(Script):
description = "This script does not require any input"
def run(self, data):
self.log_debug("This a debug message.")
self.log_info("This an info message.")
self.log_success("This a success message.")
self.log_warning("This a warning message.")
self.log_failure("This a failure message.")
class DemoScript(Script):
name = "Script Demo"
description = "A quick demonstration of the available field types"
my_string1 = StringVar(
description="Input a string between 3 and 10 characters",
min_length=3,
max_length=10
)
my_string2 = StringVar(
description="This field enforces a regex: three letters followed by three numbers",
regex=r'[a-z]{3}\d{3}'
)
my_number = IntegerVar(
description="Pick a number between 1 and 255 (inclusive)",
min_value=1,
max_value=255
)
my_boolean = BooleanVar(
description="Use the checkbox to toggle true/false"
)
my_object = ObjectVar(
description="Select a NetBox site",
queryset=Site.objects.all()
)
def run(self, data):
self.log_info("Your string was {}".format(data['my_string1']))
self.log_info("Your second string was {}".format(data['my_string2']))
self.log_info("Your number was {}".format(data['my_number']))
if data['my_boolean']:
self.log_info("You ticked the checkbox")
else:
self.log_info("You did not tick the checkbox")
self.log_info("You chose the sites {}".format(data['my_object']))
return "Here's some output"

View File

@@ -46,10 +46,8 @@ class SecretRoleViewSet(ModelViewSet):
# #
class SecretViewSet(ModelViewSet): class SecretViewSet(ModelViewSet):
queryset = Secret.objects.select_related( queryset = Secret.objects.prefetch_related(
'device__primary_ip4', 'device__primary_ip6', 'role', 'device__primary_ip4', 'device__primary_ip6', 'role', 'role__users', 'role__groups', 'tags',
).prefetch_related(
'role__users', 'role__groups', 'tags',
) )
serializer_class = serializers.SecretSerializer serializer_class = serializers.SecretSerializer
filterset_class = filters.SecretFilter filterset_class = filters.SecretFilter

View File

@@ -199,6 +199,9 @@ class UserKeyForm(BootstrapMixin, forms.ModelForm):
'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption. " 'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption. "
"Please note that passphrase-protected keys are not supported.", "Please note that passphrase-protected keys are not supported.",
} }
labels = {
'public_key': ''
}
def clean_public_key(self): def clean_public_key(self):
key = self.cleaned_data['public_key'] key = self.cleaned_data['public_key']

View File

@@ -69,7 +69,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class SecretListView(PermissionRequiredMixin, ObjectListView): class SecretListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'secrets.view_secret' permission_required = 'secrets.view_secret'
queryset = Secret.objects.select_related('role', 'device') queryset = Secret.objects.prefetch_related('role', 'device')
filter = filters.SecretFilter filter = filters.SecretFilter
filter_form = forms.SecretFilterForm filter_form = forms.SecretFilterForm
table = tables.SecretTable table = tables.SecretTable
@@ -247,7 +247,7 @@ class SecretBulkImportView(BulkImportView):
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'secrets.change_secret' permission_required = 'secrets.change_secret'
queryset = Secret.objects.select_related('role', 'device') queryset = Secret.objects.prefetch_related('role', 'device')
filter = filters.SecretFilter filter = filters.SecretFilter
table = tables.SecretTable table = tables.SecretTable
form = forms.SecretBulkEditForm form = forms.SecretBulkEditForm
@@ -256,7 +256,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret' permission_required = 'secrets.delete_secret'
queryset = Secret.objects.select_related('role', 'device') queryset = Secret.objects.prefetch_related('role', 'device')
filter = filters.SecretFilter filter = filters.SecretFilter
table = tables.SecretTable table = tables.SecretTable
default_return_url = 'secrets:secret_list' default_return_url = 'secrets:secret_list'

View File

@@ -4,7 +4,7 @@
<head> <head>
<title>Server Error</title> <title>Server Error</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}"> <link rel="stylesheet" href="{% static 'bootstrap-3.4.1-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}"> <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>

View File

@@ -4,7 +4,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{% block title %}Home{% endblock %} - NetBox</title> <title>{% block title %}Home{% endblock %} - NetBox</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}"> <link rel="stylesheet" href="{% static 'bootstrap-3.4.1-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}"> <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
<link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}"> <link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'select2-4.0.5/css/select2.min.css' %}"> <link rel="stylesheet" href="{% static 'select2-4.0.5/css/select2.min.css' %}">
@@ -67,7 +67,7 @@
</footer> </footer>
<script src="{% static 'js/jquery-3.3.1.min.js' %}"></script> <script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script> <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
<script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script> <script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"></script>
<script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script> <script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
<script src="{% static 'clipboard-2.0.4.min.js' %}"></script> <script src="{% static 'clipboard-2.0.4.min.js' %}"></script>
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script> <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>

View File

@@ -35,6 +35,12 @@
</div> </div>
</div> </div>
<div class="pull-right noprint"> <div class="pull-right noprint">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }}" data-url="{% url 'dcim-api:device-graphs' pk=device.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.dcim.change_device %} {% if perms.dcim.change_device %}
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -239,7 +245,7 @@
<td>Platform</td> <td>Platform</td>
<td> <td>
{% if device.platform %} {% if device.platform %}
<span>{{ device.platform }}</span> <a href="{{ device.platform.get_absolute_url }}">{{ device.platform }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}

View File

@@ -135,7 +135,7 @@
{# Buttons #} {# Buttons #}
<td class="text-right text-nowrap noprint"> <td class="text-right text-nowrap noprint">
{% if show_graphs %} {% if show_interface_graphs %}
{% if iface.connected_endpoint %} {% if iface.connected_endpoint %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs"> <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i> <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>

View File

@@ -14,6 +14,8 @@
{% render_field form.mgmt_only %} {% render_field form.mgmt_only %}
{% render_field form.description %} {% render_field form.description %}
{% render_field form.mode %} {% render_field form.mode %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">
@@ -22,21 +24,6 @@
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
</div> </div>
<div class="panel panel-default" id="vlans_panel">
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
{% if obj.mode %}
{% include 'dcim/inc/interface_vlans_table.html' %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
<i class="glyphicon glyphicon-plus"></i> Add VLANs
</a>
</div>
{% else %}
<div class="panel-body text-center text-muted">
<p>802.1Q mode not set</p>
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}
{% block buttons %} {% block buttons %}
@@ -49,18 +36,3 @@
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a> <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endblock %} {% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
$('#clear_untagged_vlan').click(function () {
$('input[name="untagged_vlan"]').prop("checked", false);
return false;
});
$('#clear_tagged_vlans').click(function () {
$('input[name="tagged_vlans"]').prop("checked", false);
return false;
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends '_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% load log_levels %}
{% block title %}{{ script }}{% endblock %}
{% block content %}
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
<li>{{ script }}</li>
</ol>
</div>
</div>
<h1>{{ script }}</h1>
<p>{{ script.Meta.description }}</p>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
</li>
<li role="presentation"{% if not output %} class="disabled"{% endif %}>
<a href="#output" role="tab" data-toggle="tab">Output</a>
</li>
<li role="presentation">
<a href="#source" role="tab" data-toggle="tab">Source</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="run">
{% if execution_time or script.log %}
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Script Log</strong>
</div>
<table class="table table-hover panel-body">
<tr>
<th>Line</th>
<th>Level</th>
<th>Message</th>
</tr>
{% for level, message in script.log %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{% log_level level %}</td>
<td class="rendered-markdown">{{ message|gfm }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">
No log output
</td>
</tr>
{% endfor %}
</table>
{% if execution_time %}
<div class="panel-footer text-right text-muted">
<small>Exec time: {{ execution_time|floatformat:3 }}s</small>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if not perms.extras.run_script %}
<div class="alert alert-warning">
<i class="fa fa-warning"></i>
You do not have permission to run scripts.
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
{% if form.requires_input %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Script Data</strong>
</div>
<div class="panel-body">
{% render_form form %}
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fa fa-exclamation-circle"></i>
This script does not require any input to run.
</div>
{% render_form form %}
{% endif %}
<div class="pull-right">
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="fa fa-play"></i> Run Script</button>
<a href="{% url 'extras:script_list' %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="output">
<pre>{{ output }}</pre>
</div>
<div role="tabpanel" class="tab-pane" id="source">
<p><code>{{ script.filename }}</code></p>
<pre>{{ script.source }}</pre>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Scripts{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% if scripts %}
{% for module, module_scripts in scripts.items %}
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th class="col-md-3">Name</th>
<th class="col-md-9">Description</th>
</tr>
</thead>
<tbody>
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
</td>
<td>{{ script.Meta.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% else %}
<div class="alert alert-info">
<p><strong>No scripts found.</strong></p>
<p>Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>. (This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.)</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -5,7 +5,7 @@
<h1>{% block title %}Tags{% endblock %}</h1> <h1>{% block title %}Tags{% endblock %}</h1>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %} {% include 'utilities/obj_table.html' with bulk_edit_url='extras:tag_bulk_edit' bulk_delete_url='extras:tag_bulk_delete' %}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}

View File

@@ -0,0 +1 @@
<label class="label label-{{ class }}">{{ name }}</label>

View File

@@ -66,6 +66,9 @@
<li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}> <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
<a href="{% url 'extras:configcontext_list' %}">Config Contexts</a> <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
</li> </li>
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
<a href="{% url 'extras:script_list' %}">Scripts</a>
</li>
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}> <li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
<a href="{% url 'extras:report_list' %}">Reports</a> <a href="{% url 'extras:report_list' %}">Reports</a>
</li> </li>

View File

@@ -24,7 +24,7 @@
</ul> </ul>
{% endif %} {% endif %}
</div> </div>
{% elif field|widget_type == 'textarea' %} {% elif field|widget_type == 'textarea' and not field.label %}
<div class="col-md-12"> <div class="col-md-12">
{{ field }} {{ field }}
{% if bulk_nullable %} {% if bulk_nullable %}

View File

@@ -1,7 +1,5 @@
{% load helpers %} {% load helpers %}
{% if url_name %} {% if url_name %}<a href="{% url url_name %}?tag={{ tag.slug }}">{% endif %}
<a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span></a> <span class="label label-default" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span>
{% else %} {% if url_name %}</a>{% endif %}
<span class="label label-default">{{ tag }}</span>
{% endif %}

View File

@@ -35,10 +35,8 @@ class TenantGroupViewSet(ModelViewSet):
# #
class TenantViewSet(CustomFieldModelViewSet): class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.select_related( queryset = Tenant.objects.prefetch_related(
'group' 'group', 'tags'
).prefetch_related(
'tags'
).annotate( ).annotate(
circuit_count=get_subquery(Circuit, 'tenant'), circuit_count=get_subquery(Circuit, 'tenant'),
device_count=get_subquery(Device, 'tenant'), device_count=get_subquery(Device, 'tenant'),

View File

@@ -56,7 +56,7 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class TenantListView(PermissionRequiredMixin, ObjectListView): class TenantListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'tenancy.view_tenant' permission_required = 'tenancy.view_tenant'
queryset = Tenant.objects.select_related('group') queryset = Tenant.objects.prefetch_related('group')
filter = filters.TenantFilter filter = filters.TenantFilter
filter_form = forms.TenantFilterForm filter_form = forms.TenantFilterForm
table = tables.TenantTable table = tables.TenantTable
@@ -115,7 +115,7 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'tenancy.change_tenant' permission_required = 'tenancy.change_tenant'
queryset = Tenant.objects.select_related('group') queryset = Tenant.objects.prefetch_related('group')
filter = filters.TenantFilter filter = filters.TenantFilter
table = tables.TenantTable table = tables.TenantTable
form = forms.TenantBulkEditForm form = forms.TenantBulkEditForm
@@ -124,7 +124,7 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenant' permission_required = 'tenancy.delete_tenant'
queryset = Tenant.objects.select_related('group') queryset = Tenant.objects.prefetch_related('group')
filter = filters.TenantFilter filter = filters.TenantFilter
table = tables.TenantTable table = tables.TenantTable
default_return_url = 'tenancy:tenant_list' default_return_url = 'tenancy:tenant_list'

View File

@@ -85,9 +85,9 @@ class ChoiceField(Field):
def to_internal_value(self, data): def to_internal_value(self, data):
# Provide an explicit error message if the request is trying to write a dict # Provide an explicit error message if the request is trying to write a dict or list
if type(data) is dict: if isinstance(data, (dict, list)):
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary.') raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
# Check for string representations of boolean/integer values # Check for string representations of boolean/integer values
if hasattr(data, 'lower'): if hasattr(data, 'lower'):
@@ -101,10 +101,13 @@ class ChoiceField(Field):
except ValueError: except ValueError:
pass pass
if data not in self._choices: try:
raise ValidationError("{} is not a valid choice.".format(data)) if data in self._choices:
return data return data
except TypeError: # Input is an unhashable type
pass
raise ValidationError("{} is not a valid choice.".format(data))
@property @property
def choices(self): def choices(self):

View File

@@ -0,0 +1,5 @@
class AbortTransaction(Exception):
"""
A dummy exception used to trigger a database transaction rollback.
"""
pass

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