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
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
*.min.* binary
*.map binary
*.pack.js binary
# Treat compiled JS/CSS files as binary, as they're not meant to be human-readable
netbox/project-static/dist/*.css binary
netbox/project-static/dist/*.js binary
netbox/project-static/dist/*.js.map binary

View File

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

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.0.2
placeholder: v3.0.3
validations:
required: true
- 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.
{!docs/models/users/objectpermission.md!}
{!models/users/objectpermission.md!}
### 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
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
Default: `'HTTP_REMOTE_USER'`
@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
---
## REMOTE_AUTH_GROUP_HEADER
Default: `'HTTP_REMOTE_USER_GROUP'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSER_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSERS
Default: `[]` (Empty list)
The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_USERS
Default: `[]` (Empty list)
The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_GROUP_SEPARATOR
Default: `|` (Pipe)
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## RELEASE_CHECK_URL
Default: None (disabled)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# VLAN Management
{!docs/models/ipam/vlan.md!}
{!docs/models/ipam/vlangroup.md!}
{!models/ipam/vlan.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
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

View File

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

View File

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

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
* **Virtualization** - Virtual machines and clusters
* **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
## 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.
<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

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:
![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:

View File

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

View File

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

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.
{!docs/models/users/token.md!}
{!models/users/token.md!}
## Authenticating to the API

View File

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

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
#

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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;
/**
* 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.
*/
@ -171,10 +164,6 @@ export class APISelect {
this.base = base;
this.name = base.name;
if (base.getAttribute('pre-sorted') !== null) {
this.preSorted = true;
}
if (hasUrl(base)) {
const url = base.getAttribute('data-url') as string;
this.url = url;
@ -294,9 +283,7 @@ export class APISelect {
}
/**
* Sort incoming options by label and apply the new options to both the SlimSelect instance and
* this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
* the options will *not* be sorted.
* Apply new options to both the SlimSelect instance and this manager's state.
*/
private set options(optionsIn: Option[]) {
let newOptions = optionsIn;
@ -304,12 +291,6 @@ export class APISelect {
if (this.nullOption !== null) {
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.
const deduplicated = uniqueByProperty(newOptions, 'value');
// Determine if the new options have a placeholder.
@ -471,9 +452,6 @@ export class APISelect {
if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text.
if (!this.preSorted) {
this.preSorted = true;
}
text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
}
const data = {} as Record<string, string>;

View File

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

View File

@ -19,7 +19,7 @@ $spacing-s: $input-padding-x;
--nbx-select-option-selected-bg: #{$gray-300};
--nbx-select-option-hover-bg: #{$blue};
--nbx-select-option-hover-color: #{$white};
--nbx-select-placeholder-color: #{$gray-600};
--nbx-select-placeholder-color: #{$gray-500};
--nbx-select-value-color: #{$white};
&[data-netbox-color-mode='dark'] {
// Dark Mode Variables.
@ -27,7 +27,7 @@ $spacing-s: $input-padding-x;
--nbx-select-option-selected-bg: #{$gray-500};
--nbx-select-option-hover-bg: #{$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};
}
}

View File

@ -82,7 +82,7 @@ $input-border-color: $gray-700;
$input-focus-bg: $input-bg;
$input-focus-border-color: tint-color($component-active-bg, 10%);
$input-focus-color: $input-color;
$input-placeholder-color: $gray-300;
$input-placeholder-color: $gray-700;
$input-plaintext-color: $body-color;
$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;
$input-placeholder-color: $gray-500;
$form-select-disabled-color: $gray-600;
// Tabbed content

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
{% extends 'base/layout.html' %}
{% extends 'generic/object.html' %}
{% load helpers %}
{% 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>
{% endblock %}
{% block content %}
{% if report.description %}
<p class="text-muted">{{ report.description|render_markdown }}</p>
{% endif %}
{% 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 %}
{% block subtitle %}
{% if report.description %}
<div class="object-subtitle">
<div class="text-muted">{{ report.description|render_markdown }}</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 %}

View File

@ -2,15 +2,13 @@
{% load helpers %}
{% load static %}
{% block title %}{{ report.name }} <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>{% endblock %}
{% block head %}
<script src="{% static 'jobs.js' %}?v{{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=jobs.js'"></script>
{% endblock %}
{% block content %}
<div class="row">
{% block content-wrapper %}
<div class="row px-3">
<div class="col col-md-12">
<p>
Run: <strong>{{ result.created|annotated_date }}</strong>
@ -21,7 +19,7 @@
<span class="visually-hidden">Loading...</span>
</div>
{% endif %}
<span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
</p>
{% if result.completed %}
<div class="card">
@ -32,7 +30,7 @@
<table class="table table-hover">
{% for method, data in result.data.items %}
<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">
<span class="badge bg-success">{{ data.success }}</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 form_helpers %}
{% load log_levels %}
@ -16,6 +16,8 @@
</div>
{% endblock %}
{% block controls %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">

View File

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

View File

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

View File

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

View File

@ -15,9 +15,9 @@
{% block content %}
<div class="row">
<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 %}
</div>
</div>
{% table_config_form table table_name="PrefixDetailTable" %}
{% table_config_form table table_name="PrefixTable" %}
{% endblock %}

View File

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

View File

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

View File

@ -12,7 +12,7 @@
<div class="col col-md-9">
{% for obj_type in results %}
<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">
{% render_table obj_type.table 'inc/table.html' %}
</div>
@ -54,7 +54,7 @@
{% endif %}
{% else %}
<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">
<div class="card">
<h5 class="card-header">

View File

@ -1,4 +1,5 @@
{% load helpers %}
{% load render_table from django_tables2 %}
{% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal">
@ -7,6 +8,7 @@
{% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card noprint">
<div class="card-body">
<div class="float-end">
{% 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">
@ -25,10 +27,13 @@
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
</div>
</div>
{% 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">
{% block extra_actions %}{% endblock %}
@ -48,7 +53,9 @@
</form>
{% else %}
{% include table_template|default:'inc/responsive_table.html' %}
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% endif %}

View File

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

View File

@ -1,5 +1,6 @@
{% extends 'virtualization/cluster/base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block content %}
<div class="row">
@ -8,8 +9,8 @@
<h5 class="card-header">
Virtual Machines
</h5>
<div class="card-body">
{% include 'inc/responsive_table.html' with table=virtualmachines_table %}
<div class="card-body table-responsive">
{% render_table virtualmachines_table 'inc/table.html' %}
</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:
ordering = ['group', 'name']
ordering = ['name']
def __str__(self):
return self.name

View File

@ -1,8 +1,16 @@
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
__all__ = (
'TenantColumn',
'TenantGroupTable',
'TenantTable',
)
#
# Table columns
@ -60,11 +68,12 @@ class TenantTable(BaseTable):
name = tables.Column(
linkify=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='tenancy:tenant_list'
)
class Meta(BaseTable.Meta):
model = Tenant
fields = ('pk', 'name', 'slug', 'group', 'description', 'tags')
fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'group', 'description')

View File

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

View File

@ -122,28 +122,32 @@ def get_selected_values(form, field_name):
form.is_valid()
filter_data = form.cleaned_data.get(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
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):

View File

@ -12,7 +12,6 @@ from django_tables2.data import TableQuerysetData
from django_tables2.utils import Accessor
from extras.models import CustomField
from extras.utils import FeatureQuery
from .utils import content_type_name
from .paginator import EnhancedPaginator, get_paginate_count
@ -395,6 +394,28 @@ class UtilizationColumn(tables.TemplateColumn):
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
#

View File

@ -411,10 +411,10 @@ def applied_filters(form, query_params):
Display the active filters for a given filter form.
"""
form.is_valid()
querydict = query_params.copy()
applied_filters = []
for filter_name in form.changed_data:
querydict = query_params.copy()
if filter_name not in querydict:
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 extras.forms import (
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
CustomFieldModelFilterForm, CustomFieldsMixin,
CustomFieldModelFilterForm, CustomFieldsMixin, LocalConfigContextFilterForm,
)
from extras.models import Tag
from ipam.models import IPAddress, VLAN, VLANGroup
@ -61,6 +61,18 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
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
#
@ -97,6 +109,18 @@ class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
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
#
@ -545,13 +569,13 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
]
class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class VirtualMachineFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualMachine
field_groups = [
['q', 'tag'],
['cluster_group_id', 'cluster_type_id', 'cluster_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'],
]
q = forms.CharField(

View File

@ -3,7 +3,8 @@ from django.conf import settings
from dcim.tables.devices import BaseInterfaceTable
from tenancy.tables import TenantColumn
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
@ -11,12 +12,13 @@ __all__ = (
'ClusterTable',
'ClusterGroupTable',
'ClusterTypeTable',
'VirtualMachineDetailTable',
'VirtualMachineTable',
'VirtualMachineVMInterfaceTable',
'VMInterfaceTable',
)
PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
VMINTERFACE_BUTTONS = """
{% 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">
@ -91,13 +93,14 @@ class ClusterTable(BaseTable):
url_params={'cluster_id': 'pk'},
verbose_name='VMs'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='virtualization:cluster_list'
)
class Meta(BaseTable.Meta):
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')
@ -116,13 +119,7 @@ class VirtualMachineTable(BaseTable):
)
role = ColoredLabelColumn()
tenant = TenantColumn()
class Meta(BaseTable.Meta):
model = VirtualMachine
fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk')
class VirtualMachineDetailTable(VirtualMachineTable):
comments = MarkdownColumn()
primary_ip4 = tables.Column(
linkify=True,
verbose_name='IPv4 Address'
@ -131,18 +128,11 @@ class VirtualMachineDetailTable(VirtualMachineTable):
linkify=True,
verbose_name='IPv6 Address'
)
if settings.PREFER_IPV4:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip4', 'primary_ip6'),
verbose_name='IP Address'
)
else:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
primary_ip = tables.Column(
linkify=True,
order_by=PRIMARY_IP_ORDERING,
verbose_name='IP Address'
)
tags = TagColumn(
url_name='virtualization:virtualmachine_list'
)
@ -151,7 +141,7 @@ class VirtualMachineDetailTable(VirtualMachineTable):
model = VirtualMachine
fields = (
'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 = (
'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(
cluster_count=count_related(Cluster, 'type')
)
filterset = filtersets.ClusterTypeFilterSet
filterset_form = forms.ClusterTypeFilterForm
table = tables.ClusterTypeTable
@ -37,9 +39,7 @@ class ClusterTypeView(generic.ObjectView):
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
clusters_table = tables.ClusterTable(clusters)
clusters_table.columns.hide('type')
clusters_table = tables.ClusterTable(clusters, exclude=('type',))
paginate_table(clusters_table, request)
return {
@ -86,6 +86,8 @@ class ClusterGroupListView(generic.ObjectListView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
)
filterset = filtersets.ClusterGroupFilterSet
filterset_form = forms.ClusterGroupFilterForm
table = tables.ClusterGroupTable
@ -99,9 +101,7 @@ class ClusterGroupView(generic.ObjectView):
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
clusters_table = tables.ClusterTable(clusters)
clusters_table.columns.hide('group')
clusters_table = tables.ClusterTable(clusters, exclude=('group',))
paginate_table(clusters_table, request)
return {
@ -167,7 +167,11 @@ class ClusterVirtualMachinesView(generic.ObjectView):
def get_extra_context(self, request, 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 {
'virtualmachines_table': virtualmachines_table,
@ -311,7 +315,7 @@ class VirtualMachineListView(generic.ObjectListView):
queryset = VirtualMachine.objects.all()
filterset = filtersets.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm
table = tables.VirtualMachineDetailTable
table = tables.VirtualMachineTable
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_tables = tables.VMInterfaceTable(
child_interfaces,
exclude=('virtual_machine',),
orderable=False
)
child_interfaces_tables.columns.hide('virtual_machine')
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []