diff --git a/.gitattributes b/.gitattributes index 9ad1ee25e..d431ac5f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index e04b0e602..b3558959f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 61a36fb0c..522c328fa 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index c7c8996dc..97b691c1d 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -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 diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index ee956edf5..531f9c027 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -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 -``` diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index e324f3d46..88dd80918 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -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) diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 51261858c..b1b02e300 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -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!} diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md index a965f51f0..037d3cfd0 100644 --- a/docs/core-functionality/device-types.md +++ b/docs/core-functionality/device-types.md @@ -1,7 +1,7 @@ # Device Types -{!docs/models/dcim/devicetype.md!} -{!docs/models/dcim/manufacturer.md!} +{!models/dcim/devicetype.md!} +{!models/dcim/manufacturer.md!} --- @@ -30,11 +30,11 @@ Once component templates have been created, every new device that you create as !!! note Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices. -{!docs/models/dcim/consoleporttemplate.md!} -{!docs/models/dcim/consoleserverporttemplate.md!} -{!docs/models/dcim/powerporttemplate.md!} -{!docs/models/dcim/poweroutlettemplate.md!} -{!docs/models/dcim/interfacetemplate.md!} -{!docs/models/dcim/frontporttemplate.md!} -{!docs/models/dcim/rearporttemplate.md!} -{!docs/models/dcim/devicebaytemplate.md!} +{!models/dcim/consoleporttemplate.md!} +{!models/dcim/consoleserverporttemplate.md!} +{!models/dcim/powerporttemplate.md!} +{!models/dcim/poweroutlettemplate.md!} +{!models/dcim/interfacetemplate.md!} +{!models/dcim/frontporttemplate.md!} +{!models/dcim/rearporttemplate.md!} +{!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index e05d6efd3..67e3612b9 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -1,8 +1,8 @@ # Devices and Cabling -{!docs/models/dcim/device.md!} -{!docs/models/dcim/devicerole.md!} -{!docs/models/dcim/platform.md!} +{!models/dcim/device.md!} +{!models/dcim/devicerole.md!} +{!models/dcim/platform.md!} --- @@ -10,20 +10,20 @@ Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. -{!docs/models/dcim/consoleport.md!} -{!docs/models/dcim/consoleserverport.md!} -{!docs/models/dcim/powerport.md!} -{!docs/models/dcim/poweroutlet.md!} -{!docs/models/dcim/interface.md!} -{!docs/models/dcim/frontport.md!} -{!docs/models/dcim/rearport.md!} -{!docs/models/dcim/devicebay.md!} -{!docs/models/dcim/inventoryitem.md!} +{!models/dcim/consoleport.md!} +{!models/dcim/consoleserverport.md!} +{!models/dcim/powerport.md!} +{!models/dcim/poweroutlet.md!} +{!models/dcim/interface.md!} +{!models/dcim/frontport.md!} +{!models/dcim/rearport.md!} +{!models/dcim/devicebay.md!} +{!models/dcim/inventoryitem.md!} --- -{!docs/models/dcim/virtualchassis.md!} +{!models/dcim/virtualchassis.md!} --- -{!docs/models/dcim/cable.md!} +{!models/dcim/cable.md!} diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 00267dfe0..c1e77069e 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -1,19 +1,19 @@ # IP Address Management -{!docs/models/ipam/aggregate.md!} -{!docs/models/ipam/rir.md!} +{!models/ipam/aggregate.md!} +{!models/ipam/rir.md!} --- -{!docs/models/ipam/prefix.md!} -{!docs/models/ipam/role.md!} +{!models/ipam/prefix.md!} +{!models/ipam/role.md!} --- -{!docs/models/ipam/iprange.md!} -{!docs/models/ipam/ipaddress.md!} +{!models/ipam/iprange.md!} +{!models/ipam/ipaddress.md!} --- -{!docs/models/ipam/vrf.md!} -{!docs/models/ipam/routetarget.md!} +{!models/ipam/vrf.md!} +{!models/ipam/routetarget.md!} diff --git a/docs/core-functionality/power.md b/docs/core-functionality/power.md index 571109936..bdefb2afd 100644 --- a/docs/core-functionality/power.md +++ b/docs/core-functionality/power.md @@ -1,8 +1,8 @@ # Power Tracking -{!docs/models/dcim/powerpanel.md!} -{!docs/models/dcim/powerfeed.md!} +{!models/dcim/powerpanel.md!} +{!models/dcim/powerfeed.md!} # Example Power Topology -![Power distribution model](../../media/power_distribution.png) +![Power distribution model](/media/power_distribution.png) diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md index 4d4256081..2e7aaf65a 100644 --- a/docs/core-functionality/services.md +++ b/docs/core-functionality/services.md @@ -1,3 +1,3 @@ # Service Mapping -{!docs/models/ipam/service.md!} +{!models/ipam/service.md!} diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md index 1b5ee3ad1..c78f2120a 100644 --- a/docs/core-functionality/sites-and-racks.md +++ b/docs/core-functionality/sites-and-racks.md @@ -1,12 +1,12 @@ # Sites and Racks -{!docs/models/dcim/region.md!} -{!docs/models/dcim/sitegroup.md!} -{!docs/models/dcim/site.md!} -{!docs/models/dcim/location.md!} +{!models/dcim/region.md!} +{!models/dcim/sitegroup.md!} +{!models/dcim/site.md!} +{!models/dcim/location.md!} --- -{!docs/models/dcim/rack.md!} -{!docs/models/dcim/rackrole.md!} -{!docs/models/dcim/rackreservation.md!} +{!models/dcim/rack.md!} +{!models/dcim/rackrole.md!} +{!models/dcim/rackreservation.md!} diff --git a/docs/core-functionality/tenancy.md b/docs/core-functionality/tenancy.md index 540955698..fbe1ea8b9 100644 --- a/docs/core-functionality/tenancy.md +++ b/docs/core-functionality/tenancy.md @@ -1,4 +1,4 @@ # Tenancy Assignment -{!docs/models/tenancy/tenant.md!} -{!docs/models/tenancy/tenantgroup.md!} +{!models/tenancy/tenant.md!} +{!models/tenancy/tenantgroup.md!} diff --git a/docs/core-functionality/virtualization.md b/docs/core-functionality/virtualization.md index f406a59f3..220030ab2 100644 --- a/docs/core-functionality/virtualization.md +++ b/docs/core-functionality/virtualization.md @@ -1,10 +1,10 @@ # Virtualization -{!docs/models/virtualization/cluster.md!} -{!docs/models/virtualization/clustertype.md!} -{!docs/models/virtualization/clustergroup.md!} +{!models/virtualization/cluster.md!} +{!models/virtualization/clustertype.md!} +{!models/virtualization/clustergroup.md!} --- -{!docs/models/virtualization/virtualmachine.md!} -{!docs/models/virtualization/vminterface.md!} +{!models/virtualization/virtualmachine.md!} +{!models/virtualization/vminterface.md!} diff --git a/docs/core-functionality/vlans.md b/docs/core-functionality/vlans.md index cf1c73fe3..d69128765 100644 --- a/docs/core-functionality/vlans.md +++ b/docs/core-functionality/vlans.md @@ -1,4 +1,4 @@ # VLAN Management -{!docs/models/ipam/vlan.md!} -{!docs/models/ipam/vlangroup.md!} +{!models/ipam/vlan.md!} +{!models/ipam/vlangroup.md!} diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index a27bcab83..252e65f90 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -226,7 +226,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a !!! note To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below. - ![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 diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 0d75abd21..2fead68ec 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -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 diff --git a/docs/graphql-api/overview.md b/docs/graphql-api/overview.md index f024306b0..f4cdc5fba 100644 --- a/docs/graphql-api/overview.md +++ b/docs/graphql-api/overview.md @@ -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 diff --git a/docs/index.md b/docs/index.md index ad28a708c..c25c5be16 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/installation/index.md b/docs/installation/index.md index db1e48620..893b1f639 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -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. - + ## Requirements diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md index 753ab6f7f..18ac1002e 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -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: diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 007d8434f..cde659a45 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -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 %} diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 379a6877e..522a026e3 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -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 diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 93c1ce303..1571f15fa 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index f4c0cb137..7244c36d6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/netbox/circuits/__init__.py b/netbox/circuits/__init__.py index e5400337f..e69de29bb 100644 --- a/netbox/circuits/__init__.py +++ b/netbox/circuits/__init__.py @@ -1 +0,0 @@ -default_app_config = 'circuits.apps.CircuitsConfig' diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 56cd46d4a..f43a3cfff 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -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 # diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 41a3aed7f..2e31237b6 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -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 %} {{ value.site }} @@ -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', diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index dfbfe68a4..b549b3a01 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -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 { diff --git a/netbox/dcim/__init__.py b/netbox/dcim/__init__.py index 1f3214979..e69de29bb 100644 --- a/netbox/dcim/__init__.py +++ b/netbox/dcim/__init__.py @@ -1 +0,0 @@ -default_app_config = 'dcim.apps.DCIMConfig' diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d20db1aa9..df0b1651b 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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'), ) ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c1f8eccf8..56c0f046b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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\"]" } ) ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 352ac7d5c..306b29b09 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -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', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 6720baf66..3b11a180b 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -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', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 1c4d6e921..b8e032e7f 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -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', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index ef0517dfc..fcc3ed4d2 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -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', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 47c912354..37fa019a1 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -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') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cb0e089cf..63f2be5c8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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) diff --git a/netbox/extras/__init__.py b/netbox/extras/__init__.py index 3db5f9c25..e69de29bb 100644 --- a/netbox/extras/__init__.py +++ b/netbox/extras/__init__.py @@ -1 +0,0 @@ -default_app_config = 'extras.apps.ExtrasConfig' diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 699bffc9f..20a6ffd8a 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -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 %} @@ -232,6 +245,7 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) + comments = MarkdownColumn() class Meta(BaseTable.Meta): model = JournalEntry diff --git a/netbox/ipam/__init__.py b/netbox/ipam/__init__.py index 63c45c90b..e69de29bb 100644 --- a/netbox/ipam/__init__.py +++ b/netbox/ipam/__init__.py @@ -1 +0,0 @@ -default_app_config = 'ipam.apps.IPAMConfig' diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4d5b3ad73..c72884b3c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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')}), diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py new file mode 100644 index 000000000..a280eac1b --- /dev/null +++ b/netbox/ipam/tables/__init__.py @@ -0,0 +1,4 @@ +from .ip import * +from .services import * +from .vlans import * +from .vrfs import * diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables/ip.py similarity index 55% rename from netbox/ipam/tables.py rename to netbox/ipam/tables/ip.py index 5afb6a9c9..2e59a681c 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables/ip.py @@ -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('Available') @@ -66,114 +75,6 @@ VRF_LINK = """ {% endif %} """ -VRF_TARGETS = """ -{% for rt in value.all %} - {{ rt }}{% if not forloop.last %}
{% endif %} -{% empty %} - — -{% endfor %} -""" - -VLAN_LINK = """ -{% if record.pk %} - {{ record.vid }} -{% elif perms.ipam.add_vlan %} - {{ record.available }} VLAN{{ record.available|pluralize }} available -{% else %} - {{ record.available }} VLAN{{ record.available|pluralize }} available -{% endif %} -""" - -VLAN_PREFIXES = """ -{% for prefix in record.prefixes.all %} - {{ prefix }}{% if not forloop.last %}
{% endif %} -{% empty %} - — -{% endfor %} -""" - -VLAN_ROLE_LINK = """ -{% if record.role %} - {{ record.role }} -{% else %} - — -{% endif %} -""" - -VLANGROUP_ADD_VLAN = """ -{% with next_vid=record.get_next_available_vid %} - {% if next_vid and perms.ipam.add_vlan %} - - - - {% endif %} -{% endwith %} -""" - -VLAN_MEMBER_TAGGED = """ -{% if record.untagged_vlan_id == object.pk %} - -{% else %} - -{% 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') diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py new file mode 100644 index 000000000..58c8ea49e --- /dev/null +++ b/netbox/ipam/tables/services.py @@ -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') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py new file mode 100644 index 000000000..4219a8a52 --- /dev/null +++ b/netbox/ipam/tables/vlans.py @@ -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('Available') + +VLAN_LINK = """ +{% if record.pk %} + {{ record.vid }} +{% elif perms.ipam.add_vlan %} + {{ record.available }} VLAN{{ record.available|pluralize }} available +{% else %} + {{ record.available }} VLAN{{ record.available|pluralize }} available +{% endif %} +""" + +VLAN_PREFIXES = """ +{% for prefix in record.prefixes.all %} + {{ prefix }}{% if not forloop.last %}
{% endif %} +{% empty %} + — +{% endfor %} +""" + +VLAN_ROLE_LINK = """ +{% if record.role %} + {{ record.role }} +{% else %} + — +{% endif %} +""" + +VLANGROUP_ADD_VLAN = """ +{% with next_vid=record.get_next_available_vid %} + {% if next_vid and perms.ipam.add_vlan %} + + + + {% endif %} +{% endwith %} +""" + +VLAN_MEMBER_TAGGED = """ +{% if record.untagged_vlan_id == object.pk %} + +{% else %} + +{% 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) diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py new file mode 100644 index 000000000..bea2a6b1f --- /dev/null +++ b/netbox/ipam/tables/vrfs.py @@ -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 %} + {{ rt }}{% if not forloop.last %}
{% endif %} +{% empty %} + — +{% endfor %} +""" + + +# +# VRFs +# + +class VRFTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + rd = tables.Column( + verbose_name='RD' + ) + tenant = TenantColumn() + enforce_unique = BooleanColumn( + verbose_name='Unique' + ) + import_targets = tables.TemplateColumn( + template_code=VRF_TARGETS, + orderable=False + ) + export_targets = tables.TemplateColumn( + template_code=VRF_TARGETS, + orderable=False + ) + tags = TagColumn( + url_name='ipam:vrf_list' + ) + + class Meta(BaseTable.Meta): + model = VRF + fields = ( + 'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags', + ) + default_columns = ('pk', 'name', 'rd', 'tenant', 'description') + + +# +# Route targets +# + +class RouteTargetTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + tenant = TenantColumn() + tags = TagColumn( + url_name='ipam:vrf_list' + ) + + class Meta(BaseTable.Meta): + model = RouteTarget + fields = ('pk', 'name', 'tenant', 'description', 'tags') + default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f84760418..015d47065 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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, diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 2c843f076..653fad3b0 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -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 " - "._. (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 " + "._. (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 diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 31e56fb1f..ec6daa021 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -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 diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index e0f376223..a8f989a2a 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 416536654..a720eb484 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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 diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 6b30d044d..7fc12b4fd 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -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) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 06e1eee06..53e20351c 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -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.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/', serve, {'document_root': settings.MEDIA_ROOT}), diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index bb24cff5a..3568204fe 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -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'] diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 0d4f89d0f..b05033128 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -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')] diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 196966fea..7104afe1c 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index b915a414f..c9b3b9453 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 413c4d0ad..b8157e2e3 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 33d004108..0b93ac20f 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 8bee87505..0faacc7df 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js index c817efb4c..bffd74dca 100644 Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map index 53b01423e..abfb8e6ca 100644 Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 6d83daaac..032fc83fa 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -149,13 +149,6 @@ export class APISelect { */ private more: Nullable = 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 `` 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 = `${'─'.repeat(result._depth)} ${text}`; } const data = {} as Record; diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index d7a8d5b6a..04dfa1d01 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -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; } /** diff --git a/netbox/project-static/styles/select.scss b/netbox/project-static/styles/select.scss index a532d1b69..e306e7c69 100644 --- a/netbox/project-static/styles/select.scss +++ b/netbox/project-static/styles/select.scss @@ -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}; } } diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index 716e27df0..c7c0cd76e 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/netbox/project-static/styles/theme-dark.scss @@ -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%); diff --git a/netbox/project-static/styles/theme-light.scss b/netbox/project-static/styles/theme-light.scss index 48af7de1a..d4b9303bd 100644 --- a/netbox/project-static/styles/theme-light.scss +++ b/netbox/project-static/styles/theme-light.scss @@ -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 diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 4e82568b7..d9dbc5b2d 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -5,7 +5,10 @@ {% block content %} {% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d3bff464d..aa3f834d6 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -95,11 +95,11 @@ Serial Number - {{ object.serial|placeholder }} + {{ object.serial|placeholder }} Asset Tag - {{ object.asset_tag|placeholder }} + {{ object.asset_tag|placeholder }} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index dd79a668c..900e0f818 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -1,4 +1,6 @@ {% load helpers %} +{% load render_table from django_tables2 %} + {% if perms.dcim.change_devicetype %}
{% csrf_token %} @@ -6,8 +8,8 @@
{{ title }}
-
- {% include 'inc/responsive_table.html' %} +
+ {% render_table table 'inc/table.html' %}