Merge pull request #7317 from netbox-community/develop

Release v3.0.3
This commit is contained in:
Jeremy Stretch 2021-09-20 13:08:32 -04:00 committed by GitHub
commit 55e9685d30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
99 changed files with 1283 additions and 735 deletions

8
.gitattributes vendored
View File

@ -1,5 +1,5 @@
*.sh text eol=lf *.sh text eol=lf
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable # Treat compiled JS/CSS files as binary, as they're not meant to be human-readable
*.min.* binary netbox/project-static/dist/*.css binary
*.map binary netbox/project-static/dist/*.js binary
*.pack.js binary netbox/project-static/dist/*.js.map binary

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.) before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.2 placeholder: v3.0.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

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

View File

@ -2,7 +2,7 @@
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
{!docs/models/users/objectpermission.md!} {!models/users/objectpermission.md!}
### Example Constraint Definitions ### Example Constraint Definitions

View File

@ -71,14 +71,3 @@ To extract the saved archive into a new installation, run the following from the
```no-highlight ```no-highlight
tar -xf netbox_media.tar.gz tar -xf netbox_media.tar.gz
``` ```
---
## Cache Invalidation
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
```no-highlight
# source /opt/netbox/venv/bin/activate
(venv) # python3 manage.py invalidate all
```

View File

@ -490,6 +490,14 @@ NetBox can be configured to support remote user authentication by inferring user
--- ---
## REMOTE_AUTH_GROUP_SYNC_ENABLED
Default: `False`
NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_HEADER ## REMOTE_AUTH_HEADER
Default: `'HTTP_REMOTE_USER'` Default: `'HTTP_REMOTE_USER'`
@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
--- ---
## REMOTE_AUTH_GROUP_HEADER
Default: `'HTTP_REMOTE_USER_GROUP'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSER_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSERS
Default: `[]` (Empty list)
The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_USERS
Default: `[]` (Empty list)
The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_GROUP_SEPARATOR
Default: `|` (Pipe)
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## RELEASE_CHECK_URL ## RELEASE_CHECK_URL
Default: None (disabled) Default: None (disabled)

View File

@ -1,10 +1,10 @@
# Circuits # Circuits
{!docs/models/circuits/provider.md!} {!models/circuits/provider.md!}
{!docs/models/circuits/providernetwork.md!} {!models/circuits/providernetwork.md!}
--- ---
{!docs/models/circuits/circuit.md!} {!models/circuits/circuit.md!}
{!docs/models/circuits/circuittype.md!} {!models/circuits/circuittype.md!}
{!docs/models/circuits/circuittermination.md!} {!models/circuits/circuittermination.md!}

View File

@ -1,7 +1,7 @@
# Device Types # Device Types
{!docs/models/dcim/devicetype.md!} {!models/dcim/devicetype.md!}
{!docs/models/dcim/manufacturer.md!} {!models/dcim/manufacturer.md!}
--- ---
@ -30,11 +30,11 @@ Once component templates have been created, every new device that you create as
!!! note !!! note
Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices. Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices.
{!docs/models/dcim/consoleporttemplate.md!} {!models/dcim/consoleporttemplate.md!}
{!docs/models/dcim/consoleserverporttemplate.md!} {!models/dcim/consoleserverporttemplate.md!}
{!docs/models/dcim/powerporttemplate.md!} {!models/dcim/powerporttemplate.md!}
{!docs/models/dcim/poweroutlettemplate.md!} {!models/dcim/poweroutlettemplate.md!}
{!docs/models/dcim/interfacetemplate.md!} {!models/dcim/interfacetemplate.md!}
{!docs/models/dcim/frontporttemplate.md!} {!models/dcim/frontporttemplate.md!}
{!docs/models/dcim/rearporttemplate.md!} {!models/dcim/rearporttemplate.md!}
{!docs/models/dcim/devicebaytemplate.md!} {!models/dcim/devicebaytemplate.md!}

View File

@ -1,8 +1,8 @@
# Devices and Cabling # Devices and Cabling
{!docs/models/dcim/device.md!} {!models/dcim/device.md!}
{!docs/models/dcim/devicerole.md!} {!models/dcim/devicerole.md!}
{!docs/models/dcim/platform.md!} {!models/dcim/platform.md!}
--- ---
@ -10,20 +10,20 @@
Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources.
{!docs/models/dcim/consoleport.md!} {!models/dcim/consoleport.md!}
{!docs/models/dcim/consoleserverport.md!} {!models/dcim/consoleserverport.md!}
{!docs/models/dcim/powerport.md!} {!models/dcim/powerport.md!}
{!docs/models/dcim/poweroutlet.md!} {!models/dcim/poweroutlet.md!}
{!docs/models/dcim/interface.md!} {!models/dcim/interface.md!}
{!docs/models/dcim/frontport.md!} {!models/dcim/frontport.md!}
{!docs/models/dcim/rearport.md!} {!models/dcim/rearport.md!}
{!docs/models/dcim/devicebay.md!} {!models/dcim/devicebay.md!}
{!docs/models/dcim/inventoryitem.md!} {!models/dcim/inventoryitem.md!}
--- ---
{!docs/models/dcim/virtualchassis.md!} {!models/dcim/virtualchassis.md!}
--- ---
{!docs/models/dcim/cable.md!} {!models/dcim/cable.md!}

View File

@ -1,19 +1,19 @@
# IP Address Management # IP Address Management
{!docs/models/ipam/aggregate.md!} {!models/ipam/aggregate.md!}
{!docs/models/ipam/rir.md!} {!models/ipam/rir.md!}
--- ---
{!docs/models/ipam/prefix.md!} {!models/ipam/prefix.md!}
{!docs/models/ipam/role.md!} {!models/ipam/role.md!}
--- ---
{!docs/models/ipam/iprange.md!} {!models/ipam/iprange.md!}
{!docs/models/ipam/ipaddress.md!} {!models/ipam/ipaddress.md!}
--- ---
{!docs/models/ipam/vrf.md!} {!models/ipam/vrf.md!}
{!docs/models/ipam/routetarget.md!} {!models/ipam/routetarget.md!}

View File

@ -1,8 +1,8 @@
# Power Tracking # Power Tracking
{!docs/models/dcim/powerpanel.md!} {!models/dcim/powerpanel.md!}
{!docs/models/dcim/powerfeed.md!} {!models/dcim/powerfeed.md!}
# Example Power Topology # Example Power Topology
![Power distribution model](../../media/power_distribution.png) ![Power distribution model](/media/power_distribution.png)

View File

@ -1,3 +1,3 @@
# Service Mapping # Service Mapping
{!docs/models/ipam/service.md!} {!models/ipam/service.md!}

View File

@ -1,12 +1,12 @@
# Sites and Racks # Sites and Racks
{!docs/models/dcim/region.md!} {!models/dcim/region.md!}
{!docs/models/dcim/sitegroup.md!} {!models/dcim/sitegroup.md!}
{!docs/models/dcim/site.md!} {!models/dcim/site.md!}
{!docs/models/dcim/location.md!} {!models/dcim/location.md!}
--- ---
{!docs/models/dcim/rack.md!} {!models/dcim/rack.md!}
{!docs/models/dcim/rackrole.md!} {!models/dcim/rackrole.md!}
{!docs/models/dcim/rackreservation.md!} {!models/dcim/rackreservation.md!}

View File

@ -1,4 +1,4 @@
# Tenancy Assignment # Tenancy Assignment
{!docs/models/tenancy/tenant.md!} {!models/tenancy/tenant.md!}
{!docs/models/tenancy/tenantgroup.md!} {!models/tenancy/tenantgroup.md!}

View File

@ -1,10 +1,10 @@
# Virtualization # Virtualization
{!docs/models/virtualization/cluster.md!} {!models/virtualization/cluster.md!}
{!docs/models/virtualization/clustertype.md!} {!models/virtualization/clustertype.md!}
{!docs/models/virtualization/clustergroup.md!} {!models/virtualization/clustergroup.md!}
--- ---
{!docs/models/virtualization/virtualmachine.md!} {!models/virtualization/virtualmachine.md!}
{!docs/models/virtualization/vminterface.md!} {!models/virtualization/vminterface.md!}

View File

@ -1,4 +1,4 @@
# VLAN Management # VLAN Management
{!docs/models/ipam/vlan.md!} {!models/ipam/vlan.md!}
{!docs/models/ipam/vlangroup.md!} {!models/ipam/vlangroup.md!}

View File

@ -226,7 +226,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
!!! note !!! note
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below. To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../../media/admin_ui_run_permission.png) ![Adding the run action to a permission](/media/admin_ui_run_permission.png)
### Via the Web UI ### Via the Web UI

View File

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

View File

@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Accept: application/json" \ -H "Accept: application/json" \
http://netbox/graphql/ \ http://netbox/graphql/ \
--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}' --data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}'
``` ```
The response will include the requested data formatted as JSON: The response will include the requested data formatted as JSON:
@ -54,7 +54,7 @@ For more detail on constructing GraphQL queries, see the [Graphene documentation
The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active: The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
``` ```
{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"} {"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
``` ```
## Authentication ## Authentication

View File

@ -10,7 +10,6 @@ NetBox is an infrastructure resource modeling (IRM) application designed to empo
* **Connections** - Network, console, and power connections among devices * **Connections** - Network, console, and power connections among devices
* **Virtualization** - Virtual machines and clusters * **Virtualization** - Virtual machines and clusters
* **Data circuits** - Long-haul communications circuits and providers * **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
## What NetBox Is Not ## What NetBox Is Not

View File

@ -13,7 +13,7 @@ The following sections detail how to set up a new instance of NetBox:
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference. The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> <iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Requirements ## Requirements

View File

@ -25,7 +25,7 @@ A cable may be traced from either of its endpoints by clicking the "trace" butto
In the example below, three individual cables comprise a path between devices A and D: In the example below, three individual cables comprise a path between devices A and D:
![Cable path](../../media/models/dcim_cable_trace.png) ![Cable path](/media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path: Traced from Interface 1 on Device A, NetBox will show the following path:

View File

@ -17,12 +17,12 @@ However, keep in mind that each piece of functionality is entirely optional. For
## Initial Setup ## Initial Setup
## Plugin Structure ### Plugin Structure
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
```no-highlight ```no-highlight
plugin_name/ project-name/
- plugin_name/ - plugin_name/
- templates/ - templates/
- plugin_name/ - plugin_name/
@ -38,13 +38,13 @@ plugin_name/
- setup.py - setup.py
``` ```
The top level is the project root. Immediately within the root should exist several items: The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. * `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. * `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
* The plugin source directory, with the same name as your plugin. * The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
### Create setup.py ### Create setup.py
@ -118,6 +118,21 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
### Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
```shell
python3 -m venv /path/to/my/venv
```
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
```shell
cd $VENV/lib/python3.7/site-packages/
echo /opt/netbox/netbox > netbox.pth
```
### Install the Plugin for Development ### Install the Plugin for Development
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
@ -218,7 +233,7 @@ NetBox provides a base template to ensure a consistent user experience, which pl
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
```jinja2 ```jinja2
{% extends 'base.html' %} {% extends 'base/layout.html' %}
{% block content %} {% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}

View File

@ -1,5 +1,34 @@
# NetBox v3.0 # NetBox v3.0
## v3.0.3 (2021-09-20)
### Enhancements
* [#5775](https://github.com/netbox-community/netbox/issues/5775) - Enable synchronization of groups for remote authentication backend
* [#6387](https://github.com/netbox-community/netbox/issues/6387) - Add xDSL interface type
* [#6988](https://github.com/netbox-community/netbox/issues/6988) - Order tenants alphabetically without regard to group assignment
* [#7032](https://github.com/netbox-community/netbox/issues/7032) - Add URM port types
* [#7087](https://github.com/netbox-community/netbox/issues/7087) - Add `local_context_data` filter for virtual machines list
* [#7208](https://github.com/netbox-community/netbox/issues/7208) - Add navigation breadcrumbs for custom scripts & reports
* [#7210](https://github.com/netbox-community/netbox/issues/7210) - Add search/filter forms for all organizational models
* [#7239](https://github.com/netbox-community/netbox/issues/7239) - Redirect global search to filtered object list when an object type is selected
* [#7284](https://github.com/netbox-community/netbox/issues/7284) - Include comments field in table/export for all appropriate models
### Bug Fixes
* [#7167](https://github.com/netbox-community/netbox/issues/7167) - Ensure consistent font size when using monospace formatting
* [#7226](https://github.com/netbox-community/netbox/issues/7226) - Exempt GraphQL API requests from CSRF inspection
* [#7228](https://github.com/netbox-community/netbox/issues/7228) - Improve temperature conversions under device status
* [#7248](https://github.com/netbox-community/netbox/issues/7248) - Fix global search results section links
* [#7266](https://github.com/netbox-community/netbox/issues/7266) - Tweak font color for form field placeholder text
* [#7273](https://github.com/netbox-community/netbox/issues/7273) - Fix natural ordering of device components in UI form fields
* [#7279](https://github.com/netbox-community/netbox/issues/7279) - Fix exception when tracing cable with no associated path
* [#7282](https://github.com/netbox-community/netbox/issues/7282) - Fix KeyError exception when `INSECURE_SKIP_TLS_VERIFY` is true
* [#7298](https://github.com/netbox-community/netbox/issues/7298) - Restore missing object names from applied object list filters
* [#7301](https://github.com/netbox-community/netbox/issues/7301) - Fix exception when deleting a large number of child prefixes
---
## v3.0.2 (2021-09-08) ## v3.0.2 (2021-09-08)
### Bug Fixes ### Bug Fixes

View File

@ -2,7 +2,7 @@
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
{!docs/models/users/token.md!} {!models/users/token.md!}
## Authenticating to the API ## Authenticating to the API

View File

@ -3,9 +3,6 @@ site_dir: netbox/project-static/docs
site_url: https://netbox.readthedocs.io/ site_url: https://netbox.readthedocs.io/
repo_name: netbox-community/netbox repo_name: netbox-community/netbox
repo_url: https://github.com/netbox-community/netbox repo_url: https://github.com/netbox-community/netbox
python:
install:
- requirements: docs/requirements.txt
theme: theme:
name: material name: material
icon: icon:
@ -24,13 +21,14 @@ extra:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox link: https://github.com/netbox-community/netbox
- icon: fontawesome/brands/slack - icon: fontawesome/brands/slack
link: https://slack.netbox.dev link: https://netdev.chat/
extra_css: extra_css:
- extra.css - extra.css
markdown_extensions: markdown_extensions:
- admonition - admonition
- attr_list - attr_list
- markdown_include.include: - markdown_include.include:
base_path: 'docs/'
headingOffset: 1 headingOffset: 1
- pymdownx.emoji: - pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji emoji_index: !!python/name:materialx.emoji.twemoji

View File

@ -1 +0,0 @@
default_app_config = 'circuits.apps.CircuitsConfig'

View File

@ -266,6 +266,18 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
} }
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = CircuitType
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Circuits # Circuits
# #

View File

@ -2,10 +2,18 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
from .models import * from .models import *
__all__ = (
'CircuitTable',
'CircuitTypeTable',
'ProviderTable',
'ProviderNetworkTable',
)
CIRCUITTERMINATION_LINK = """ CIRCUITTERMINATION_LINK = """
{% if value.site %} {% if value.site %}
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a> <a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
@ -28,6 +36,7 @@ class ProviderTable(BaseTable):
accessor=Accessor('count_circuits'), accessor=Accessor('count_circuits'),
verbose_name='Circuits' verbose_name='Circuits'
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='circuits:provider_list' url_name='circuits:provider_list'
) )
@ -35,7 +44,8 @@ class ProviderTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Provider model = Provider
fields = ( fields = (
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'tags', 'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
'tags',
) )
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@ -52,13 +62,14 @@ class ProviderNetworkTable(BaseTable):
provider = tables.Column( provider = tables.Column(
linkify=True linkify=True
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='circuits:providernetwork_list' url_name='circuits:providernetwork_list'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ProviderNetwork model = ProviderNetwork
fields = ('pk', 'name', 'provider', 'description', 'tags') fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'provider', 'description') default_columns = ('pk', 'name', 'provider', 'description')
@ -105,6 +116,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z' verbose_name='Side Z'
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'
) )
@ -113,7 +125,7 @@ class CircuitTable(BaseTable):
model = Circuit model = Circuit
fields = ( fields = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'tags', 'commit_rate', 'description', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@ -34,9 +34,7 @@ class ProviderView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider')
paginate_table(circuits_table, request) paginate_table(circuits_table, request)
return { return {
@ -97,10 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('termination_a')
circuits_table.columns.hide('termination_z')
paginate_table(circuits_table, request) paginate_table(circuits_table, request)
return { return {
@ -144,6 +139,8 @@ class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type') circuit_count=count_related(Circuit, 'type')
) )
filterset = filtersets.CircuitTypeFilterSet
filterset_form = forms.CircuitTypeFilterForm
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
@ -151,12 +148,8 @@ class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter( circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
type=instance circuits_table = tables.CircuitTable(circuits, exclude=('type',))
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('type')
paginate_table(circuits_table, request) paginate_table(circuits_table, request)
return { return {

View File

@ -1 +0,0 @@
default_app_config = 'dcim.apps.DCIMConfig'

View File

@ -761,6 +761,9 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_T3 = 't3' TYPE_T3 = 't3'
TYPE_E3 = 'e3' TYPE_E3 = 'e3'
# ATM/DSL
TYPE_XDSL = 'xdsl'
# Stacking # Stacking
TYPE_STACKWISE = 'cisco-stackwise' TYPE_STACKWISE = 'cisco-stackwise'
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
@ -885,6 +888,12 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_E3, 'E3 (34 Mbps)'), (TYPE_E3, 'E3 (34 Mbps)'),
) )
), ),
(
'ATM',
(
(TYPE_XDSL, 'xDSL'),
)
),
( (
'Stacking', 'Stacking',
( (
@ -958,6 +967,9 @@ class PortTypeChoices(ChoiceSet):
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs' TYPE_CS = 'cs'
TYPE_SN = 'sn' TYPE_SN = 'sn'
TYPE_URM_P2 = 'urm-p2'
TYPE_URM_P4 = 'urm-p4'
TYPE_URM_P8 = 'urm-p8'
CHOICES = ( CHOICES = (
( (
@ -998,6 +1010,9 @@ class PortTypeChoices(ChoiceSet):
(TYPE_ST, 'ST'), (TYPE_ST, 'ST'),
(TYPE_CS, 'CS'), (TYPE_CS, 'CS'),
(TYPE_SN, 'SN'), (TYPE_SN, 'SN'),
(TYPE_URM_P2, 'URM-P2'),
(TYPE_URM_P4, 'URM-P4'),
(TYPE_URM_P8, 'URM-P8'),
(TYPE_SPLICE, 'Splice'), (TYPE_SPLICE, 'Splice'),
) )
) )

View File

@ -696,6 +696,18 @@ class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description'] nullable_fields = ['color', 'description']
class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = RackRole
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Racks # Racks
# #
@ -1240,6 +1252,18 @@ class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description'] nullable_fields = ['description']
class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Manufacturer
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Device types # Device types
# #
@ -2076,6 +2100,18 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description'] nullable_fields = ['color', 'description']
class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = DeviceRole
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Platforms # Platforms
# #
@ -2202,9 +2238,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
api_url='/api/dcim/racks/{{rack}}/elevation/', api_url='/api/dcim/racks/{{rack}}/elevation/',
attrs={ attrs={
'disabled-indicator': 'device', 'disabled-indicator': 'device',
'data-query-param-face': "[\"$face\"]", 'data-query-param-face': "[\"$face\"]"
# The UI will not sort this element's options.
'pre-sorted': ''
} }
) )
) )

View File

@ -9,7 +9,7 @@ from dcim.models import (
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
TagColumn, ToggleColumn, MarkdownColumn, TagColumn, ToggleColumn,
) )
from .template_code import ( from .template_code import (
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@ -18,6 +18,7 @@ from .template_code import (
) )
__all__ = ( __all__ = (
'BaseInterfaceTable',
'ConsolePortTable', 'ConsolePortTable',
'ConsoleServerPortTable', 'ConsoleServerPortTable',
'DeviceBayTable', 'DeviceBayTable',
@ -187,6 +188,7 @@ class DeviceTable(BaseTable):
vc_priority = tables.Column( vc_priority = tables.Column(
verbose_name='VC Priority' verbose_name='VC Priority'
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='dcim:device_list' url_name='dcim:device_list'
) )
@ -196,7 +198,7 @@ class DeviceTable(BaseTable):
fields = ( fields = (
'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -5,7 +5,7 @@ from dcim.models import (
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, TagColumn, ToggleColumn, BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
) )
__all__ = ( __all__ = (
@ -68,6 +68,7 @@ class DeviceTypeTable(BaseTable):
url_params={'device_type_id': 'pk'}, url_params={'device_type_id': 'pk'},
verbose_name='Instances' verbose_name='Instances'
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='dcim:devicetype_list' url_name='dcim:devicetype_list'
) )
@ -76,7 +77,7 @@ class DeviceTypeTable(BaseTable):
model = DeviceType model = DeviceType
fields = ( fields = (
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'instance_count', 'tags', 'comments', 'instance_count', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@ -1,7 +1,7 @@
import django_tables2 as tables import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
from .devices import CableTerminationTable from .devices import CableTerminationTable
__all__ = ( __all__ = (
@ -62,6 +62,7 @@ class PowerFeedTable(CableTerminationTable):
available_power = tables.Column( available_power = tables.Column(
verbose_name='Available power (VA)' verbose_name='Available power (VA)'
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='dcim:powerfeed_list' url_name='dcim:powerfeed_list'
) )
@ -71,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
fields = ( fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
'tags', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -4,13 +4,12 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole from dcim.models import Rack, RackReservation, RackRole
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
ToggleColumn, UtilizationColumn, TagColumn, ToggleColumn, UtilizationColumn,
) )
__all__ = ( __all__ = (
'RackTable', 'RackTable',
'RackDetailTable',
'RackReservationTable', 'RackReservationTable',
'RackRoleTable', 'RackRoleTable',
) )
@ -56,17 +55,7 @@ class RackTable(BaseTable):
template_code="{{ record.u_height }}U", template_code="{{ record.u_height }}U",
verbose_name='Height' verbose_name='Height'
) )
comments = MarkdownColumn()
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height',
)
default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
class RackDetailTable(RackTable):
device_count = LinkedCountColumn( device_count = LinkedCountColumn(
viewname='dcim:device_list', viewname='dcim:device_list',
url_params={'rack_id': 'pk'}, url_params={'rack_id': 'pk'},
@ -84,10 +73,11 @@ class RackDetailTable(RackTable):
url_name='dcim:rack_list' url_name='dcim:rack_list'
) )
class Meta(RackTable.Meta): class Meta(BaseTable.Meta):
model = Rack
fields = ( fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', 'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@ -3,7 +3,7 @@ import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
) )
from .template_code import LOCATION_ELEVATIONS from .template_code import LOCATION_ELEVATIONS
@ -76,6 +76,7 @@ class SiteTable(BaseTable):
linkify=True linkify=True
) )
tenant = TenantColumn() tenant = TenantColumn()
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='dcim:site_list' url_name='dcim:site_list'
) )
@ -85,7 +86,7 @@ class SiteTable(BaseTable):
fields = ( fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'tags', 'contact_email', 'comments', 'tags',
) )
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')

View File

@ -131,8 +131,7 @@ class RegionView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter( sites = Site.objects.restrict(request.user, 'view').filter(
region=instance region=instance
) )
sites_table = tables.SiteTable(sites) sites_table = tables.SiteTable(sites, exclude=('region',))
sites_table.columns.hide('region')
paginate_table(sites_table, request) paginate_table(sites_table, request)
return { return {
@ -216,8 +215,7 @@ class SiteGroupView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter( sites = Site.objects.restrict(request.user, 'view').filter(
group=instance group=instance
) )
sites_table = tables.SiteTable(sites) sites_table = tables.SiteTable(sites, exclude=('group',))
sites_table.columns.hide('group')
paginate_table(sites_table, request) paginate_table(sites_table, request)
return { return {
@ -440,6 +438,8 @@ class RackRoleListView(generic.ObjectListView):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role') rack_count=count_related(Rack, 'role')
) )
filterset = filtersets.RackRoleFilterSet
filterset_form = forms.RackRoleFilterForm
table = tables.RackRoleTable table = tables.RackRoleTable
@ -451,8 +451,7 @@ class RackRoleView(generic.ObjectView):
role=instance role=instance
) )
racks_table = tables.RackTable(racks) racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
racks_table.columns.hide('role')
paginate_table(racks_table, request) paginate_table(racks_table, request)
return { return {
@ -503,7 +502,7 @@ class RackListView(generic.ObjectListView):
) )
filterset = filtersets.RackFilterSet filterset = filtersets.RackFilterSet
filterset_form = forms.RackFilterForm filterset_form = forms.RackFilterForm
table = tables.RackDetailTable table = tables.RackTable
class RackElevationListView(generic.ObjectListView): class RackElevationListView(generic.ObjectListView):
@ -684,6 +683,8 @@ class ManufacturerListView(generic.ObjectListView):
inventoryitem_count=count_related(InventoryItem, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer') platform_count=count_related(Platform, 'manufacturer')
) )
filterset = filtersets.ManufacturerFilterSet
filterset_form = forms.ManufacturerFilterForm
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -700,8 +701,7 @@ class ManufacturerView(generic.ObjectView):
manufacturer=instance manufacturer=instance
) )
devicetypes_table = tables.DeviceTypeTable(devicetypes) devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
devicetypes_table.columns.hide('manufacturer')
paginate_table(devicetypes_table, request) paginate_table(devicetypes_table, request)
return { return {
@ -1149,6 +1149,8 @@ class DeviceRoleListView(generic.ObjectListView):
device_count=count_related(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role') vm_count=count_related(VirtualMachine, 'role')
) )
filterset = filtersets.DeviceRoleFilterSet
filterset_form = forms.DeviceRoleFilterForm
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
@ -1159,9 +1161,7 @@ class DeviceRoleView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter( devices = Device.objects.restrict(request.user, 'view').filter(
device_role=instance device_role=instance
) )
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('device_role')
paginate_table(devices_table, request) paginate_table(devices_table, request)
return { return {
@ -1225,9 +1225,7 @@ class PlatformView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter( devices = Device.objects.restrict(request.user, 'view').filter(
platform=instance platform=instance
) )
devices_table = tables.DeviceTable(devices, exclude=('platform',))
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('platform')
paginate_table(devices_table, request) paginate_table(devices_table, request)
return { return {
@ -1872,9 +1870,9 @@ class InterfaceView(generic.ObjectView):
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.InterfaceTable( child_interfaces_tables = tables.InterfaceTable(
child_interfaces, child_interfaces,
exclude=('device', 'parent'),
orderable=False orderable=False
) )
child_interfaces_tables.columns.hide('device')
# Get assigned VLANs and annotate whether each is tagged or untagged # Get assigned VLANs and annotate whether each is tagged or untagged
vlans = [] vlans = []
@ -2411,6 +2409,12 @@ class PathTraceView(generic.ObjectView):
else: else:
path = related_paths.first() path = related_paths.first()
# No paths found
if path is None:
return {
'path': None
}
# Get the total length of the cable and whether the length is definitive (fully defined) # Get the total length of the cable and whether the length is definitive (fully defined)
total_length, is_definitive = path.get_total_length() if path else (None, False) total_length, is_definitive = path.get_total_length() if path else (None, False)

View File

@ -1 +0,0 @@
default_app_config = 'extras.apps.ExtrasConfig'

View File

@ -3,10 +3,23 @@ from django.conf import settings
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
ToggleColumn, MarkdownColumn, ToggleColumn,
) )
from .models import * from .models import *
__all__ = (
'ConfigContextTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
'JournalEntryTable',
'ObjectChangeTable',
'ObjectJournalTable',
'TaggedItemTable',
'TagTable',
'WebhookTable',
)
CONFIGCONTEXT_ACTIONS = """ CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %} {% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a> <a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
@ -232,6 +245,7 @@ class JournalEntryTable(ObjectJournalTable):
orderable=False, orderable=False,
verbose_name='Object' verbose_name='Object'
) )
comments = MarkdownColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = JournalEntry model = JournalEntry

View File

@ -1 +0,0 @@
default_app_config = 'ipam.apps.IPAMConfig'

View File

@ -256,7 +256,17 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['is_private', 'description'] nullable_fields = ['is_private', 'description']
class RIRFilterForm(BootstrapMixin, forms.Form): class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = RIR
field_groups = [
['q'],
['is_private'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
is_private = forms.NullBooleanField( is_private = forms.NullBooleanField(
required=False, required=False,
label=_('Private'), label=_('Private'),
@ -413,6 +423,18 @@ class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description'] nullable_fields = ['description']
class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Role
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Prefixes # Prefixes
# #
@ -1460,11 +1482,12 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['site', 'description'] nullable_fields = ['site', 'description']
class VLANGroupFilterForm(BootstrapMixin, forms.Form): class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
field_groups = [ field_groups = [
['q'], ['q'],
['region', 'sitegroup', 'site', 'location', 'rack'] ['region', 'sitegroup', 'site', 'location', 'rack']
] ]
model = VLANGroup
q = forms.CharField( q = forms.CharField(
required=False, required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),

View File

@ -0,0 +1,4 @@
from .ip import *
from .services import *
from .vlans import *
from .vrfs import *

View File

@ -2,14 +2,23 @@ import django_tables2 as tables
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
ToggleColumn, UtilizationColumn, ToggleColumn, UtilizationColumn,
) )
from virtualization.models import VMInterface from ipam.models import *
from .models import *
__all__ = (
'AggregateTable',
'InterfaceIPAddressTable',
'IPAddressAssignTable',
'IPAddressTable',
'IPRangeTable',
'PrefixTable',
'RIRTable',
'RoleTable',
)
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>') AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
@ -66,114 +75,6 @@ VRF_LINK = """
{% endif %} {% endif %}
""" """
VRF_TARGETS = """
{% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
{% elif perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
{% else %}
{{ record.available }} VLAN{{ record.available|pluralize }} available
{% endif %}
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
"""
VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == object.pk %}
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
{% else %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% endif %}
"""
#
# VRFs
#
class VRFTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
rd = tables.Column(
verbose_name='RD'
)
tenant = TenantColumn()
enforce_unique = BooleanColumn(
verbose_name='Unique'
)
import_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
export_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
#
# Route targets
#
class RouteTargetTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'name', 'tenant', 'description', 'tags')
default_columns = ('pk', 'name', 'tenant', 'description')
# #
# RIRs # RIRs
@ -215,13 +116,6 @@ class AggregateTable(BaseTable):
format="Y-m-d", format="Y-m-d",
verbose_name='Added' verbose_name='Added'
) )
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'tenant', 'date_added', 'description')
class AggregateDetailTable(AggregateTable):
child_count = tables.Column( child_count = tables.Column(
verbose_name='Prefixes' verbose_name='Prefixes'
) )
@ -233,7 +127,8 @@ class AggregateDetailTable(AggregateTable):
url_name='ipam:aggregate_list' url_name='ipam:aggregate_list'
) )
class Meta(AggregateTable.Meta): class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags') fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@ -332,20 +227,6 @@ class PrefixTable(BaseTable):
mark_utilized = BooleanColumn( mark_utilized = BooleanColumn(
verbose_name='Marked Utilized' verbose_name='Marked Utilized'
) )
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'prefix', 'prefix_flat', 'status', 'depth', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description',
)
default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}
class PrefixDetailTable(PrefixTable):
utilization = PrefixUtilizationColumn( utilization = PrefixUtilizationColumn(
accessor='get_utilization', accessor='get_utilization',
orderable=False orderable=False
@ -354,7 +235,8 @@ class PrefixDetailTable(PrefixTable):
url_name='ipam:prefix_list' url_name='ipam:prefix_list'
) )
class Meta(PrefixTable.Meta): class Meta(BaseTable.Meta):
model = Prefix
fields = ( fields = (
'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description', 'tags', 'is_pool', 'mark_utilized', 'description', 'tags',
@ -362,6 +244,9 @@ class PrefixDetailTable(PrefixTable):
default_columns = ( default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
) )
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}
# #
@ -427,25 +312,11 @@ class IPAddressTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Device/VM' verbose_name='Device/VM'
) )
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
class IPAddressDetailTable(IPAddressTable):
nat_inside = tables.Column( nat_inside = tables.Column(
linkify=True, linkify=True,
orderable=False, orderable=False,
verbose_name='NAT (Inside)' verbose_name='NAT (Inside)'
) )
tenant = TenantColumn()
assigned = BooleanColumn( assigned = BooleanColumn(
accessor='assigned_object_id', accessor='assigned_object_id',
verbose_name='Assigned' verbose_name='Assigned'
@ -454,14 +325,18 @@ class IPAddressDetailTable(IPAddressTable):
url_name='ipam:ipaddress_list' url_name='ipam:ipaddress_list'
) )
class Meta(IPAddressTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress
fields = ( fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'description', 'tags', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
) )
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
class IPAddressAssignTable(BaseTable): class IPAddressAssignTable(BaseTable):
@ -501,173 +376,3 @@ class InterfaceIPAddressTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description') fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
#
# VLAN groups
#
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
scope_type = ContentTypeColumn()
scope = tables.Column(
linkify=True,
orderable=False
)
vlan_count = LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
verbose_name='VLANs'
)
actions = ButtonsColumn(
model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN
)
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
#
# VLANs
#
class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='ID'
)
site = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
tenant = TenantColumn()
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'name', 'site', 'group', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
}
class VLANDetailTable(VLANTable):
prefixes = tables.TemplateColumn(
template_code=VLAN_PREFIXES,
orderable=False,
verbose_name='Prefixes'
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vlan_list'
)
class Meta(VLANTable.Meta):
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANMembersTable(BaseTable):
"""
Base table for Interface and VMInterface assignments
"""
name = tables.Column(
linkify=True,
verbose_name='Interface'
)
tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_TAGGED,
orderable=False
)
class VLANDevicesTable(VLANMembersTable):
device = tables.Column(
linkify=True
)
actions = ButtonsColumn(Interface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
class VLANVirtualMachinesTable(VLANMembersTable):
virtual_machine = tables.Column(
linkify=True
)
actions = ButtonsColumn(VMInterface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = VMInterface
fields = ('virtual_machine', 'name', 'tagged', 'actions')
class InterfaceVLANTable(BaseTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name='ID'
)
tagged = BooleanColumn()
site = tables.Column(
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name='Group'
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)
#
# Services
#
class ServiceTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
parent = tables.Column(
linkify=True,
order_by=('device', 'virtual_machine')
)
ports = tables.TemplateColumn(
template_code='{{ record.port_list }}',
verbose_name='Ports'
)
tags = TagColumn(
url_name='ipam:service_list'
)
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@ -0,0 +1,35 @@
import django_tables2 as tables
from utilities.tables import BaseTable, TagColumn, ToggleColumn
from ipam.models import *
__all__ = (
'ServiceTable',
)
#
# Services
#
class ServiceTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
parent = tables.Column(
linkify=True,
order_by=('device', 'virtual_machine')
)
ports = tables.TemplateColumn(
template_code='{{ record.port_list }}',
verbose_name='Ports'
)
tags = TagColumn(
url_name='ipam:service_list'
)
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

203
netbox/ipam/tables/vlans.py Normal file
View File

@ -0,0 +1,203 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
ToggleColumn,
)
from virtualization.models import VMInterface
from ipam.models import *
__all__ = (
'InterfaceVLANTable',
'VLANDevicesTable',
'VLANGroupTable',
'VLANMembersTable',
'VLANTable',
'VLANVirtualMachinesTable',
)
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
VLAN_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
{% elif perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
{% else %}
{{ record.available }} VLAN{{ record.available|pluralize }} available
{% endif %}
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
"""
VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == object.pk %}
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
{% else %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% endif %}
"""
#
# VLAN groups
#
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
scope_type = ContentTypeColumn()
scope = tables.Column(
linkify=True,
orderable=False
)
vlan_count = LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
verbose_name='VLANs'
)
actions = ButtonsColumn(
model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN
)
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
#
# VLANs
#
class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='ID'
)
site = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
tenant = TenantColumn()
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
prefixes = tables.TemplateColumn(
template_code=VLAN_PREFIXES,
orderable=False,
verbose_name='Prefixes'
)
tags = TagColumn(
url_name='ipam:vlan_list'
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
}
class VLANMembersTable(BaseTable):
"""
Base table for Interface and VMInterface assignments
"""
name = tables.Column(
linkify=True,
verbose_name='Interface'
)
tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_TAGGED,
orderable=False
)
class VLANDevicesTable(VLANMembersTable):
device = tables.Column(
linkify=True
)
actions = ButtonsColumn(Interface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
class VLANVirtualMachinesTable(VLANMembersTable):
virtual_machine = tables.Column(
linkify=True
)
actions = ButtonsColumn(VMInterface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = VMInterface
fields = ('virtual_machine', 'name', 'tagged', 'actions')
class InterfaceVLANTable(BaseTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name='ID'
)
tagged = BooleanColumn()
site = tables.Column(
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name='Group'
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)

View File

@ -0,0 +1,74 @@
import django_tables2 as tables
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
from ipam.models import *
__all__ = (
'RouteTargetTable',
'VRFTable',
)
VRF_TARGETS = """
{% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
#
# VRFs
#
class VRFTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
rd = tables.Column(
verbose_name='RD'
)
tenant = TenantColumn()
enforce_unique = BooleanColumn(
verbose_name='Unique'
)
import_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
export_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
#
# Route targets
#
class RouteTargetTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'name', 'tenant', 'description', 'tags')
default_columns = ('pk', 'name', 'tenant', 'description')

View File

@ -155,9 +155,7 @@ class RIRView(generic.ObjectView):
aggregates = Aggregate.objects.restrict(request.user, 'view').filter( aggregates = Aggregate.objects.restrict(request.user, 'view').filter(
rir=instance rir=instance
) )
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
aggregates_table = tables.AggregateTable(aggregates)
aggregates_table.columns.hide('rir')
paginate_table(aggregates_table, request) paginate_table(aggregates_table, request)
return { return {
@ -207,7 +205,7 @@ class AggregateListView(generic.ObjectListView):
) )
filterset = filtersets.AggregateFilterSet filterset = filtersets.AggregateFilterSet
filterset_form = forms.AggregateFilterForm filterset_form = forms.AggregateFilterForm
table = tables.AggregateDetailTable table = tables.AggregateTable
class AggregateView(generic.ObjectView): class AggregateView(generic.ObjectView):
@ -227,7 +225,7 @@ class AggregateView(generic.ObjectView):
if request.GET.get('show_available', 'true') == 'true': if request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes) child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes) prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.columns.show('pk') prefix_table.columns.show('pk')
paginate_table(prefix_table, request) paginate_table(prefix_table, request)
@ -283,6 +281,8 @@ class RoleListView(generic.ObjectListView):
prefix_count=count_related(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role') vlan_count=count_related(VLAN, 'role')
) )
filterset = filtersets.RoleFilterSet
filterset_form = forms.RoleFilterForm
table = tables.RoleTable table = tables.RoleTable
@ -294,8 +294,7 @@ class RoleView(generic.ObjectView):
role=instance role=instance
) )
prefixes_table = tables.PrefixTable(prefixes) prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
prefixes_table.columns.hide('role')
paginate_table(prefixes_table, request) paginate_table(prefixes_table, request)
return { return {
@ -338,7 +337,7 @@ class PrefixListView(generic.ObjectListView):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
filterset = filtersets.PrefixFilterSet filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm filterset_form = forms.PrefixFilterForm
table = tables.PrefixDetailTable table = tables.PrefixTable
template_name = 'ipam/prefix_list.html' template_name = 'ipam/prefix_list.html'
@ -361,8 +360,11 @@ class PrefixView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
) )
parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefix_table = tables.PrefixTable(
parent_prefix_table.exclude = ('vrf',) list(parent_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
# Duplicate prefixes table # Duplicate prefixes table
duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter( duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
@ -372,8 +374,11 @@ class PrefixView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
) )
duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False) duplicate_prefix_table = tables.PrefixTable(
duplicate_prefix_table.exclude = ('vrf',) list(duplicate_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
return { return {
'aggregate': aggregate, 'aggregate': aggregate,
@ -396,7 +401,7 @@ class PrefixPrefixesView(generic.ObjectView):
if child_prefixes and request.GET.get('show_available', 'true') == 'true': if child_prefixes and request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes) child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
table = tables.PrefixDetailTable(child_prefixes, user=request.user) table = tables.PrefixTable(child_prefixes, user=request.user, exclude=('utilization',))
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
table.columns.show('pk') table.columns.show('pk')
paginate_table(table, request) paginate_table(table, request)
@ -599,7 +604,7 @@ class IPAddressListView(generic.ObjectListView):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable table = tables.IPAddressTable
class IPAddressView(generic.ObjectView): class IPAddressView(generic.ObjectView):
@ -613,8 +618,11 @@ class IPAddressView(generic.ObjectView):
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
) )
parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefixes_table = tables.PrefixTable(
parent_prefixes_table.exclude = ('vrf',) list(parent_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
# Duplicate IPs table # Duplicate IPs table
duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter( duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
@ -765,11 +773,9 @@ class VLANGroupView(generic.ObjectView):
vlans_count = vlans.count() vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance) vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANDetailTable(vlans) vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes'))
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk') vlans_table.columns.show('pk')
vlans_table.columns.hide('site')
vlans_table.columns.hide('group')
paginate_table(vlans_table, request) paginate_table(vlans_table, request)
# Compile permissions list for rendering the object table # Compile permissions list for rendering the object table
@ -826,7 +832,7 @@ class VLANListView(generic.ObjectListView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet filterset = filtersets.VLANFilterSet
filterset_form = forms.VLANFilterForm filterset_form = forms.VLANFilterForm
table = tables.VLANDetailTable table = tables.VLANTable
class VLANView(generic.ObjectView): class VLANView(generic.ObjectView):
@ -836,8 +842,7 @@ class VLANView(generic.ObjectView):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related( prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
'vrf', 'site', 'role' 'vrf', 'site', 'role'
) )
prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
prefix_table.exclude = ('vlan',)
return { return {
'prefix_table': prefix_table, 'prefix_table': prefix_table,

View File

@ -2,14 +2,17 @@ import logging
from collections import defaultdict from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import Group from django.contrib.auth.models import Group, AnonymousUser
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q from django.db.models import Q
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
UserModel = get_user_model()
class ObjectPermissionMixin(): class ObjectPermissionMixin():
@ -101,38 +104,145 @@ class RemoteUserBackend(_RemoteUserBackend):
def create_unknown_user(self): def create_unknown_user(self):
return settings.REMOTE_AUTH_AUTO_CREATE_USER return settings.REMOTE_AUTH_AUTO_CREATE_USER
def configure_user(self, request, user): def configure_groups(self, user, remote_groups):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend') logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
# Assign default groups to the user # Assign default groups to the user
group_list = [] group_list = []
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: for name in remote_groups:
try: try:
group_list.append(Group.objects.get(name=name)) group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist: except Group.DoesNotExist:
logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.add(*group_list)
logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
# Assign default object permissions to the user
permissions_list = []
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
object_type, action = resolve_permission_ct(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(actions=[action], constraints=constraints)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(object_type)
permissions_list.append(permission_name)
except ValueError:
logging.error( logging.error(
f"Invalid permission name: '{permission_name}'. Permissions must be in the form " f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
"<app>.<action>_<model>. (Example: dcim.add_site)" if group_list:
) user.groups.set(group_list)
if permissions_list: logger.debug(
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") f"Assigned groups to remotely-authenticated user {user}: {group_list}")
else:
user.groups.clear()
logger.debug(f"Stripping user {user} from Groups")
user.is_superuser = self._is_superuser(user)
logger.debug(f"User {user} is Superuser: {user.is_superuser}")
logger.debug(
f"User {user} should be Superuser: {self._is_superuser(user)}")
user.is_staff = self._is_staff(user)
logger.debug(f"User {user} is Staff: {user.is_staff}")
logger.debug(f"User {user} should be Staff: {self._is_staff(user)}")
user.save()
return user
def authenticate(self, request, remote_user, remote_groups=None):
"""
The username passed as ``remote_user`` is considered trusted. Return
the ``User`` object with the given username. Create a new ``User``
object if ``create_unknown_user`` is ``True``.
Return None if ``create_unknown_user`` is ``False`` and a ``User``
object with the given username is not found in the database.
"""
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger.debug(
f"trying to authenticate {remote_user} with groups {remote_groups}")
if not remote_user:
return
user = None
username = self.clean_username(remote_user)
# Note that this could be accomplished in one try-except clause, but
# instead we use get_or_create when creating unknown users since it has
# built-in safeguards for multiple threads.
if self.create_unknown_user:
user, created = UserModel._default_manager.get_or_create(**{
UserModel.USERNAME_FIELD: username
})
if created:
user = self.configure_user(request, user)
else:
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
pass
if self.user_can_authenticate(user):
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
if user is not None and not isinstance(user, AnonymousUser):
return self.configure_groups(user, remote_groups)
else:
return user
else:
return None
def _is_superuser(self, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
logger.debug(f"Superuser Groups: {superuser_groups}")
superusers = settings.REMOTE_AUTH_SUPERUSERS
logger.debug(f"Superuser Users: {superusers}")
user_groups = set()
for g in user.groups.all():
user_groups.add(g.name)
logger.debug(f"User {user.username} is in Groups:{user_groups}")
result = user.username in superusers or (
set(user_groups) & set(superuser_groups))
logger.debug(f"User {user.username} in Superuser Users :{result}")
return bool(result)
def _is_staff(self, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
logger.debug(f"Superuser Groups: {staff_groups}")
staff_users = settings.REMOTE_AUTH_STAFF_USERS
logger.debug(f"Staff Users :{staff_users}")
user_groups = set()
for g in user.groups.all():
user_groups.add(g.name)
logger.debug(f"User {user.username} is in Groups:{user_groups}")
result = user.username in staff_users or (
set(user_groups) & set(staff_groups))
logger.debug(f"User {user.username} in Staff Users :{result}")
return bool(result)
def configure_user(self, request, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
# Assign default groups to the user
group_list = []
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
try:
group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.add(*group_list)
logger.debug(
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
# Assign default object permissions to the user
permissions_list = []
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
object_type, action = resolve_permission_ct(
permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(
actions=[action], constraints=constraints)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(object_type)
permissions_list.append(permission_name)
except ValueError:
logging.error(
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
"<app>.<action>_<model>. (Example: dcim.add_site)"
)
if permissions_list:
logger.debug(
f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
else:
logger.debug(
f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled")
return user return user

View File

@ -21,7 +21,7 @@ from tenancy.tables import TenantTable
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15 SEARCH_MAX_RESULTS = 15
SEARCH_TYPES = OrderedDict(( SEARCH_TYPES = OrderedDict((
@ -130,7 +130,7 @@ SEARCH_TYPES = OrderedDict((
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
), ),
'filterset': VirtualMachineFilterSet, 'filterset': VirtualMachineFilterSet,
'table': VirtualMachineDetailTable, 'table': VirtualMachineTable,
'url': 'virtualization:virtualmachine_list', 'url': 'virtualization:virtualmachine_list',
}), }),
# IPAM # IPAM

View File

@ -1,8 +1,11 @@
import uuid import uuid
from urllib import parse from urllib import parse
import logging
from django.conf import settings from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
@ -16,6 +19,7 @@ class LoginRequiredMiddleware(object):
""" """
If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page. If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
""" """
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
@ -49,12 +53,65 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return settings.REMOTE_AUTH_HEADER return settings.REMOTE_AUTH_HEADER
def process_request(self, request): def process_request(self, request):
logger = logging.getLogger(
'netbox.authentication.RemoteUserMiddleware')
# Bypass middleware if remote authentication is not enabled # Bypass middleware if remote authentication is not enabled
if not settings.REMOTE_AUTH_ENABLED: if not settings.REMOTE_AUTH_ENABLED:
return return
# AuthenticationMiddleware is required so that request.user exists.
if not hasattr(request, 'user'):
raise ImproperlyConfigured(
"The Django remote user auth middleware requires the"
" authentication middleware to be installed. Edit your"
" MIDDLEWARE setting to insert"
" 'django.contrib.auth.middleware.AuthenticationMiddleware'"
" before the RemoteUserMiddleware class.")
try:
username = request.META[self.header]
except KeyError:
# If specified header doesn't exist then remove any existing
# authenticated remote-user, or return (leaving request.user set to
# AnonymousUser by the AuthenticationMiddleware).
if self.force_logout_if_no_header and request.user.is_authenticated:
self._remove_invalid_user(request)
return
# If the user is already authenticated and that user is the user we are
# getting passed in the headers, then the correct user is already
# persisted in the session and we don't need to continue.
if request.user.is_authenticated:
if request.user.get_username() == self.clean_username(username, request):
return
else:
# An authenticated user is associated with the request, but
# it does not match the authorized user in the header.
self._remove_invalid_user(request)
return super().process_request(request) # We are seeing this user for the first time in this session, attempt
# to authenticate the user.
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
logger.debug("Trying to sync Groups")
user = auth.authenticate(
request, remote_user=username, remote_groups=self._get_groups(request))
else:
user = auth.authenticate(request, remote_user=username)
if user:
# User is valid. Set request.user and persist user in the session
# by logging the user in.
request.user = user
auth.login(request, user)
def _get_groups(self, request):
logger = logging.getLogger(
'netbox.authentication.RemoteUserMiddleware')
groups_string = request.META.get(
settings.REMOTE_AUTH_GROUP_HEADER, None)
if groups_string:
groups = groups_string.split(settings.REMOTE_AUTH_GROUP_SEPARATOR)
else:
groups = []
logger.debug(f"Groups are {groups}")
return groups
class ObjectChangeMiddleware(object): class ObjectChangeMiddleware(object):
@ -71,6 +128,7 @@ class ObjectChangeMiddleware(object):
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
object is recorded before it (and any related objects) are actually deleted from the database. object is recorded before it (and any related objects) are actually deleted from the database.
""" """
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
@ -90,6 +148,7 @@ class APIVersionMiddleware(object):
""" """
If the request is for an API endpoint, include the API version as a response header. If the request is for an API endpoint, include the API version as a response header.
""" """
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
@ -105,6 +164,7 @@ class ExceptionHandlingMiddleware(object):
Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
to the user. to the user.
""" """
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '3.0.2' VERSION = '3.0.3'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -120,6 +120,13 @@ REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS'
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False) REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP')
REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False)
REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', [])
REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
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('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
@ -255,6 +262,7 @@ if CACHING_REDIS_SENTINELS:
CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient' CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient'
CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS
if CACHING_REDIS_SKIP_TLS_VERIFY: if CACHING_REDIS_SKIP_TLS_VERIFY:
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False

View File

@ -58,7 +58,8 @@ class ExternalAuthenticationTestCase(TestCase):
response = self.client.get(reverse('home'), follow=True, **headers) response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed') self.assertEqual(int(self.client.session.get(
'_auth_user_id')), self.user.pk, msg='Authentication failed')
@override_settings( @override_settings(
REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_ENABLED=True,
@ -78,7 +79,8 @@ class ExternalAuthenticationTestCase(TestCase):
response = self.client.get(reverse('home'), follow=True, **headers) response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed') self.assertEqual(int(self.client.session.get(
'_auth_user_id')), self.user.pk, msg='Authentication failed')
@override_settings( @override_settings(
REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_ENABLED=True,
@ -102,7 +104,8 @@ class ExternalAuthenticationTestCase(TestCase):
# Local user should have been automatically created # Local user should have been automatically created
new_user = User.objects.get(username='remoteuser2') new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed') self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
@override_settings( @override_settings(
REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_ENABLED=True,
@ -121,7 +124,8 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(settings.REMOTE_AUTH_ENABLED) self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER') self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS, ['Group 1', 'Group 2']) self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS,
['Group 1', 'Group 2'])
# Create required groups # Create required groups
groups = ( groups = (
@ -135,7 +139,8 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2') new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed') self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertListEqual( self.assertListEqual(
[groups[0], groups[1]], [groups[0], groups[1]],
list(new_user.groups.all()) list(new_user.groups.all())
@ -144,7 +149,8 @@ class ExternalAuthenticationTestCase(TestCase):
@override_settings( @override_settings(
REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True, REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None}, REMOTE_AUTH_DEFAULT_PERMISSIONS={
'dcim.add_site': None, 'dcim.change_site': None},
LOGIN_REQUIRED=True LOGIN_REQUIRED=True
) )
def test_remote_auth_default_permissions(self): def test_remote_auth_default_permissions(self):
@ -158,14 +164,102 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(settings.REMOTE_AUTH_ENABLED) self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER') self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None}) self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {
'dcim.add_site': None, 'dcim.change_site': None})
response = self.client.get(reverse('home'), follow=True, **headers) response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2') new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed') self.assertEqual(int(self.client.session.get(
self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site'])) '_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertTrue(new_user.has_perms(
['dcim.add_site', 'dcim.change_site']))
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
LOGIN_REQUIRED=True
)
def test_remote_auth_remote_groups_default(self):
"""
Test enabling remote authentication with group sync enabled with the default configuration.
"""
headers = {
'HTTP_REMOTE_USER': 'remoteuser2',
'HTTP_REMOTE_USER_GROUP': 'Group 1|Group 2',
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER,
'HTTP_REMOTE_USER_GROUP')
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
# Create required groups
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertListEqual(
[groups[0], groups[1]],
list(new_user.groups.all())
)
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
REMOTE_AUTH_HEADER='HTTP_FOO',
REMOTE_AUTH_GROUP_HEADER='HTTP_BAR',
LOGIN_REQUIRED=True
)
def test_remote_auth_remote_groups_custom_header(self):
"""
Test enabling remote authentication with group sync enabled with the default configuration.
"""
headers = {
'HTTP_FOO': 'remoteuser2',
'HTTP_BAR': 'Group 1|Group 2',
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_FOO')
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, 'HTTP_BAR')
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
# Create required groups
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertListEqual(
[groups[0], groups[1]],
list(new_user.groups.all())
)
class ObjectPermissionAPIViewTestCase(TestCase): class ObjectPermissionAPIViewTestCase(TestCase):
@ -206,7 +300,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
def test_get_object(self): def test_get_object(self):
# Attempt to retrieve object without permission # Attempt to retrieve object without permission
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -221,12 +316,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object # Retrieve permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Attempt to retrieve non-permitted object # Attempt to retrieve non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[3].pk})
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@ -292,7 +389,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Attempt to edit an object without permission # Attempt to edit an object without permission
data = {'site': self.sites[0].pk} data = {'site': self.sites[0].pk}
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -308,19 +406,22 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Attempt to edit a non-permitted object # Attempt to edit a non-permitted object
data = {'site': self.sites[0].pk} data = {'site': self.sites[0].pk}
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[3].pk})
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# Edit a permitted object # Edit a permitted object
data['status'] = 'reserved' data['status'] = 'reserved'
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Attempt to modify a permitted object to a non-permitted object # Attempt to modify a permitted object to a non-permitted object
data['site'] = self.sites[1].pk data['site'] = self.sites[1].pk
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -328,7 +429,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
def test_delete_object(self): def test_delete_object(self):
# Attempt to delete an object without permission # Attempt to delete an object without permission
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.delete(url, format='json', **self.header) response = self.client.delete(url, format='json', **self.header)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -343,11 +445,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete a non-permitted object # Attempt to delete a non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[3].pk})
response = self.client.delete(url, format='json', **self.header) response = self.client.delete(url, format='json', **self.header)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# Delete a permitted object # Delete a permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.delete(url, format='json', **self.header) response = self.client.delete(url, format='json', **self.header)
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)

View File

@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include from django.conf.urls import include
from django.urls import path, re_path from django.urls import path, re_path
from django.views.decorators.csrf import csrf_exempt
from django.views.static import serve from django.views.static import serve
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
@ -63,7 +64,7 @@ _patterns = [
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
# GraphQL # GraphQL
path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema), name='graphql'), path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware # Serving static media in Django to pipe it through LoginRequiredMiddleware
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}), path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),

View File

@ -26,7 +26,6 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
from netbox.forms import SearchForm from netbox.forms import SearchForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.tables import paginate_table
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
@ -154,26 +153,18 @@ class HomeView(View):
class SearchView(View): class SearchView(View):
def get(self, request): def get(self, request):
# No query
if 'q' not in request.GET:
return render(request, 'search.html', {
'form': SearchForm(),
})
form = SearchForm(request.GET) form = SearchForm(request.GET)
results = [] results = []
if form.is_valid(): if form.is_valid():
# If an object type has been specified, redirect to the dedicated view for it
if form.cleaned_data['obj_type']: if form.cleaned_data['obj_type']:
# Searching for a single type of object object_type = form.cleaned_data['obj_type']
obj_types = [form.cleaned_data['obj_type']] url = reverse(SEARCH_TYPES[object_type]['url'])
else: return redirect(f"{url}?q={form.cleaned_data['q']}")
# Searching all object types
obj_types = SEARCH_TYPES.keys()
for obj_type in obj_types: for obj_type in SEARCH_TYPES.keys():
queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view') queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
filterset = SEARCH_TYPES[obj_type]['filterset'] filterset = SEARCH_TYPES[obj_type]['filterset']

View File

@ -1010,10 +1010,10 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Are we deleting *all* objects in the queryset or just a selected subset? # Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'): if request.POST.get('_all'):
qs = model.objects.all()
if self.filterset is not None: if self.filterset is not None:
pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs] qs = self.filterset(request.GET, qs).qs
else: pk_list = qs.only('pk').values_list('pk', flat=True)
pk_list = model.objects.values_list('pk', flat=True)
else: else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')] pk_list = [int(pk) for pk in request.POST.getlist('pk')]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -149,13 +149,6 @@ export class APISelect {
*/ */
private more: Nullable<string> = null; private more: Nullable<string> = null;
/**
* This element's options come from the server pre-sorted and should not be sorted client-side.
* Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element, or
* by existence of specific fields such as `_depth`.
*/
private preSorted: boolean = false;
/** /**
* Array of options values which should be considered disabled or static. * Array of options values which should be considered disabled or static.
*/ */
@ -171,10 +164,6 @@ export class APISelect {
this.base = base; this.base = base;
this.name = base.name; this.name = base.name;
if (base.getAttribute('pre-sorted') !== null) {
this.preSorted = true;
}
if (hasUrl(base)) { if (hasUrl(base)) {
const url = base.getAttribute('data-url') as string; const url = base.getAttribute('data-url') as string;
this.url = url; this.url = url;
@ -294,9 +283,7 @@ export class APISelect {
} }
/** /**
* Sort incoming options by label and apply the new options to both the SlimSelect instance and * Apply new options to both the SlimSelect instance and this manager's state.
* this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
* the options will *not* be sorted.
*/ */
private set options(optionsIn: Option[]) { private set options(optionsIn: Option[]) {
let newOptions = optionsIn; let newOptions = optionsIn;
@ -304,12 +291,6 @@ export class APISelect {
if (this.nullOption !== null) { if (this.nullOption !== null) {
newOptions = [this.nullOption, ...newOptions]; newOptions = [this.nullOption, ...newOptions];
} }
// Sort options unless this element is pre-sorted.
if (!this.preSorted) {
newOptions = newOptions.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
);
}
// Deduplicate options each time they're set. // Deduplicate options each time they're set.
const deduplicated = uniqueByProperty(newOptions, 'value'); const deduplicated = uniqueByProperty(newOptions, 'value');
// Determine if the new options have a placeholder. // Determine if the new options have a placeholder.
@ -471,9 +452,6 @@ export class APISelect {
if (typeof result._depth === 'number' && result._depth > 0) { if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text. // If the object has a `_depth` property, indent its display text.
if (!this.preSorted) {
this.preSorted = true;
}
text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`; text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
} }
const data = {} as Record<string, string>; const data = {} as Record<string, string>;

View File

@ -409,7 +409,7 @@ export function createElement<
* @returns Degrees in Fahrenheit. * @returns Degrees in Fahrenheit.
*/ */
export function cToF(celsius: number): number { export function cToF(celsius: number): number {
return celsius * (9 / 5) + 32; return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10;
} }
/** /**

View File

@ -19,7 +19,7 @@ $spacing-s: $input-padding-x;
--nbx-select-option-selected-bg: #{$gray-300}; --nbx-select-option-selected-bg: #{$gray-300};
--nbx-select-option-hover-bg: #{$blue}; --nbx-select-option-hover-bg: #{$blue};
--nbx-select-option-hover-color: #{$white}; --nbx-select-option-hover-color: #{$white};
--nbx-select-placeholder-color: #{$gray-600}; --nbx-select-placeholder-color: #{$gray-500};
--nbx-select-value-color: #{$white}; --nbx-select-value-color: #{$white};
&[data-netbox-color-mode='dark'] { &[data-netbox-color-mode='dark'] {
// Dark Mode Variables. // Dark Mode Variables.
@ -27,7 +27,7 @@ $spacing-s: $input-padding-x;
--nbx-select-option-selected-bg: #{$gray-500}; --nbx-select-option-selected-bg: #{$gray-500};
--nbx-select-option-hover-bg: #{$blue-200}; --nbx-select-option-hover-bg: #{$blue-200};
--nbx-select-option-hover-color: #{color-contrast($blue-200)}; --nbx-select-option-hover-color: #{color-contrast($blue-200)};
--nbx-select-placeholder-color: #{$gray-500}; --nbx-select-placeholder-color: #{$gray-700};
--nbx-select-value-color: #{$black}; --nbx-select-value-color: #{$black};
} }
} }

View File

@ -82,7 +82,7 @@ $input-border-color: $gray-700;
$input-focus-bg: $input-bg; $input-focus-bg: $input-bg;
$input-focus-border-color: tint-color($component-active-bg, 10%); $input-focus-border-color: tint-color($component-active-bg, 10%);
$input-focus-color: $input-color; $input-focus-color: $input-color;
$input-placeholder-color: $gray-300; $input-placeholder-color: $gray-700;
$input-plaintext-color: $body-color; $input-plaintext-color: $body-color;
$form-check-input-active-filter: brightness(90%); $form-check-input-active-filter: brightness(90%);

View File

@ -40,6 +40,7 @@ $list-group-disabled-color: $gray-500;
$table-flush-header-bg: $gray-100; $table-flush-header-bg: $gray-100;
$input-placeholder-color: $gray-500;
$form-select-disabled-color: $gray-600; $form-select-disabled-color: $gray-600;
// Tabbed content // Tabbed content

View File

@ -5,7 +5,10 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
{# Cable trace SVG & options #}
<div class="col col-md-5"> <div class="col col-md-5">
{% if path %}
<div class="text-center my-3"> <div class="text-center my-3">
<object data="{{ svg_url }}" class="rack_elevation"></object> <object data="{{ svg_url }}" class="rack_elevation"></object>
<a class="btn btn-outline-primary btn-sm my-3" href="{{ svg_url }}"> <a class="btn btn-outline-primary btn-sm my-3" href="{{ svg_url }}">
@ -51,9 +54,15 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
{% else %}
<h3 class="text-center text-muted my-3">
No paths found
</h3>
{% endif %}
</div> </div>
<div class="col col-md-7">
{# Related paths #}
<div class="col col-md-7">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Related Paths Related Paths
@ -95,7 +104,7 @@
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -95,11 +95,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Serial Number</th> <th scope="row">Serial Number</th>
<td><code>{{ object.serial|placeholder }}</code></td> <td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Asset Tag</th> <th scope="row">Asset Tag</th>
<td><span>{{ object.asset_tag|placeholder }}</span></td> <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -1,4 +1,6 @@
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% if perms.dcim.change_devicetype %} {% if perms.dcim.change_devicetype %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
@ -6,8 +8,8 @@
<h5 class="card-header"> <h5 class="card-header">
{{ title }} {{ title }}
</h5> </h5>
<div class="card-body"> <div class="card-body table-responsive">
{% include 'inc/responsive_table.html' %} {% render_table table 'inc/table.html' %}
</div> </div>
<div class="card-footer noprint"> <div class="card-footer noprint">
{% if table.rows %} {% if table.rows %}
@ -36,8 +38,8 @@
<h5 class="card-header"> <h5 class="card-header">
{{ title }} {{ title }}
</h5> </h5>
<div class="card-body"> <div class="card-body table-responsive">
{% include 'inc/responsive_table.html' %} {% render_table table 'inc/table.html' %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -96,11 +96,11 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Serial Number</th> <th scope="row">Serial Number</th>
<td>{{ object.serial|placeholder }}</td> <td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Asset Tag</th> <th scope="row">Asset Tag</th>
<td>{{ object.asset_tag|placeholder }}</td> <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Devices</th> <th scope="row">Devices</th>

View File

@ -1,4 +1,4 @@
{% extends 'base/layout.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% block title %}{{ report.name }}{% endblock %} {% block title %}{{ report.name }}{% endblock %}
@ -8,31 +8,39 @@
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li> <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
{% endblock %} {% endblock %}
{% block content %} {% block subtitle %}
{% if report.description %} {% if report.description %}
<p class="text-muted">{{ report.description|render_markdown }}</p> <div class="object-subtitle">
{% endif %} <div class="text-muted">{{ report.description|render_markdown }}</div>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
<div class="row">
<div class="col col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
<strong>{{ report.result.created|annotated_date }}</strong>
</a>
{% endif %}
</div> </div>
</div> {% endif %}
{% endblock %}
{% block controls %}{% endblock %}
{% block tabs %}{% endblock %}
{% block content-wrapper %}
{% if perms.extras.run_report %}
<div class="px-3 float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
<div class="row px-3">
<div class="col col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
<strong>{{ report.result.created|annotated_date }}</strong>
</a>
{% endif %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -2,15 +2,13 @@
{% load helpers %} {% load helpers %}
{% load static %} {% load static %}
{% block title %}{{ report.name }} <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>{% endblock %}
{% block head %} {% block head %}
<script src="{% static 'jobs.js' %}?v{{ settings.VERSION }}" <script src="{% static 'jobs.js' %}?v{{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=jobs.js'"></script> onerror="window.location='{% url 'media_failure' %}?filename=jobs.js'"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content-wrapper %}
<div class="row"> <div class="row px-3">
<div class="col col-md-12"> <div class="col col-md-12">
<p> <p>
Run: <strong>{{ result.created|annotated_date }}</strong> Run: <strong>{{ result.created|annotated_date }}</strong>
@ -21,7 +19,7 @@
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
{% endif %} {% endif %}
<span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
</p> </p>
{% if result.completed %} {% if result.completed %}
<div class="card"> <div class="card">
@ -32,7 +30,7 @@
<table class="table table-hover"> <table class="table table-hover">
{% for method, data in result.data.items %} {% for method, data in result.data.items %}
<tr> <tr>
<td><code><a href="#{{ method }}">{{ method }}</a></code></td> <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
<td class="text-end report-stats"> <td class="text-end report-stats">
<span class="badge bg-success">{{ data.success }}</span> <span class="badge bg-success">{{ data.success }}</span>
<span class="badge bg-info">{{ data.info }}</span> <span class="badge bg-info">{{ data.info }}</span>

View File

@ -1,4 +1,4 @@
{% extends 'base/layout.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% load log_levels %} {% load log_levels %}
@ -16,6 +16,8 @@
</div> </div>
{% endblock %} {% endblock %}
{% block controls %}{% endblock %}
{% block tabs %} {% block tabs %}
<ul class="nav nav-tabs px-3"> <ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">

View File

@ -79,7 +79,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Payload URL</th> <th scope="row">Payload URL</th>
<td><code>{{ object.payload_url }}</code></td> <td class="font-monospace">{{ object.payload_url }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">HTTP Content Type</th> <th scope="row">HTTP Content Type</th>
@ -110,13 +110,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">CA File Path</th> <th scope="row">CA File Path</th>
<td> <td>{{ object.ca_file_path|placeholder }}</td>
{% if object.ca_file_path %}
<code>{{ object.ca_file_path }}</code>
{% else %}
&mdash
{% endif %}
</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -71,8 +71,8 @@
<i class="mdi mdi-clipboard-clock"></i> <i class="mdi mdi-clipboard-clock"></i>
<span class="ms-1">Change Log</span> <span class="ms-1">Change Log</span>
</h6> </h6>
<div class="card-body"> <div class="card-body table-responsive">
{% include 'inc/responsive_table.html' with table=changelog_table %} {% render_table changelog_table 'inc/table.html' %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,12 @@
{% extends 'base/layout.html' %} {% extends 'base/layout.html' %}
{% load render_table from django_tables2 %}
{% block title %}Import Completed{% endblock %} {% block title %}Import Completed{% endblock %}
{% block content %} {% block content %}
{% include 'inc/responsive_table.html' %} <div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% if return_url %} {% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-dark">View All</a> <a href="{{ return_url }}" class="btn btn-outline-dark">View All</a>
{% endif %} {% endif %}

View File

@ -1,5 +0,0 @@
{% load render_table from django_tables2 %}
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>

View File

@ -77,6 +77,6 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
{% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %} {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -15,9 +15,9 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% include 'inc/table_controls.html' with table_modal="PrefixDetailTable_config" %} {% include 'inc/table_controls.html' with table_modal="PrefixTable_config" %}
{% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} {% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
</div> </div>
</div> </div>
{% table_config_form table table_name="PrefixDetailTable" %} {% table_config_form table table_name="PrefixTable" %}
{% endblock %} {% endblock %}

View File

@ -13,7 +13,7 @@
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">Name</th> <th scope="row">Name</th>
<td><code>{{ object.name }}</code></td> <td class="font-monospace">{{ object.name }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Tenant</th> <th scope="row">Tenant</th>

View File

@ -1,5 +1,6 @@
{% extends 'ipam/vlan/base.html' %} {% extends 'ipam/vlan/base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% load plugins %} {% load plugins %}
{% block content %} {% block content %}
@ -92,8 +93,8 @@
<h5 class="card-header"> <h5 class="card-header">
Prefixes Prefixes
</h5> </h5>
<div class="card-body"> <div class="card-body table-responsive">
{% include 'inc/responsive_table.html' with table=prefix_table %} {% render_table prefix_table 'inc/table.html' %}
</div> </div>
{% if perms.ipam.add_prefix %} {% if perms.ipam.add_prefix %}
<div class="card-footer text-end noprint"> <div class="card-footer text-end noprint">

View File

@ -12,7 +12,7 @@
<div class="col col-md-9"> <div class="col col-md-9">
{% for obj_type in results %} {% for obj_type in results %}
<div class="card"> <div class="card">
<h5 class="card-header">{{ obj_type.name|bettertitle }}</h5> <h5 class="card-header" id="{{ obj_type.name|lower }}">{{ obj_type.name|bettertitle }}</h5>
<div class="card-body table-responsive"> <div class="card-body table-responsive">
{% render_table obj_type.table 'inc/table.html' %} {% render_table obj_type.table 'inc/table.html' %}
</div> </div>
@ -54,7 +54,7 @@
{% endif %} {% endif %}
{% else %} {% else %}
<div class="row"> <div class="row">
<div class="col col-12 col-lg-4 offset-lg-4"> <div class="col col-12 col-lg-6 offset-lg-3">
<form action="{% url 'search' %}" method="get" class="form form-horizontal"> <form action="{% url 'search' %}" method="get" class="form form-horizontal">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">

View File

@ -1,4 +1,5 @@
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% if permissions.change or permissions.delete %} {% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal"> <form method="post" class="form form-horizontal">
@ -7,6 +8,7 @@
{% if table.paginator.num_pages > 1 %} {% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card noprint"> <div id="select-all-box" class="d-none card noprint">
<div class="card-body">
<div class="float-end"> <div class="float-end">
{% if bulk_edit_url and permissions.change %} {% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled"> <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
@ -25,10 +27,13 @@
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label> </label>
</div> </div>
</div>
</div> </div>
{% endif %} {% endif %}
{% include table_template|default:'inc/responsive_table.html' %} <div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
<div class="float-start noprint"> <div class="float-start noprint">
{% block extra_actions %}{% endblock %} {% block extra_actions %}{% endblock %}
@ -48,7 +53,9 @@
</form> </form>
{% else %} {% else %}
{% include table_template|default:'inc/responsive_table.html' %} <div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% endif %} {% endif %}

View File

@ -1,5 +1,6 @@
{% extends 'virtualization/cluster/base.html' %} {% extends 'virtualization/cluster/base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -10,8 +11,8 @@
</h5> </h5>
<form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post"> <form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
{% csrf_token %} {% csrf_token %}
<div class="card-body"> <div class="card-body table-responsive">
{% include 'inc/responsive_table.html' with table=devices_table %} {% render_table devices_table 'inc/table.html' %}
</div> </div>
{% if perms.virtualization.change_cluster %} {% if perms.virtualization.change_cluster %}
<div class="card-footer noprint"> <div class="card-footer noprint">

View File

@ -1,5 +1,6 @@
{% extends 'virtualization/cluster/base.html' %} {% extends 'virtualization/cluster/base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@ -8,8 +9,8 @@
<h5 class="card-header"> <h5 class="card-header">
Virtual Machines Virtual Machines
</h5> </h5>
<div class="card-body"> <div class="card-body table-responsive">
{% include 'inc/responsive_table.html' with table=virtualmachines_table %} {% render_table virtualmachines_table 'inc/table.html' %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0001_squashed_0012'),
]
operations = [
migrations.AlterModelOptions(
name='tenant',
options={'ordering': ['name']},
),
]

View File

@ -83,7 +83,7 @@ class Tenant(PrimaryModel):
] ]
class Meta: class Meta:
ordering = ['group', 'name'] ordering = ['name']
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,8 +1,16 @@
import django_tables2 as tables import django_tables2 as tables
from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn from utilities.tables import (
BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
__all__ = (
'TenantColumn',
'TenantGroupTable',
'TenantTable',
)
# #
# Table columns # Table columns
@ -60,11 +68,12 @@ class TenantTable(BaseTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='tenancy:tenant_list' url_name='tenancy:tenant_list'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Tenant model = Tenant
fields = ('pk', 'name', 'slug', 'group', 'description', 'tags') fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'group', 'description') default_columns = ('pk', 'name', 'group', 'description')

View File

@ -32,9 +32,7 @@ class TenantGroupView(generic.ObjectView):
tenants = Tenant.objects.restrict(request.user, 'view').filter( tenants = Tenant.objects.restrict(request.user, 'view').filter(
group=instance group=instance
) )
tenants_table = tables.TenantTable(tenants, exclude=('group',))
tenants_table = tables.TenantTable(tenants)
tenants_table.columns.hide('group')
paginate_table(tenants_table, request) paginate_table(tenants_table, request)
return { return {

View File

@ -122,28 +122,32 @@ def get_selected_values(form, field_name):
form.is_valid() form.is_valid()
filter_data = form.cleaned_data.get(field_name) filter_data = form.cleaned_data.get(field_name)
field = form.fields[field_name] field = form.fields[field_name]
# Selection field
if hasattr(field, 'choices'):
try:
choices = unpack_grouped_choices(field.choices)
if hasattr(field, 'null_option'):
# If the field has a `null_option` attribute set and it is selected,
# add it to the field's grouped choices.
if field.null_option is not None and None in filter_data:
choices.append((settings.FILTERS_NULL_CHOICE_VALUE, field.null_option))
return [
label for value, label in choices if str(value) in filter_data or None in filter_data
]
except TypeError:
# Field uses dynamic choices. Show all that have been populated.
return [
subwidget.choice_label for subwidget in form[field_name].subwidgets
]
# Non-selection field # Non-selection field
return [str(filter_data)] if not hasattr(field, 'choices'):
return [str(filter_data)]
# Get choice labels
if type(field.choices) is forms.models.ModelChoiceIterator:
# Field uses dynamic choices: show all that have been populated on the widget
values = [
subwidget.choice_label for subwidget in form[field_name].subwidgets
]
else:
# Static selection field
choices = unpack_grouped_choices(field.choices)
values = [
label for value, label in choices if str(value) in filter_data or None in filter_data
]
if hasattr(field, 'null_option'):
# If the field has a `null_option` attribute set and it is selected,
# add it to the field's grouped choices.
if field.null_option is not None and None in filter_data:
values.append(field.null_option)
return values
def add_blank_choice(choices): def add_blank_choice(choices):

View File

@ -12,7 +12,6 @@ from django_tables2.data import TableQuerysetData
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from extras.models import CustomField from extras.models import CustomField
from extras.utils import FeatureQuery
from .utils import content_type_name from .utils import content_type_name
from .paginator import EnhancedPaginator, get_paginate_count from .paginator import EnhancedPaginator, get_paginate_count
@ -395,6 +394,28 @@ class UtilizationColumn(tables.TemplateColumn):
return f'{value}%' return f'{value}%'
class MarkdownColumn(tables.TemplateColumn):
"""
Render a Markdown string.
"""
template_code = """
{% load helpers %}
{% if value %}
{{ value|render_markdown }}
{% else %}
&mdash;
{% endif %}
"""
def __init__(self):
super().__init__(
template_code=self.template_code
)
def value(self, value):
return value
# #
# Pagination # Pagination
# #

View File

@ -411,10 +411,10 @@ def applied_filters(form, query_params):
Display the active filters for a given filter form. Display the active filters for a given filter form.
""" """
form.is_valid() form.is_valid()
querydict = query_params.copy()
applied_filters = [] applied_filters = []
for filter_name in form.changed_data: for filter_name in form.changed_data:
querydict = query_params.copy()
if filter_name not in querydict: if filter_name not in querydict:
continue continue

View File

@ -1 +0,0 @@
default_app_config = 'virtualization.apps.VirtualizationConfig'

View File

@ -9,7 +9,7 @@ from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
CustomFieldModelFilterForm, CustomFieldsMixin, CustomFieldModelFilterForm, CustomFieldsMixin, LocalConfigContextFilterForm,
) )
from extras.models import Tag from extras.models import Tag
from ipam.models import IPAddress, VLAN, VLANGroup from ipam.models import IPAddress, VLAN, VLANGroup
@ -61,6 +61,18 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description'] nullable_fields = ['description']
class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = ClusterType
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Cluster groups # Cluster groups
# #
@ -97,6 +109,18 @@ class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description'] nullable_fields = ['description']
class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = ClusterGroup
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
# #
# Clusters # Clusters
# #
@ -545,13 +569,13 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
] ]
class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class VirtualMachineFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualMachine model = VirtualMachine
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['cluster_group_id', 'cluster_type_id', 'cluster_id'], ['cluster_group_id', 'cluster_type_id', 'cluster_id'],
['region_id', 'site_group_id', 'site_id'], ['region_id', 'site_group_id', 'site_id'],
['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip'], ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
['tenant_group_id', 'tenant_id'], ['tenant_group_id', 'tenant_id'],
] ]
q = forms.CharField( q = forms.CharField(

View File

@ -3,7 +3,8 @@ from django.conf import settings
from dcim.tables.devices import BaseInterfaceTable from dcim.tables.devices import BaseInterfaceTable
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn,
ToggleColumn,
) )
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -11,12 +12,13 @@ __all__ = (
'ClusterTable', 'ClusterTable',
'ClusterGroupTable', 'ClusterGroupTable',
'ClusterTypeTable', 'ClusterTypeTable',
'VirtualMachineDetailTable',
'VirtualMachineTable', 'VirtualMachineTable',
'VirtualMachineVMInterfaceTable', 'VirtualMachineVMInterfaceTable',
'VMInterfaceTable', 'VMInterfaceTable',
) )
PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
VMINTERFACE_BUTTONS = """ VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address"> <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address">
@ -91,13 +93,14 @@ class ClusterTable(BaseTable):
url_params={'cluster_id': 'pk'}, url_params={'cluster_id': 'pk'},
verbose_name='VMs' verbose_name='VMs'
) )
comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='virtualization:cluster_list' url_name='virtualization:cluster_list'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Cluster model = Cluster
fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count', 'tags') fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags')
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
@ -116,13 +119,7 @@ class VirtualMachineTable(BaseTable):
) )
role = ColoredLabelColumn() role = ColoredLabelColumn()
tenant = TenantColumn() tenant = TenantColumn()
comments = MarkdownColumn()
class Meta(BaseTable.Meta):
model = VirtualMachine
fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk')
class VirtualMachineDetailTable(VirtualMachineTable):
primary_ip4 = tables.Column( primary_ip4 = tables.Column(
linkify=True, linkify=True,
verbose_name='IPv4 Address' verbose_name='IPv4 Address'
@ -131,18 +128,11 @@ class VirtualMachineDetailTable(VirtualMachineTable):
linkify=True, linkify=True,
verbose_name='IPv6 Address' verbose_name='IPv6 Address'
) )
if settings.PREFER_IPV4: primary_ip = tables.Column(
primary_ip = tables.Column( linkify=True,
linkify=True, order_by=PRIMARY_IP_ORDERING,
order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address'
verbose_name='IP Address' )
)
else:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
tags = TagColumn( tags = TagColumn(
url_name='virtualization:virtualmachine_list' url_name='virtualization:virtualmachine_list'
) )
@ -151,7 +141,7 @@ class VirtualMachineDetailTable(VirtualMachineTable):
model = VirtualMachine model = VirtualMachine
fields = ( fields = (
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
'primary_ip6', 'primary_ip', 'tags', 'primary_ip6', 'primary_ip', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',

View File

@ -24,6 +24,8 @@ class ClusterTypeListView(generic.ObjectListView):
queryset = ClusterType.objects.annotate( queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type') cluster_count=count_related(Cluster, 'type')
) )
filterset = filtersets.ClusterTypeFilterSet
filterset_form = forms.ClusterTypeFilterForm
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
@ -37,9 +39,7 @@ class ClusterTypeView(generic.ObjectView):
device_count=count_related(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
) )
clusters_table = tables.ClusterTable(clusters, exclude=('type',))
clusters_table = tables.ClusterTable(clusters)
clusters_table.columns.hide('type')
paginate_table(clusters_table, request) paginate_table(clusters_table, request)
return { return {
@ -86,6 +86,8 @@ class ClusterGroupListView(generic.ObjectListView):
queryset = ClusterGroup.objects.annotate( queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group') cluster_count=count_related(Cluster, 'group')
) )
filterset = filtersets.ClusterGroupFilterSet
filterset_form = forms.ClusterGroupFilterForm
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
@ -99,9 +101,7 @@ class ClusterGroupView(generic.ObjectView):
device_count=count_related(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
) )
clusters_table = tables.ClusterTable(clusters, exclude=('group',))
clusters_table = tables.ClusterTable(clusters)
clusters_table.columns.hide('group')
paginate_table(clusters_table, request) paginate_table(clusters_table, request)
return { return {
@ -167,7 +167,11 @@ class ClusterVirtualMachinesView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
virtualmachines = VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=instance) virtualmachines = VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=instance)
virtualmachines_table = tables.VirtualMachineTable(virtualmachines, orderable=False) virtualmachines_table = tables.VirtualMachineTable(
virtualmachines,
exclude=('cluster',),
orderable=False
)
return { return {
'virtualmachines_table': virtualmachines_table, 'virtualmachines_table': virtualmachines_table,
@ -311,7 +315,7 @@ class VirtualMachineListView(generic.ObjectListView):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
filterset = filtersets.VirtualMachineFilterSet filterset = filtersets.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm filterset_form = forms.VirtualMachineFilterForm
table = tables.VirtualMachineDetailTable table = tables.VirtualMachineTable
template_name = 'virtualization/virtualmachine_list.html' template_name = 'virtualization/virtualmachine_list.html'
@ -426,9 +430,9 @@ class VMInterfaceView(generic.ObjectView):
child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance) child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.VMInterfaceTable( child_interfaces_tables = tables.VMInterfaceTable(
child_interfaces, child_interfaces,
exclude=('virtual_machine',),
orderable=False orderable=False
) )
child_interfaces_tables.columns.hide('virtual_machine')
# Get assigned VLANs and annotate whether each is tagged or untagged # Get assigned VLANs and annotate whether each is tagged or untagged
vlans = [] vlans = []