Merge branch 'develop' into feature/15794-related-objects

This commit is contained in:
Alexander Haase 2024-05-15 23:12:38 +02:00
commit 4c41db0184
68 changed files with 45897 additions and 2160 deletions

View File

@ -26,7 +26,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.0.0 placeholder: v4.0.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

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

View File

@ -7,6 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: write
issues: write issues: write
pull-requests: write pull-requests: write

1
.gitignore vendored
View File

@ -17,6 +17,7 @@ yarn-error.log*
/venv/ /venv/
/*.sh /*.sh
local_requirements.txt local_requirements.txt
local_settings.py
!upgrade.sh !upgrade.sh
fabfile.py fabfile.py
gunicorn.py gunicorn.py

View File

@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a> <a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a> <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a> <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a> <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-10-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a> <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p> <p></p>
</div> </div>
@ -95,16 +95,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started. * Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
* [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself! * [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself!
## Project Stats
<p align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</p>
## Screenshots ## Screenshots
<p align="center"> <p align="center">

View File

@ -131,7 +131,7 @@ social-auth-app-django
strawberry-graphql strawberry-graphql
# Strawberry GraphQL Django extension # Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md # https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django strawberry-graphql-django
# SVG image rendering (used for rack elevations) # SVG image rendering (used for rack elevations)

View File

@ -353,6 +353,8 @@
"800gbase-x-qsfpdd", "800gbase-x-qsfpdd",
"800gbase-x-osfp", "800gbase-x-osfp",
"1000base-kx", "1000base-kx",
"2.5gbase-kx",
"5gbase-kr",
"10gbase-kr", "10gbase-kr",
"10gbase-kx4", "10gbase-kx4",
"25gbase-kr", "25gbase-kr",

View File

@ -159,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED ## LOGIN_REQUIRED
Default: False Default: True
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes. When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
!!! info "Changed in NetBox v4.0.2"
Prior to NetBox v4.0.2, this setting was disabled by default.
--- ---

View File

@ -198,3 +198,11 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
Default: UTC Default: UTC
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
---
## TRANSLATION_ENABLED
Default: True
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

View File

@ -72,7 +72,7 @@ In cases where upgrading a dependency to its most recent release is breaking, it
### Update UI Dependencies ### Update UI Dependencies
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](http://0.0.0.0:9000/development/web-ui/#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution. Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
### Rebuild the Device Type Definition Schema ### Rebuild the Device Type Definition Schema

View File

@ -55,18 +55,20 @@ project-name/
- template_content.py - template_content.py
- urls.py - urls.py
- views.py - views.py
- pyproject.toml
- README.md - README.md
- setup.py
``` ```
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: 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. * `pyproject.toml` - is a standard configuration file used to install the plugin package within the Python environment.
* `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display. * `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display.
* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores. * The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores.
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below. The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below.
**Note:** The [Cookiecutter NetBox Plugin](https://github.com/netbox-community/cookiecutter-netbox-plugin) can be used to auto-generate all the needed directories and files for a new plugin.
## PluginConfig ## PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
@ -136,31 +138,48 @@ Apps from this list are inserted *before* the plugin's `PluginConfig` in the ord
Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin.
## Create setup.py ## Create pyproject.toml
`setup.py` is the [setup script](https://docs.python.org/3.10/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: `pyproject.toml` is the [configuration file](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) used to package and install our plugin once it's finished. It is used by packaging tools, as well as other tools. The primary function of this file is to call the build system to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. There are three possible TOML tables in this file:
```python * `[build-system]` allows you to declare which build backend you use and which other dependencies (if any) are needed to build your project.
from setuptools import find_packages, setup * `[project]` is the format that most build backends use to specify your projects basic metadata, such as the author's name, project URL, etc.
* `[tool]` has tool-specific subtables, e.g., `[tool.black]`, `[tool.mypy]`. Consult the particular tools documentation for reference.
An example `pyproject.toml` is below:
```
# See PEP 518 for the spec of this file
# https://www.python.org/dev/peps/pep-0518/
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "my-example-plugin"
version = "0.1.0"
authors = [
{name = "John Doe", email = "test@netboxlabs.com"},
]
description = "An example NetBox plugin."
readme = "README.md"
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Natural Language :: English',
"Programming Language :: Python :: 3 :: Only",
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
]
requires-python = ">=3.10.0"
setup(
name='my-example-plugin',
version='0.1',
description='An example NetBox plugin',
url='https://github.com/jeremystretch/my-example-plugin',
author='Jeremy Stretch',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
``` ```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
!!! info
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
## Create a Virtual Environment ## Create a Virtual Environment
@ -178,11 +197,12 @@ echo /opt/netbox/netbox > $VENV/lib/python3.10/site-packages/netbox.pth
## Development Installation ## Development Installation
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): 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 `pip` from the plugin's root directory with the `-e` flag:
```no-highlight ```no-highlight
$ python setup.py develop $ pip install -e .
``` ```
More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
## Configure NetBox ## Configure NetBox

View File

@ -1,6 +1,64 @@
# NetBox v4.0 # NetBox v4.0
## v4.0.1 (FUTURE) ## v4.0.3 (FUTURE)
### Enhancements
* [#14639](https://github.com/netbox-community/netbox/issues/14639) - Add Ukrainian translation support
* [#14686](https://github.com/netbox-community/netbox/issues/14686) - Add German translation support
* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support
---
## v4.0.2 (2024-05-14)
!!! warning "Important"
This release includes an important security fix, and is a strongly recommended update for all users. More details will follow.
### Enhancements
* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups
* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected
* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter
* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True
* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings
### Bug Fixes
* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions
* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API
* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables
* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API
* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces
---
## v4.0.1 (2024-05-09)
### Enhancements
* [#15148](https://github.com/netbox-community/netbox/issues/15148) - Add copy-to-clipboard button for config context data
* [#15328](https://github.com/netbox-community/netbox/issues/15328) - Add a virtual machines UI tab for host devices
* [#15451](https://github.com/netbox-community/netbox/issues/15451) - Add 2.5 and 5 Gbps backplane Ethernet interface types
* [#16010](https://github.com/netbox-community/netbox/issues/16010) - Enable Prometheus middleware only if metrics are enabled
### Bug Fixes
* [#15968](https://github.com/netbox-community/netbox/issues/15968) - Avoid resizing quick search field to display clear button
* [#15973](https://github.com/netbox-community/netbox/issues/15973) - Fix AttributeError exception when modifying cable termination type
* [#15977](https://github.com/netbox-community/netbox/issues/15977) - Hide all admin menu items for non-authenticated users
* [#15982](https://github.com/netbox-community/netbox/issues/15982) - Restore the "assign IP" tab for assigning existing IP addresses to interfaces
* [#15992](https://github.com/netbox-community/netbox/issues/15992) - Fix AttributeError exception when Sentry integration is enabled
* [#15995](https://github.com/netbox-community/netbox/issues/15995) - Permit nullable fields referenced by unique constraints to be omitted from REST API requests
* [#15999](https://github.com/netbox-community/netbox/issues/15999) - Fix layout of login form labels for certain languages
* [#16003](https://github.com/netbox-community/netbox/issues/16003) - Enable cache busting for `setmode.js` asset to avoid breaking dark mode support on upgrade
* [#16011](https://github.com/netbox-community/netbox/issues/16011) - Fix site tenant assignment by PK via REST API
* [#16020](https://github.com/netbox-community/netbox/issues/16020) - Include Python version in system UI view
* [#16022](https://github.com/netbox-community/netbox/issues/16022) - Fix database migration failure when encountering a script module which no longer exists on disk
* [#16025](https://github.com/netbox-community/netbox/issues/16025) - Fix execution of scripts via the `runscript` management command
* [#16031](https://github.com/netbox-community/netbox/issues/16031) - Render Markdown content in script log messages
* [#16051](https://github.com/netbox-community/netbox/issues/16051) - Translate "empty" text for object tables
* [#16061](https://github.com/netbox-community/netbox/issues/16061) - Omit hidden fields from display within event rule edit form
--- ---

View File

@ -848,6 +848,8 @@ class InterfaceTypeChoices(ChoiceSet):
# Ethernet Backplane # Ethernet Backplane
TYPE_1GE_KX = '1000base-kx' TYPE_1GE_KX = '1000base-kx'
TYPE_2GE_KX = '2.5gbase-kx'
TYPE_5GE_KR = '5gbase-kr'
TYPE_10GE_KR = '10gbase-kr' TYPE_10GE_KR = '10gbase-kr'
TYPE_10GE_KX4 = '10gbase-kx4' TYPE_10GE_KX4 = '10gbase-kx4'
TYPE_25GE_KR = '25gbase-kr' TYPE_25GE_KR = '25gbase-kr'
@ -1008,6 +1010,8 @@ class InterfaceTypeChoices(ChoiceSet):
_('Ethernet (backplane)'), _('Ethernet (backplane)'),
( (
(TYPE_1GE_KX, '1000BASE-KX (1GE)'), (TYPE_1GE_KX, '1000BASE-KX (1GE)'),
(TYPE_2GE_KX, '2.5GBASE-KX (2.5GE)'),
(TYPE_5GE_KR, '5GBASE-KR (5GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'), (TYPE_10GE_KR, '10GBASE-KR (10GE)'),
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'), (TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
(TYPE_25GE_KR, '25GBASE-KR (25GE)'), (TYPE_25GE_KR, '25GBASE-KR (25GE)'),

View File

@ -90,14 +90,14 @@ def get_cable_form(a_type, b_type):
class _CableForm(CableForm, metaclass=FormMetaclass): class _CableForm(CableForm, metaclass=FormMetaclass):
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):
initial = initial or {} initial = initial or {}
if a_type: if a_type:
ct = ContentType.objects.get_for_model(a_type) a_ct = ContentType.objects.get_for_model(a_type)
initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}' initial['a_terminations_type'] = f'{a_ct.app_label}.{a_ct.model}'
if b_type: if b_type:
ct = ContentType.objects.get_for_model(b_type) b_ct = ContentType.objects.get_for_model(b_type)
initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}' initial['b_terminations_type'] = f'{b_ct.app_label}.{b_ct.model}'
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict() # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'): for field_name in ('a_terminations', 'b_terminations'):
@ -108,8 +108,17 @@ def get_cable_form(a_type, b_type):
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance # Initialize A/B terminations when modifying an existing Cable instance
self.initial['a_terminations'] = self.instance.a_terminations if a_type and self.instance.a_terminations and a_ct == ContentType.objects.get_for_model(self.instance.a_terminations[0]):
self.initial['b_terminations'] = self.instance.b_terminations self.initial['a_terminations'] = self.instance.a_terminations
if b_type and self.instance.b_terminations and b_ct == ContentType.objects.get_for_model(self.instance.b_terminations[0]):
self.initial['b_terminations'] = self.instance.b_terminations
else:
# Need to clear terminations if swapped type - but need to do it only
# if not from instance
if a_type:
initial.pop('a_terminations', None)
if b_type:
initial.pop('b_terminations', None)
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -30,7 +30,9 @@ from utilities.query_functions import CollateAsChar
from utilities.views import ( from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
) )
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices
from .models import * from .models import *
@ -2052,6 +2054,24 @@ class DeviceRenderConfigView(generic.ObjectView):
} }
@register_model_view(Device, 'virtual-machines')
class DeviceVirtualMachinesView(generic.ObjectChildrenView):
queryset = Device.objects.all()
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
weight=2200,
hide_if_empty=True,
permission='virtualization.view_virtualmachine'
)
def get_children(self, request, parent):
return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
class DeviceBulkImportView(generic.BulkImportView): class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all() queryset = Device.objects.all()
model_form = forms.DeviceImportForm model_form = forms.DeviceImportForm
@ -2922,7 +2942,6 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem child_model = InventoryItem
table = tables.InventoryItemTable table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('Children'), label=_('Children'),
badge=lambda obj: obj.child_items.count(), badge=lambda obj: obj.child_items.count(),

View File

@ -240,9 +240,9 @@ class ScriptViewSet(ModelViewSet):
raise RQWorkerNotRunningException() raise RQWorkerNotRunningException()
if input_serializer.is_valid(): if input_serializer.is_valid():
script.result = Job.enqueue( Job.enqueue(
run_script, run_script,
instance=script.module, instance=script,
name=script.python_class.class_name, name=script.python_class.class_name,
user=request.user, user=request.user,
data=input_serializer.data['data'], data=input_serializer.data['data'],

View File

@ -279,10 +279,7 @@ class EventRuleForm(NetBoxModelForm):
FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')), FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
FieldSet('conditions', name=_('Conditions')), FieldSet('conditions', name=_('Conditions')),
FieldSet( FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data',
name=_('Action')
),
) )
class Meta: class Meta:

View File

@ -1,10 +1,10 @@
import json import json
import django_tables2 as tables import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.models import * from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import * from .template_code import *
@ -550,7 +550,7 @@ class ScriptResultsTable(BaseTable):
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
empty_text = _('No results found') empty_text = _(EMPTY_TABLE_TEXT)
fields = ( fields = (
'index', 'time', 'status', 'message', 'index', 'time', 'status', 'message',
) )
@ -581,7 +581,7 @@ class ReportResultsTable(BaseTable):
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
empty_text = _('No results found') empty_text = _(EMPTY_TABLE_TEXT)
fields = ( fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message', 'index', 'method', 'time', 'status', 'object', 'url', 'message',
) )

View File

@ -10,7 +10,7 @@ from tenancy.forms import TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, ClusterGroup, Cluster
from vpn.models import L2VPN from vpn.models import L2VPN
__all__ = ( __all__ = (
@ -405,6 +405,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')), FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')), FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
) )
model = VLANGroup model = VLANGroup
@ -445,6 +446,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
max_value=VLAN_VID_MAX, max_value=VLAN_VID_MAX,
label=_('Maximum VID') label=_('Maximum VID')
) )
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -355,6 +355,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
): ):
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
})
elif type(instance.assigned_object) is VMInterface:
self.fields['vminterface'].widget.add_query_params({
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
})
# Disable object assignment fields if the IP address is designated as primary # Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'): if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True self.fields['interface'].disabled = True

View File

@ -648,6 +648,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
graphql_filter = {
'address': '192.168.0.1/24',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -205,7 +205,6 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN child_model = ASN
table = tables.ASNTable table = tables.ASNTable
filterset = filtersets.ASNFilterSet filterset = filtersets.ASNFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('ASNs'), label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(), badge=lambda x: x.get_child_asns().count(),
@ -863,7 +862,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('Related IPs'), label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(), badge=lambda x: x.get_related_ips().count(),
@ -931,7 +929,6 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN child_model = VLAN
table = tables.VLANTable table = tables.VLANTable
filterset = filtersets.VLANFilterSet filterset = filtersets.VLANFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('VLANs'), label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(), badge=lambda x: x.get_child_vlans().count(),
@ -1087,7 +1084,6 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface child_model = Interface
table = tables.VLANDevicesTable table = tables.VLANDevicesTable
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('Device Interfaces'), label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(), badge=lambda x: x.get_interfaces().count(),
@ -1105,7 +1101,6 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface child_model = VMInterface
table = tables.VLANVirtualMachinesTable table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet filterset = VMInterfaceFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('VM Interfaces'), label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(), badge=lambda x: x.get_vminterfaces().count(),

View File

@ -157,9 +157,8 @@ LOGGING = {}
# authenticated to NetBox indefinitely. # authenticated to NetBox indefinitely.
LOGIN_PERSISTENCE = False LOGIN_PERSISTENCE = False
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users # Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
# are permitted to access most data in NetBox but not make any changes. LOGIN_REQUIRED = True
LOGIN_REQUIRED = False
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to # The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
# re-authenticate. (Default: 1209600 [14 days]) # re-authenticate. (Default: 1209600 [14 days])

View File

@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
# General-purpose tokens # General-purpose tokens
CENSOR_TOKEN = '********' CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***' CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found'

View File

@ -87,7 +87,7 @@ def map_strawberry_type(field):
pass pass
elif issubclass(type(field), django_filters.NumberFilter): elif issubclass(type(field), django_filters.NumberFilter):
should_create_function = True should_create_function = True
attr_type = int attr_type = int | None
elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter): elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
should_create_function = True should_create_function = True
attr_type = List[str] | None attr_type = List[str] | None

View File

@ -138,13 +138,15 @@ class PluginConfig(AppConfig):
min_version = version.parse(cls.min_version) min_version = version.parse(cls.min_version)
if current_version < min_version: if current_version < min_version:
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}." f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})."
) )
if cls.max_version is not None: if cls.max_version is not None:
max_version = version.parse(cls.max_version) max_version = version.parse(cls.max_version)
if current_version > max_version: if current_version > max_version:
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}." f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})."
) )
# Verify required configuration settings # Verify required configuration settings

View File

@ -23,7 +23,7 @@ PREFERENCES = {
), ),
description=_('Enable dynamic UI navigation'), description=_('Enable dynamic UI navigation'),
default=False, default=False,
experimental=True warning=_('Experimental feature')
), ),
'locale.language': UserPreference( 'locale.language': UserPreference(
label=_('Language'), label=_('Language'),
@ -31,7 +31,12 @@ PREFERENCES = {
('', _('Auto')), ('', _('Auto')),
*settings.LANGUAGES, *settings.LANGUAGES,
), ),
description=_('Forces UI translation to the specified language.') description=_('Forces UI translation to the specified language'),
warning=(
_("Support for translation has been disabled locally")
if not settings.TRANSLATION_ENABLED
else ''
)
), ),
'pagination.per_page': UserPreference( 'pagination.per_page': UserPreference(
label=_('Page length'), label=_('Page length'),

View File

@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup # Environment setup
# #
VERSION = '4.0.1-dev' VERSION = '4.0.3-dev'
HOSTNAME = platform.node() HOSTNAME = platform.node()
# Set the base directory two levels up # Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -105,7 +105,7 @@ LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
LOGGING = getattr(configuration, 'LOGGING', {}) LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home') LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@ -156,6 +156,7 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
# Load any dynamic configuration parameters which have been hard-coded in the configuration file # Load any dynamic configuration parameters which have been hard-coded in the configuration file
for param in CONFIG_PARAMS: for param in CONFIG_PARAMS:
@ -445,6 +446,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
# Use timezone-aware datetime objects # Use timezone-aware datetime objects
USE_TZ = True USE_TZ = True
# Toggle language translation support
USE_I18N = TRANSLATION_ENABLED
# WSGI # WSGI
WSGI_APPLICATION = 'netbox.wsgi.application' WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@ -708,6 +712,7 @@ RQ_QUEUES.update({
# Supported translation languages # Supported translation languages
LANGUAGES = ( LANGUAGES = (
('de', _('German')),
('en', _('English')), ('en', _('English')),
('es', _('Spanish')), ('es', _('Spanish')),
('fr', _('French')), ('fr', _('French')),
@ -715,6 +720,8 @@ LANGUAGES = (
('pt', _('Portuguese')), ('pt', _('Portuguese')),
('ru', _('Russian')), ('ru', _('Russian')),
('tr', _('Turkish')), ('tr', _('Turkish')),
('uk', _('Ukrainian')),
('zh', _('Chinese')),
) )
LOCALE_PATHS = ( LOCALE_PATHS = (
BASE_DIR + '/translations', BASE_DIR + '/translations',
@ -801,3 +808,10 @@ for plugin_name in PLUGINS:
RQ_QUEUES.update({ RQ_QUEUES.update({
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
}) })
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *
_UNSUPPORTED_SETTINGS = True
except ImportError:
pass

View File

@ -14,6 +14,7 @@ from django_tables2.data import TableQuerysetData
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomLink from extras.models import CustomField, CustomLink
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry from netbox.registry import registry
from netbox.tables import columns from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -258,7 +259,7 @@ class SearchTable(tables.Table):
attrs = { attrs = {
'class': 'table table-hover object-list', 'class': 'table table-hover object-list',
} }
empty_text = _('No results found') empty_text = _(EMPTY_TABLE_TEXT)
def __init__(self, data, highlight=None, **kwargs): def __init__(self, data, highlight=None, **kwargs):
self.highlight = highlight self.highlight = highlight

View File

@ -42,6 +42,7 @@ class PluginTest(TestCase):
url = reverse('admin:dummy_plugin_dummymodel_add') url = reverse('admin:dummy_plugin_dummymodel_add')
self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/') self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
@override_settings(LOGIN_REQUIRED=False)
def test_views(self): def test_views(self):
# Test URL resolution # Test URL resolution
@ -53,7 +54,7 @@ class PluginTest(TestCase):
response = client.get(url) response = client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_api_views(self): def test_api_views(self):
# Test URL resolution # Test URL resolution
@ -65,6 +66,7 @@ class PluginTest(TestCase):
response = client.get(url) response = client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@override_settings(LOGIN_REQUIRED=False)
def test_registered_views(self): def test_registered_views(self):
# Test URL resolution # Test URL resolution

View File

@ -1,24 +1,76 @@
import urllib.parse import urllib.parse
from utilities.testing import TestCase
from django.urls import reverse from django.urls import reverse
from django.test import override_settings
from dcim.models import Site
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.search.backends import search_backend
from utilities.testing import TestCase
class HomeViewTestCase(TestCase): class HomeViewTestCase(TestCase):
def test_home(self): def test_home(self):
url = reverse('home') url = reverse('home')
response = self.client.get(url) response = self.client.get(url)
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
def test_search(self):
class SearchViewTestCase(TestCase):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site Alpha', slug='alpha', description='Red'),
Site(name='Site Bravo', slug='bravo', description='Red'),
Site(name='Site Charlie', slug='charlie', description='Green'),
Site(name='Site Delta', slug='delta', description='Green'),
Site(name='Site Echo', slug='echo', description='Blue'),
Site(name='Site Foxtrot', slug='foxtrot', description='Blue'),
)
Site.objects.bulk_create(sites)
search_backend.cache(sites)
def test_search(self):
url = reverse('search')
response = self.client.get(url)
self.assertHttpStatus(response, 200)
def test_search_query(self):
url = reverse('search') url = reverse('search')
params = { params = {
'q': 'foo', 'q': 'red',
} }
query = urllib.parse.urlencode(params)
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) # Test without view permission
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)
# Add view permissions & query again. Only matching objects should be listed
self.add_permissions('dcim.view_site')
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn('Site Alpha', content)
self.assertIn('Site Bravo', content)
self.assertNotIn('Site Charlie', content)
self.assertNotIn('Site Delta', content)
self.assertNotIn('Site Echo', content)
self.assertNotIn('Site Foxtrot', content)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_search_no_results(self):
url = reverse('search')
params = {
'q': 'xxxxxxxxx', # Matches nothing
}
query = urllib.parse.urlencode(params)
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)

View File

@ -93,6 +93,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model = None child_model = None
table = None table = None
filterset = None filterset = None
template_name = 'generic/object_children.html'
def get_children(self, request, parent): def get_children(self, request, parent):
""" """

View File

@ -30,7 +30,7 @@
"gridstack": "10.1.2", "gridstack": "10.1.2",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"query-string": "9.0.0", "query-string": "9.0.0",
"sass": "1.76.0", "sass": "1.77.1",
"tom-select": "2.3.1", "tom-select": "2.3.1",
"typeface-inter": "3.18.1", "typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13" "typeface-roboto-mono": "1.1.13"

View File

@ -1816,9 +1816,9 @@ ignore@^5.2.0:
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
immutable@^4.0.0: immutable@^4.0.0:
version "4.3.5" version "4.3.6"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw== integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==
import-fresh@^3.2.1: import-fresh@^3.2.1:
version "3.3.0" version "3.3.0"
@ -2482,7 +2482,16 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sass@1.76.0, sass@^1.7.3: sass@1.77.1:
version "1.77.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.1.tgz#018cdfb206afd14724030c02e9fefd8f30a76cd0"
integrity sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
sass@^1.7.3:
version "1.76.0" version "1.76.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.76.0.tgz#fe15909500735ac154f0dc7386d656b62b03987d" resolved "https://registry.yarnpkg.com/sass/-/sass-1.76.0.tgz#fe15909500735ac154f0dc7386d656b62b03987d"
integrity sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw== integrity sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw==

View File

@ -33,7 +33,7 @@
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">{% trans "Configuration Data" %}</h5> <h5 class="card-header">{% trans "Configuration Data" %}</h5>
{% include 'core/inc/config_data.html' with config=config.data %} {% include 'core/inc/config_data.html' with config=object.data %}
</div> </div>
<div class="card"> <div class="card">

View File

@ -40,7 +40,7 @@
<td>{{ stats.django_version }}</td> <td>{{ stats.django_version }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "PotsgreSQL version" %}</th> <th scope="row">{% trans "PostgreSQL version" %}</th>
<td>{{ stats.postgresql_version }}</td> <td>{{ stats.postgresql_version }}</td>
</tr> </tr>
<tr> <tr>

View File

@ -2,15 +2,17 @@
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% with preferences|get_key:"pagination.placement" as paginator_placement %} <div class="htmx-container table-responsive">
{% if paginator_placement == 'top' or paginator_placement == 'both' %} {% with preferences|get_key:"pagination.placement" as paginator_placement %}
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page placement='top' %} {% if paginator_placement == 'top' or paginator_placement == 'both' %}
{% endif %} {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page placement='top' %}
{% render_table table 'inc/table_htmx.html' %} {% endif %}
{% if paginator_placement != 'top' %} {% render_table table 'inc/table_htmx.html' %}
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %} {% if paginator_placement != 'top' %}
{% endif %} {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
{% endwith %} {% endif %}
{% endwith %}
</div>
{# Include the updated object count for display elsewhere on the page #} {# Include the updated object count for display elsewhere on the page #}
<div class="d-none" hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div> <div class="d-none" hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>

View File

@ -5,7 +5,8 @@
<div <div
class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2" class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2"
hx-target="closest .htmx-container" hx-target="closest .htmx-container"
hx-disinherit="hx-select hx-swap" hx-disinherit="hx-select"
hx-swap="outerHTML"
{% if not table.embedded %}hx-push-url="true"{% endif %} {% if not table.embedded %}hx-push-url="true"{% endif %}
> >

View File

@ -1,5 +1,5 @@
{% load django_tables2 %} {% load django_tables2 %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %} hx-disinherit="hx-target hx-select hx-swap"> <table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %} hx-disinherit="hx-target hx-select" hx-swap="outerHTML">
{% if table.show_header %} {% if table.show_header %}
<thead <thead
hx-target="closest .htmx-container" hx-target="closest .htmx-container"

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -40,11 +40,8 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
help_text = f'<code>{field_name}</code>' help_text = f'<code>{field_name}</code>'
if preference.description: if preference.description:
help_text = f'{preference.description}<br />{help_text}' help_text = f'{preference.description}<br />{help_text}'
if preference.experimental: if warning := preference.warning:
help_text = ( help_text = f'<span class="text-danger"><i class="mdi mdi-alert"></i> {warning}</span><br />{help_text}'
f'<span class="text-danger"><i class="mdi mdi-alert"></i> Experimental feature</span><br />'
f'{help_text}'
)
field_kwargs = { field_kwargs = {
'label': preference.label, 'label': preference.label,
'choices': preference.choices, 'choices': preference.choices,

View File

@ -2,10 +2,10 @@ class UserPreference:
""" """
Represents a configurable user preference. Represents a configurable user preference.
""" """
def __init__(self, label, choices, default=None, description='', coerce=lambda x: x, experimental=False): def __init__(self, label, choices, default=None, description='', coerce=lambda x: x, warning=''):
self.label = label self.label = label
self.choices = choices self.choices = choices
self.default = default if default is not None else choices[0] self.default = default if default is not None else choices[0]
self.description = description self.description = description
self.coerce = coerce self.coerce = coerce
self.experimental = experimental self.warning = warning

View File

@ -70,14 +70,24 @@ class RestrictedGenericForeignKey(GenericForeignKey):
# 1. Capture restrict_params from RestrictedPrefetch (hack) # 1. Capture restrict_params from RestrictedPrefetch (hack)
# 2. If restrict_params is set, call restrict() on the queryset for # 2. If restrict_params is set, call restrict() on the queryset for
# the related model # the related model
def get_prefetch_queryset(self, instances, queryset=None): def get_prefetch_querysets(self, instances, querysets=None):
restrict_params = {} restrict_params = {}
custom_queryset_dict = {}
# Compensate for the hack in RestrictedPrefetch # Compensate for the hack in RestrictedPrefetch
if type(queryset) is dict: if type(querysets) is dict:
restrict_params = queryset restrict_params = querysets
elif queryset is not None:
raise ValueError(_("Custom queryset can't be used for this lookup.")) elif querysets is not None:
for queryset in querysets:
ct_id = self.get_content_type(
model=queryset.query.model, using=queryset.db
).pk
if ct_id in custom_queryset_dict:
raise ValueError(
"Only one queryset is allowed for each content type."
)
custom_queryset_dict[ct_id] = queryset
# For efficiency, group the instances by content type and then do one # For efficiency, group the instances by content type and then do one
# query per model # query per model
@ -100,15 +110,16 @@ class RestrictedGenericForeignKey(GenericForeignKey):
ret_val = [] ret_val = []
for ct_id, fkeys in fk_dict.items(): for ct_id, fkeys in fk_dict.items():
instance = instance_dict[ct_id] if ct_id in custom_queryset_dict:
ct = self.get_content_type(id=ct_id, using=instance._state.db) # Return values from the custom queryset, if provided.
if restrict_params: ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys))
# Override the default behavior to call restrict() on each model's queryset
qs = ct.model_class().objects.filter(pk__in=fkeys).restrict(**restrict_params)
ret_val.extend(qs)
else: else:
# Default behavior instance = instance_dict[ct_id]
ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) ct = self.get_content_type(id=ct_id, using=instance._state.db)
qs = ct.model_class().objects.filter(pk__in=fkeys)
if restrict_params:
qs = qs.restrict(**restrict_params)
ret_val.extend(qs)
# For doing the join in Python, we have to match both the FK val and the # For doing the join in Python, we have to match both the FK val and the
# content type, so we use a callable that returns a (fk, class) pair. # content type, so we use a callable that returns a (fk, class) pair.

View File

@ -20,14 +20,14 @@ class RestrictedPrefetch(Prefetch):
super().__init__(lookup, queryset=queryset, to_attr=to_attr) super().__init__(lookup, queryset=queryset, to_attr=to_attr)
def get_current_queryset(self, level): def get_current_querysets(self, level):
params = { params = {
'user': self.restrict_user, 'user': self.restrict_user,
'action': self.restrict_action, 'action': self.restrict_action,
} }
if qs := super().get_current_queryset(level): if querysets := super().get_current_querysets(level):
return qs.restrict(**params) return [qs.restrict(**params) for qs in querysets]
# Bit of a hack. If no queryset is defined, pass through the dict of restrict() # Bit of a hack. If no queryset is defined, pass through the dict of restrict()
# kwargs to be handled by the field. This is necessary e.g. for GenericForeignKey # kwargs to be handled by the field. This is necessary e.g. for GenericForeignKey
@ -49,11 +49,11 @@ class RestrictedQuerySet(QuerySet):
permission_required = get_permission_for_model(self.model, action) permission_required = get_permission_for_model(self.model, action)
# Bypass restriction for superusers and exempt views # Bypass restriction for superusers and exempt views
if user.is_superuser or permission_is_exempt(permission_required): if user and user.is_superuser or permission_is_exempt(permission_required):
qs = self qs = self
# User is anonymous or has not been granted the requisite permission # User is anonymous or has not been granted the requisite permission
elif not user.is_authenticated or permission_required not in user.get_all_permissions(): elif user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
qs = self.none() qs = self.none()
# Filter the queryset to include only objects with allowed attributes # Filter the queryset to include only objects with allowed attributes

View File

@ -1,5 +1,5 @@
<div class="htmx-container table-responsive" <div class="htmx-container table-responsive"
hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
hx-target="this" hx-target="this"
hx-trigger="load" hx-select="table" hx-swap="innerHTML" hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
></div> ></div>

View File

@ -73,7 +73,7 @@ class APIViewTestCases:
class GetObjectViewTestCase(APITestCase): class GetObjectViewTestCase(APITestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_get_object_anonymous(self): def test_get_object_anonymous(self):
""" """
GET a single object as an unauthenticated user. GET a single object as an unauthenticated user.
@ -135,7 +135,7 @@ class APIViewTestCases:
class ListObjectsViewTestCase(APITestCase): class ListObjectsViewTestCase(APITestCase):
brief_fields = [] brief_fields = []
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_list_objects_anonymous(self): def test_list_objects_anonymous(self):
""" """
GET a list of objects as an unauthenticated user. GET a list of objects as an unauthenticated user.
@ -440,13 +440,12 @@ class APIViewTestCases:
base_name = self.model._meta.verbose_name.lower().replace(' ', '_') base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
return getattr(self, 'graphql_base_name', base_name) return getattr(self, 'graphql_base_name', base_name)
def _build_query(self, name, **filters): def _build_query_with_filter(self, name, filter_string):
"""
Called by either _build_query or _build_filtered_query - construct the actual
query given a name and filter string
"""
type_class = get_graphql_type_for_model(self.model) type_class = get_graphql_type_for_model(self.model)
if filters:
filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
filter_string = f'({filter_string})'
else:
filter_string = ''
# Compile list of fields to include # Compile list of fields to include
fields_string = '' fields_string = ''
@ -492,6 +491,30 @@ class APIViewTestCases:
return query return query
def _build_filtered_query(self, name, **filters):
"""
Create a filtered query: i.e. ip_address_list(filters: {address: "1.1.1.1/24"}){.
"""
if filters:
filter_string = ', '.join(f'{k}: "{v}"' for k, v in filters.items())
filter_string = f'(filters: {{{filter_string}}})'
else:
filter_string = ''
return self._build_query_with_filter(name, filter_string)
def _build_query(self, name, **filters):
"""
Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
"""
if filters:
filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
filter_string = f'({filter_string})'
else:
filter_string = ''
return self._build_query_with_filter(name, filter_string)
@override_settings(LOGIN_REQUIRED=True) @override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_get_object(self): def test_graphql_get_object(self):
@ -550,6 +573,31 @@ class APIViewTestCases:
self.assertNotIn('errors', data) self.assertNotIn('errors', data)
self.assertGreater(len(data['data'][field_name]), 0) self.assertGreater(len(data['data'][field_name]), 0)
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_filter_objects(self):
if not hasattr(self, 'graphql_filter'):
return
url = reverse('graphql')
field_name = f'{self._get_graphql_base_name()}_list'
query = self._build_filtered_query(field_name, **self.graphql_filter)
# Add object-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertGreater(len(data['data'][field_name]), 0)
class APIViewTestCase( class APIViewTestCase(
GetObjectViewTestCase, GetObjectViewTestCase,
ListObjectsViewTestCase, ListObjectsViewTestCase,

View File

@ -62,7 +62,7 @@ class ViewTestCases:
""" """
Retrieve a single instance. Retrieve a single instance.
""" """
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_get_object_anonymous(self): def test_get_object_anonymous(self):
# Make the request as an unauthenticated user # Make the request as an unauthenticated user
self.client.logout() self.client.logout()
@ -421,7 +421,7 @@ class ViewTestCases:
""" """
Retrieve multiple instances. Retrieve multiple instances.
""" """
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_list_objects_anonymous(self): def test_list_objects_anonymous(self):
# Make the request as an unauthenticated user # Make the request as an unauthenticated user
self.client.logout() self.client.logout()

View File

@ -84,7 +84,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
virtualdisks: List[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]] virtualdisks: List[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]]
@ -102,8 +102,8 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
bridge_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(

View File

@ -173,7 +173,6 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine child_model = VirtualMachine
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
filterset = filtersets.VirtualMachineFilterSet filterset = filtersets.VirtualMachineFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab( tab = ViewTab(
label=_('Virtual Machines'), label=_('Virtual Machines'),
badge=lambda obj: obj.virtual_machines.count(), badge=lambda obj: obj.virtual_machines.count(),

View File

@ -20,18 +20,18 @@ feedparser==6.0.11
gunicorn==22.0.0 gunicorn==22.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.6 Markdown==3.6
mkdocs-material==9.5.21 mkdocs-material==9.5.22
mkdocstrings[python-legacy]==0.25.1 mkdocstrings[python-legacy]==0.25.1
netaddr==1.2.1 netaddr==1.2.1
nh3==0.2.17 nh3==0.2.17
Pillow==10.3.0 Pillow==10.3.0
psycopg[c,pool]==3.1.18 psycopg[c,pool]==3.1.19
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.31.0
social-auth-app-django==5.4.1 social-auth-app-django==5.4.1
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.227.3 strawberry-graphql==0.229.0
strawberry-graphql-django==0.39.2 strawberry-graphql-django==0.40.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.6.1 tablib==3.6.1
tzdata==2024.1 tzdata==2024.1