Merge pull request #15805 from netbox-community/develop

Release v3.7.6
This commit is contained in:
Jeremy Stretch 2024-04-22 12:18:27 -04:00 committed by GitHub
commit d115601da3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 580 additions and 197 deletions

View File

@ -1,7 +1,7 @@
--- ---
name: 🐛 Bug Report name: 🐛 Bug Report
description: Report a reproducible bug in the current release of NetBox description: Report a reproducible bug in the current release of NetBox
labels: ["type: bug", "needs triage"] labels: ["type: bug", "status: needs triage"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -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: v3.7.5 placeholder: v3.7.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,7 +1,7 @@
--- ---
name: 📖 Documentation Change name: 📖 Documentation Change
description: Suggest an addition or modification to the NetBox documentation description: Suggest an addition or modification to the NetBox documentation
labels: ["type: documentation", "needs triage"] labels: ["type: documentation", "status: needs triage"]
body: body:
- type: dropdown - type: dropdown
attributes: attributes:

View File

@ -1,7 +1,7 @@
--- ---
name: ✨ Feature Request name: ✨ Feature Request
description: Propose a new NetBox feature or enhancement description: Propose a new NetBox feature or enhancement
labels: ["type: feature", "needs triage"] labels: ["type: feature", "status: needs triage"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.7.5 placeholder: v3.7.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: pozil/auto-assign-issue@v1 - uses: pozil/auto-assign-issue@v1
if: "contains(github.event.issue.labels.*.name, 'needs triage')" if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
with: with:
# Weighted assignments # Weighted assignments
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps

View File

@ -61,7 +61,8 @@ django-timezone-field
# A REST API framework for Django projects # A REST API framework for Django projects
# https://www.django-rest-framework.org/community/release-notes/ # https://www.django-rest-framework.org/community/release-notes/
djangorestframework # Pinned to 3.14 for NetBox v3.7
djangorestframework<3.15
# Sane and flexible OpenAPI 3 schema generation for Django REST framework. # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst

View File

@ -2,8 +2,8 @@
{% block site_meta %} {% block site_meta %}
{{ super() }} {{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} {# Disable search indexing unless we're building for ReadTheDocs #}
{% if page.canonical_url != 'https://docs.netbox.dev/' %} {% if not config.extra.readthedocs %}
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -183,6 +183,30 @@ The view name or URL to which a user is redirected after logging out.
--- ---
## SECURE_HSTS_INCLUDE_SUBDOMAINS
Default: False
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
---
## SECURE_HSTS_PRELOAD
Default: False
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
---
## SECURE_HSTS_SECONDS
Default: 0
If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request.
---
## SECURE_SSL_REDIRECT ## SECURE_SSL_REDIRECT
Default: False Default: False

View File

@ -16,10 +16,7 @@ BASE_PATH = 'netbox/'
Default: `en-us` (US English) Default: `en-us` (US English)
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.) Defines the default preferred language/locale for requests that do not specify one. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
--- ---

View File

@ -62,7 +62,7 @@ class MyModelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Assigned site' help_text=_('Assigned site')
) )
class Meta: class Meta:

View File

@ -3,6 +3,9 @@
!!! tip "Plugins Development Tutorial" !!! tip "Plugins Development Tutorial"
Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time! Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time!
!!! tip "Plugin Certification Program"
NetBox Labs offers a [**Plugin Certification Program**](https://github.com/netbox-community/netbox/wiki/Plugin-Certification-Program) for plugin developers interested in establishing a co-maintainer relationship. The program aims to assure ongoing compatibility, maintainability, and commercial supportability of key plugins.
NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently. NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently.
!!! info "Django Development" !!! info "Django Development"

View File

@ -157,7 +157,7 @@ These views are provided to enable or enhance certain NetBox model features, suc
### Additional Tabs ### Additional Tabs
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`: Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
```python ```python
from dcim.models import Site from dcim.models import Site
@ -173,6 +173,16 @@ class MyView(generic.ObjectView):
badge=lambda obj: Stuff.objects.filter(site=obj).count(), badge=lambda obj: Stuff.objects.filter(site=obj).count(),
permission='myplugin.view_stuff' permission='myplugin.view_stuff'
) )
def get(self, request, pk):
...
return render(
request,
"myplugin/mytabview.html",
context={
"tab": self.tab,
},
)
``` ```
::: utilities.views.register_model_view ::: utilities.views.register_model_view

View File

@ -2,6 +2,8 @@
Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own. Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own.
Please see the documented instructions for [installing a plugin](./installation.md) to get started.
## Capabilities ## Capabilities
The NetBox plugin architecture allows for the following: The NetBox plugin architecture allows for the following:
@ -23,122 +25,3 @@ Either by policy or by technical limitation, the interaction of plugins with Net
* **Override core templates.** Plugins can inject additional content where supported, but may not manipulate or remove core content. * **Override core templates.** Plugins can inject additional content where supported, but may not manipulate or remove core content.
* **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration. * **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration.
* **Disable core components.** Plugins are not permitted to disable or hide core NetBox components. * **Disable core components.** Plugins are not permitted to disable or hide core NetBox components.
## Installing Plugins
The instructions below detail the process for installing and enabling a NetBox plugin.
### Install Package
Download and install the plugin package per its installation instructions. Plugins published via PyPI are typically installed using pip. Be sure to install the plugin within NetBox's virtual environment.
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip install <package>
```
Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
### Enable the Plugin
In `configuration.py`, add the plugin's name to the `PLUGINS` list:
```python
PLUGINS = [
'plugin_name',
]
```
### Configure Plugin
If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's README file.
```no-highlight
PLUGINS_CONFIG = {
'plugin_name': {
'foo': 'bar',
'buzz': 'bazz'
}
}
```
### Run Database Migrations
If the plugin introduces new database models, run the provided schema migrations:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py migrate
```
### Collect Static Files
Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py collectstatic
```
### Restart WSGI Service
Restart the WSGI service and RQ workers to load the new plugin:
```no-highlight
# sudo systemctl restart netbox netbox-rq
```
## Removing Plugins
Follow these steps to completely remove a plugin.
### Update Configuration
Remove the plugin from the `PLUGINS` list in `configuration.py`. Also remove any relevant configuration parameters from `PLUGINS_CONFIG`.
### Remove the Python Package
Use `pip` to remove the installed plugin:
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip uninstall <package>
```
### Restart WSGI Service
Restart the WSGI service:
```no-highlight
# sudo systemctl restart netbox
```
### Drop Database Tables
!!! note
This step is necessary only for plugin which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
Enter the PostgreSQL database shell to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
```no-highlight
netbox=> \dt pluginname_*
List of relations
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+--------
public | pluginname_foo | table | netbox
public | pluginname_bar | table | netbox
(2 rows)
```
!!! warning
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
Drop each of the listed tables to remove it from the database:
```no-highlight
netbox=> DROP TABLE pluginname_foo;
DROP TABLE
netbox=> DROP TABLE pluginname_bar;
DROP TABLE
```

View File

@ -0,0 +1,68 @@
# Installing a Plugin
!!! warning
The instructions below detail the general process for installing and configuring a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to install it.
## Install the Python Package
Download and install the plugin's Python package per its installation instructions. Plugins published via PyPI are typically installed using the [`pip`](https://packaging.python.org/en/latest/tutorials/installing-packages/) command line utility. Be sure to install the plugin within NetBox's virtual environment.
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip install <package>
```
Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
## Enable the Plugin
In `configuration.py`, add the plugin's name to the `PLUGINS` list:
```python
PLUGINS = [
# ...
'plugin_name',
]
```
## Configure the Plugin
If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's `README` file or other documentation.
```no-highlight
PLUGINS_CONFIG = {
'plugin_name': {
'foo': 'bar',
'buzz': 'bazz'
}
}
```
## Run Database Migrations
If the plugin introduces new database models, run the provided schema migrations:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py migrate
```
!!! tip
It's okay to run the `migrate` management command even if the plugin does not include any migration files.
## Collect Static Files
Plugins may package static resources like images or scripts to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py collectstatic
```
### Restart WSGI Service
Finally, restart the WSGI service and RQ workers to load the new plugin:
```no-highlight
# sudo systemctl restart netbox netbox-rq
```

72
docs/plugins/removal.md Normal file
View File

@ -0,0 +1,72 @@
# Removing a Plugin
!!! warning
The instructions below detail the general process for removing a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to remove it.
## Disable the Plugin
Disable the plugin by removing it from the `PLUGINS` list in `configuration.py`.
## Remove its Configuration
Delete the plugin's entry (if any) in the `PLUGINS_CONFIG` dictionary in `configuration.py`.
!!! tip
If there's a chance you may reinstall the plugin, consider commenting out any configuration parameters instead of deleting them.
## Re-index Search Entries
Run the `reindex` management command to reindex the global search engine. This will remove any stale entries pertaining to objects provided by the plugin.
```no-highlight
$ cd /opt/netbox/netbox/
$ source /opt/netbox/venv/bin/activate
(venv) $ python3 manage.py reindex
```
## Uninstall its Python Package
Use `pip` to remove the installed plugin:
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip uninstall <package>
```
## Restart WSGI Service
Restart the WSGI service:
```no-highlight
# sudo systemctl restart netbox
```
## Drop Database Tables
!!! note
This step is necessary only for plugins which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
Enter the PostgreSQL database shell (`manage.py dbshell`) to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
```no-highlight
netbox=> \dt pluginname_*
List of relations
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+--------
public | pluginname_foo | table | netbox
public | pluginname_bar | table | netbox
(2 rows)
```
!!! warning
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
Drop each of the listed tables to remove it from the database:
```no-highlight
netbox=> DROP TABLE pluginname_foo;
DROP TABLE
netbox=> DROP TABLE pluginname_bar;
DROP TABLE
```

View File

@ -1,5 +1,29 @@
# NetBox v3.7 # NetBox v3.7
## v3.7.6 (2024-04-22)
### Enhancements
* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form
* [#15427](https://github.com/netbox-community/netbox/issues/15427) - Enable compatibility with non-Amazon S3 providers for remote data sources
* [#15640](https://github.com/netbox-community/netbox/issues/15640) - Add global search support for L2VPN identifiers
* [#15644](https://github.com/netbox-community/netbox/issues/15644) - Introduce new configuration parameters for enabling HTTP Strict Transport Security (HSTS)
### Bug Fixes
* [#15541](https://github.com/netbox-community/netbox/issues/15541) - Restore ability to modify assigned component template when adding/modifying an inventory item template
* [#15582](https://github.com/netbox-community/netbox/issues/15582) - Fix permission constraints for synchronization of remote data sources
* [#15588](https://github.com/netbox-community/netbox/issues/15588) - Correct OpenAPI schema definitions for read-only fields which may return null values
* [#15635](https://github.com/netbox-community/netbox/issues/15635) - Extend plugin removal instruction to include reindexing the global search cache
* [#15654](https://github.com/netbox-community/netbox/issues/15654) - Fix `AttributeError` exception when attempting to save an incomplete tunnel termination
* [#15668](https://github.com/netbox-community/netbox/issues/15668) - Fix permission required to display virtual disks tab on virtual machine UI view
* [#15685](https://github.com/netbox-community/netbox/issues/15685) - Allow filtering cables by decimal values using UI filter form
* [#15761](https://github.com/netbox-community/netbox/issues/15761) - Add missing `ike_policy` & `ike_policy_id` filters for IKE proposals
* [#15771](https://github.com/netbox-community/netbox/issues/15771) - Include `id` in list of supported fields for all bulk import forms
* [#15790](https://github.com/netbox-community/netbox/issues/15790) - Fix live preview support for EventRule comments
---
## v3.7.5 (2024-04-04) ## v3.7.5 (2024-04-04)
### Enhancements ### Enhancements

View File

@ -42,6 +42,7 @@ plugins:
show_root_toc_entry: false show_root_toc_entry: false
show_source: false show_source: false
extra: extra:
readthedocs: !ENV READTHEDOCS
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox link: https://github.com/netbox-community/netbox
@ -127,7 +128,9 @@ nav:
- Synchronized Data: 'integrations/synchronized-data.md' - Synchronized Data: 'integrations/synchronized-data.md'
- Prometheus Metrics: 'integrations/prometheus-metrics.md' - Prometheus Metrics: 'integrations/prometheus-metrics.md'
- Plugins: - Plugins:
- Using Plugins: 'plugins/index.md' - About Plugins: 'plugins/index.md'
- Installing a Plugin: 'plugins/installation.md'
- Removing a Plugin: 'plugins/removal.md'
- Developing Plugins: - Developing Plugins:
- Getting Started: 'plugins/development/index.md' - Getting Started: 'plugins/development/index.md'
- Models: 'plugins/development/models.md' - Models: 'plugins/development/models.md'

View File

@ -1,5 +1,5 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
@ -33,10 +33,11 @@ class DataSourceViewSet(NetBoxModelViewSet):
""" """
Enqueue a job to synchronize the DataSource. Enqueue a job to synchronize the DataSource.
""" """
if not request.user.has_perm('core.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk) datasource = get_object_or_404(DataSource, pk=pk)
if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
datasource.enqueue_sync_job(request) datasource.enqueue_sync_job(request)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request}) serializer = serializers.DataSourceSerializer(datasource, context={'request': request})

View File

@ -149,7 +149,8 @@ class S3Backend(DataBackend):
region_name=self._region_name, region_name=self._region_name,
aws_access_key_id=aws_access_key_id, aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key, aws_secret_access_key=aws_secret_access_key,
config=self.config config=self.config,
endpoint_url=self._endpoint_url
) )
bucket = s3.Bucket(self._bucket_name) bucket = s3.Bucket(self._bucket_name)
@ -176,6 +177,11 @@ class S3Backend(DataBackend):
url_path = urlparse(self.url).path.lstrip('/') url_path = urlparse(self.url).path.lstrip('/')
return url_path.split('/')[0] return url_path.split('/')[0]
@property
def _endpoint_url(self):
url_path = urlparse(self.url)
return url_path._replace(params="", fragment="", query="", path="").geturl()
@property @property
def _remote_path(self): def _remote_path(self):
url_path = urlparse(self.url).path.lstrip('/') url_path = urlparse(self.url).path.lstrip('/')

View File

@ -3,6 +3,7 @@ import json
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.forms.fields import JSONField as _JSONField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
@ -12,7 +13,7 @@ from netbox.forms import NetBoxModelForm
from netbox.registry import registry from netbox.registry import registry
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.forms import BootstrapMixin, get_field_value from utilities.forms import BootstrapMixin, get_field_value
from utilities.forms.fields import CommentField from utilities.forms.fields import CommentField, JSONField
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
__all__ = ( __all__ = (
@ -132,6 +133,9 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
'help_text': param.description, 'help_text': param.description,
} }
field_kwargs.update(**param.field_kwargs) field_kwargs.update(**param.field_kwargs)
if param.field is _JSONField:
# Replace with our own JSONField to get pretty JSON in config editor
param.field = JSONField
param_fields[param.name] = param.field(**field_kwargs) param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields) attrs.update(param_fields)

View File

@ -612,7 +612,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
required=False, required=False,
allow_null=True allow_null=True
) )
component = serializers.SerializerMethodField(read_only=True) component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:
@ -668,7 +668,7 @@ class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
role = NestedDeviceRoleSerializer() role = NestedDeviceRoleSerializer()
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.') device_role = NestedDeviceRoleSerializer(read_only=True, help_text=_('Deprecated in v3.6 in favor of `role`.'))
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
@ -685,7 +685,7 @@ class DeviceSerializer(NetBoxModelSerializer):
) )
status = ChoiceField(choices=DeviceStatusChoices, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
@ -735,7 +735,7 @@ class DeviceSerializer(NetBoxModelSerializer):
class DeviceWithConfigContextSerializer(DeviceSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True) config_context = serializers.SerializerMethodField(read_only=True, allow_null=True)
class Meta(DeviceSerializer.Meta): class Meta(DeviceSerializer.Meta):
fields = [ fields = [
@ -1067,7 +1067,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
required=False, required=False,
allow_null=True allow_null=True
) )
component = serializers.SerializerMethodField(read_only=True) component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:

View File

@ -1373,14 +1373,14 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
label=_('Device'), label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Assigned role' help_text=_('Assigned role')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'), label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text='Assigned tenant' help_text=_('Assigned tenant')
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'), label=_('Status'),

View File

@ -977,9 +977,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Color'), label=_('Color'),
required=False required=False
) )
length = forms.IntegerField( length = forms.DecimalField(
label=_('Length'), label=_('Length'),
required=False required=False,
) )
length_unit = forms.ChoiceField( length_unit = forms.ChoiceField(
label=_('Length unit'), label=_('Length unit'),

View File

@ -976,21 +976,67 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
component_type = ContentTypeChoiceField( # Assigned component selectors
queryset=ContentType.objects.all(), consoleporttemplate = DynamicModelChoiceField(
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, queryset=ConsolePortTemplate.objects.all(),
required=False, required=False,
widget=forms.HiddenInput query_params={
'device_type_id': '$device_type'
},
label=_('Console port template')
) )
component_id = forms.IntegerField( consoleserverporttemplate = DynamicModelChoiceField(
queryset=ConsoleServerPortTemplate.objects.all(),
required=False, required=False,
widget=forms.HiddenInput query_params={
'device_type_id': '$device_type'
},
label=_('Console server port template')
)
frontporttemplate = DynamicModelChoiceField(
queryset=FrontPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Front port template')
)
interfacetemplate = DynamicModelChoiceField(
queryset=InterfaceTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Interface template')
)
poweroutlettemplate = DynamicModelChoiceField(
queryset=PowerOutletTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Power outlet template')
)
powerporttemplate = DynamicModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Power port template')
)
rearporttemplate = DynamicModelChoiceField(
queryset=RearPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Rear port template')
) )
fieldsets = ( fieldsets = (
(None, ( (None, (
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
)), )),
) )
@ -998,9 +1044,52 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
model = InventoryItemTemplate model = InventoryItemTemplate
fields = [ fields = [
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
] ]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
component_type = initial.get('component_type')
component_id = initial.get('component_id')
# Used for picking the default active tab for component selection
self.no_component = True
if instance:
# When editing set the initial value for component selection
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
self.no_component = False
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in (
'consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate',
'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component."))
elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]]
else:
self.instance.component = None
# #
# Device components # Device components

View File

@ -1656,6 +1656,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateCreateForm form = forms.InventoryItemTemplateCreateForm
model_form = forms.InventoryItemTemplateForm model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
def alter_object(self, instance, request): def alter_object(self, instance, request):
# Set component (if any) # Set component (if any)
@ -1673,6 +1674,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
class InventoryItemTemplateEditView(generic.ObjectEditView): class InventoryItemTemplateEditView(generic.ObjectEditView):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateForm form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
@register_model_view(InventoryItemTemplate, 'delete') @register_model_view(InventoryItemTemplate, 'delete')

View File

@ -265,6 +265,7 @@ class EventRuleForm(NetBoxModelForm):
required=False, required=False,
help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.') help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
) )
comments = CommentField()
fieldsets = ( fieldsets = (
(_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')), (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),

View File

@ -262,7 +262,7 @@ class AvailableVLANSerializer(serializers.Serializer):
Representation of a VLAN which does not exist in the database. Representation of a VLAN which does not exist in the database.
""" """
vid = serializers.IntegerField(read_only=True) vid = serializers.IntegerField(read_only=True)
group = NestedVLANGroupSerializer(read_only=True) group = NestedVLANGroupSerializer(read_only=True, allow_null=True)
def to_representation(self, instance): def to_representation(self, instance):
return { return {
@ -348,9 +348,9 @@ class AvailablePrefixSerializer(serializers.Serializer):
""" """
Representation of a prefix which does not exist in the database. Representation of a prefix which does not exist in the database.
""" """
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True, allow_null=True)
prefix = serializers.CharField(read_only=True) prefix = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True, allow_null=True)
def to_representation(self, instance): def to_representation(self, instance):
if self.context.get('vrf'): if self.context.get('vrf'):
@ -429,9 +429,9 @@ class AvailableIPSerializer(serializers.Serializer):
""" """
Representation of an IP address which does not exist in the database. Representation of an IP address which does not exist in the database.
""" """
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True, allow_null=True)
address = serializers.CharField(read_only=True) address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True, allow_null=True)
description = serializers.CharField(required=False) description = serializers.CharField(required=False)
def to_representation(self, instance): def to_representation(self, instance):

View File

@ -73,17 +73,12 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
""" """
Base form for creating a NetBox objects from CSV data. Used for bulk importing. Base form for creating a NetBox objects from CSV data. Used for bulk importing.
""" """
id = forms.IntegerField(
label=_('Id'),
required=False,
help_text='Numeric ID of an existing object to update (if not creating a new object)'
)
tags = CSVModelMultipleChoiceField( tags = CSVModelMultipleChoiceField(
label=_('Tags'), label=_('Tags'),
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False, required=False,
to_field_name='slug', to_field_name='slug',
help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")' help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")')
) )
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):

View File

@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup # Environment setup
# #
VERSION = '3.7.5' VERSION = '3.7.6'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -160,6 +160,9 @@ RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
SECURE_HSTS_PRELOAD = getattr(configuration, 'SECURE_HSTS_PRELOAD', False)
SECURE_HSTS_SECONDS = getattr(configuration, 'SECURE_HSTS_SECONDS', 0)
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)

View File

@ -0,0 +1,104 @@
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
{% block form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Inventory Item" %}</h5>
</div>
{% render_field form.device_type %}
{% render_field form.parent %}
{% render_field form.name %}
{% render_field form.label %}
{% render_field form.role %}
{% render_field form.description %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Hardware" %}</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.part_id %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Component Assignment" %}</h5>
</div>
<div class="row mb-2 offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleport_tab" data-bs-toggle="tab" aria-controls="consoleport" data-bs-target="#consoleport" class="nav-link {% if form.initial.consoleporttemplate or form.no_component %}active{% endif %}">
{% trans "Console Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleserverport_tab" data-bs-toggle="tab" aria-controls="consoleserverport" data-bs-target="#consoleserverport" class="nav-link {% if form.initial.consoleserverporttemplate %}active{% endif %}">
{% trans "Console Server Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="frontport_tab" data-bs-toggle="tab" aria-controls="frontport" data-bs-target="#frontport" class="nav-link {% if form.initial.frontporttemplate %}active{% endif %}">
{% trans "Front Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interfacetemplate %}active{% endif %}">
{% trans "Interface" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="poweroutlet_tab" data-bs-toggle="tab" aria-controls="poweroutlet" data-bs-target="#poweroutlet" class="nav-link {% if form.initial.poweroutlettemplate %}active{% endif %}">
{% trans "Power Outlet" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="powerport_tab" data-bs-toggle="tab" aria-controls="powerport" data-bs-target="#powerport" class="nav-link {% if form.initial.powerporttemplate %}active{% endif %}">
{% trans "Power Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="rearport_tab" data-bs-toggle="tab" aria-controls="rearport" data-bs-target="#rearport" class="nav-link {% if form.initial.rearporttemplate %}active{% endif %}">
{% trans "Rear Port" %}
</button>
</li>
</ul>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if form.initial.consoleporttemplate or form.no_component %}active{% endif %}" id="consoleport" role="tabpanel" aria-labeled-by="consoleport_tab">
{% render_field form.consoleporttemplate %}
</div>
<div class="tab-pane {% if form.initial.consoleserverporttemplate %}active{% endif %}" id="consoleserverport" role="tabpanel" aria-labeled-by="consoleserverport_tab">
{% render_field form.consoleserverporttemplate %}
</div>
<div class="tab-pane {% if form.initial.frontporttemplate %}active{% endif %}" id="frontport" role="tabpanel" aria-labeled-by="frontport_tab">
{% render_field form.frontporttemplate %}
</div>
<div class="tab-pane {% if form.initial.interfacetemplate %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
{% render_field form.interfacetemplate %}
</div>
<div class="tab-pane {% if form.initial.poweroutlettemplate %}active{% endif %}" id="poweroutlet" role="tabpanel" aria-labeled-by="poweroutlet_tab">
{% render_field form.poweroutlettemplate %}
</div>
<div class="tab-pane {% if form.initial.powerporttemplate %}active{% endif %}" id="powerport" role="tabpanel" aria-labeled-by="powerport_tab">
{% render_field form.powerporttemplate %}
</div>
<div class="tab-pane {% if form.initial.rearporttemplate %}active{% endif %}" id="rearport" role="tabpanel" aria-labeled-by="rearport_tab">
{% render_field form.rearporttemplate %}
</div>
</div>
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@ -6,6 +6,7 @@
# Translators: # Translators:
# Jonathan Senecal, 2024 # Jonathan Senecal, 2024
# Jeremy Stretch, 2024 # Jeremy Stretch, 2024
# Quentin Laurent, 2024
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
@ -14,7 +15,7 @@ msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-04 19:11+0000\n" "POT-Creation-Date: 2024-04-04 19:11+0000\n"
"PO-Revision-Date: 2023-10-30 17:48+0000\n" "PO-Revision-Date: 2023-10-30 17:48+0000\n"
"Last-Translator: Jeremy Stretch, 2024\n" "Last-Translator: Quentin Laurent, 2024\n"
"Language-Team: French (https://app.transifex.com/netbox-community/teams/178115/fr/)\n" "Language-Team: French (https://app.transifex.com/netbox-community/teams/178115/fr/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -3716,7 +3717,7 @@ msgstr "Réservation"
#: dcim/forms/model_forms.py:301 dcim/forms/model_forms.py:384 #: dcim/forms/model_forms.py:301 dcim/forms/model_forms.py:384
#: utilities/forms/fields/fields.py:47 #: utilities/forms/fields/fields.py:47
msgid "Slug" msgid "Slug"
msgstr "limace" msgstr "Identifiant"
#: dcim/forms/model_forms.py:308 templates/dcim/devicetype.html:12 #: dcim/forms/model_forms.py:308 templates/dcim/devicetype.html:12
msgid "Chassis" msgid "Chassis"
@ -5813,7 +5814,7 @@ msgstr "Poids maximum"
#: ipam/tables/asn.py:66 netbox/navigation/menu.py:16 #: ipam/tables/asn.py:66 netbox/navigation/menu.py:16
#: netbox/navigation/menu.py:18 #: netbox/navigation/menu.py:18
msgid "Sites" msgid "Sites"
msgstr "Des sites" msgstr "Sites"
#: dcim/tests/test_api.py:49 #: dcim/tests/test_api.py:49
msgid "Test case must set peer_termination_type" msgid "Test case must set peer_termination_type"
@ -13355,7 +13356,7 @@ msgstr ""
#: utilities/forms/fields/fields.py:48 #: utilities/forms/fields/fields.py:48
msgid "URL-friendly unique shorthand" msgid "URL-friendly unique shorthand"
msgstr "Raccourci unique et convivial pour les URL" msgstr "Identifiant unique utilisable dans les URL"
#: utilities/forms/fields/fields.py:101 #: utilities/forms/fields/fields.py:101
msgid "Enter context data in <a href=\"https://json.org/\">JSON</a> format." msgid "Enter context data in <a href=\"https://json.org/\">JSON</a> format."

View File

@ -5,8 +5,8 @@
# #
# Translators: # Translators:
# Tatsuya Ueda <ml@tatsuya.info>, 2024 # Tatsuya Ueda <ml@tatsuya.info>, 2024
# teapot, 2024
# Jeremy Stretch, 2024 # Jeremy Stretch, 2024
# teapot, 2024
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
@ -15,7 +15,7 @@ msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-04 19:11+0000\n" "POT-Creation-Date: 2024-04-04 19:11+0000\n"
"PO-Revision-Date: 2023-10-30 17:48+0000\n" "PO-Revision-Date: 2023-10-30 17:48+0000\n"
"Last-Translator: Jeremy Stretch, 2024\n" "Last-Translator: teapot, 2024\n"
"Language-Team: Japanese (https://app.transifex.com/netbox-community/teams/178115/ja/)\n" "Language-Team: Japanese (https://app.transifex.com/netbox-community/teams/178115/ja/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -7681,7 +7681,7 @@ msgstr "プレフィックス内およびプレフィックスを含む"
#: ipam/filtersets.py:259 #: ipam/filtersets.py:259
msgid "Prefixes which contain this prefix or IP" msgid "Prefixes which contain this prefix or IP"
msgstr "このプレフィックスまたは IP を含むプレフィックス" msgstr "このプレフィックス / IP を含むプレフィックス"
#: ipam/filtersets.py:270 ipam/filtersets.py:538 ipam/forms/bulk_edit.py:326 #: ipam/filtersets.py:270 ipam/filtersets.py:538 ipam/forms/bulk_edit.py:326
#: ipam/forms/filtersets.py:191 ipam/forms/filtersets.py:317 #: ipam/forms/filtersets.py:191 ipam/forms/filtersets.py:317
@ -7700,11 +7700,11 @@ msgstr "VLAN 番号 (1-4094)"
#: ipam/forms/model_forms.py:430 templates/tenancy/contact.html:54 #: ipam/forms/model_forms.py:430 templates/tenancy/contact.html:54
#: tenancy/forms/bulk_edit.py:112 #: tenancy/forms/bulk_edit.py:112
msgid "Address" msgid "Address"
msgstr "住所" msgstr "アドレス"
#: ipam/filtersets.py:445 #: ipam/filtersets.py:445
msgid "Ranges which contain this prefix or IP" msgid "Ranges which contain this prefix or IP"
msgstr "このプレフィックスまたは IP を含む範囲" msgstr "このプレフィックス / IP を含む範囲"
#: ipam/filtersets.py:473 ipam/filtersets.py:529 #: ipam/filtersets.py:473 ipam/filtersets.py:529
msgid "Parent prefix" msgid "Parent prefix"
@ -7743,11 +7743,11 @@ msgstr "FHRP グループ (ID)"
#: ipam/filtersets.py:618 #: ipam/filtersets.py:618
msgid "Is assigned to an interface" msgid "Is assigned to an interface"
msgstr "インタフェースに割り当てられている" msgstr "インタフェースに割り当てられている"
#: ipam/filtersets.py:622 #: ipam/filtersets.py:622
msgid "Is assigned" msgid "Is assigned"
msgstr "割り当てられている" msgstr "割当済みか"
#: ipam/filtersets.py:1047 #: ipam/filtersets.py:1047
msgid "IP address (ID)" msgid "IP address (ID)"
@ -7881,7 +7881,7 @@ msgstr "子 VLAN VID の最小値"
#: ipam/forms/bulk_edit.py:420 #: ipam/forms/bulk_edit.py:420
msgid "Maximum child VLAN VID" msgid "Maximum child VLAN VID"
msgstr "子 VLAN VID の最大" msgstr "子 VLAN VID の最大"
#: ipam/forms/bulk_edit.py:428 ipam/forms/model_forms.py:531 #: ipam/forms/bulk_edit.py:428 ipam/forms/model_forms.py:531
msgid "Scope type" msgid "Scope type"
@ -7905,11 +7905,11 @@ msgstr "ポート"
#: ipam/forms/bulk_import.py:47 #: ipam/forms/bulk_import.py:47
msgid "Import route targets" msgid "Import route targets"
msgstr "ルートターゲットをインポート" msgstr "インポートルートターゲット"
#: ipam/forms/bulk_import.py:53 #: ipam/forms/bulk_import.py:53
msgid "Export route targets" msgid "Export route targets"
msgstr "ルートターゲットをエクスポートする" msgstr "エクスポートルートターゲット"
#: ipam/forms/bulk_import.py:91 ipam/forms/bulk_import.py:111 #: ipam/forms/bulk_import.py:91 ipam/forms/bulk_import.py:111
#: ipam/forms/bulk_import.py:131 #: ipam/forms/bulk_import.py:131

View File

@ -70,6 +70,12 @@ class CSVModelForm(forms.ModelForm):
""" """
ModelForm used for the import of objects in CSV format. ModelForm used for the import of objects in CSV format.
""" """
id = forms.IntegerField(
label=_('ID'),
required=False,
help_text=_('Numeric ID of an existing object to update (if not creating a new object)')
)
def __init__(self, *args, headers=None, **kwargs): def __init__(self, *args, headers=None, **kwargs):
self.headers = headers or {} self.headers = headers or {}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -10,10 +10,11 @@ from django.test import Client, TestCase as _TestCase
from netaddr import IPNetwork from netaddr import IPNetwork
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from netbox.models.features import CustomFieldsMixin
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct from utilities.permissions import resolve_permission_ct
from utilities.utils import content_type_identifier from utilities.utils import content_type_identifier
from .utils import extract_form_failures from .utils import DUMMY_CF_DATA, extract_form_failures
__all__ = ( __all__ = (
'ModelTestCase', 'ModelTestCase',
@ -166,8 +167,12 @@ class ModelTestCase(TestCase):
model_dict = self.model_to_dict(instance, fields=fields, api=api) model_dict = self.model_to_dict(instance, fields=fields, api=api)
# Omit any dictionary keys which are not instance attributes or have been excluded # Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = { model_data = {
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
} }
self.assertDictEqual(model_dict, relevant_data) self.assertDictEqual(model_dict, model_data)
# Validate any custom field data, if present
if getattr(instance, 'custom_field_data', None):
self.assertDictEqual(instance.custom_field_data, DUMMY_CF_DATA)

View File

@ -1,13 +1,16 @@
import json
import logging import logging
import re import re
from contextlib import contextmanager from contextlib import contextmanager
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify from django.utils.text import slugify
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.models import Tag from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, Tag
from virtualization.models import Cluster, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterType, VirtualMachine
@ -102,3 +105,42 @@ def disable_warnings(logger_name):
logger.setLevel(logging.ERROR) logger.setLevel(logging.ERROR)
yield yield
logger.setLevel(current_level) logger.setLevel(current_level)
#
# Custom field testing
#
DUMMY_CF_DATA = {
'text_field': 'foo123',
'integer_field': 456,
'decimal_field': 456.12,
'boolean_field': True,
'json_field': {'abc': 123},
}
def add_custom_field_data(form_data, model):
"""
Create some custom fields for the model and add a value for each to the form data.
Args:
form_data: The dictionary of form data to be updated
model: The model of the object the form seeks to create or modify
"""
content_type = ContentType.objects.get_for_model(model)
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
)
CustomField.objects.bulk_create(custom_fields)
for cf in custom_fields:
cf.content_types.set([content_type])
form_data.update({
f'cf_{k}': v if type(v) is str else json.dumps(v)
for k, v in DUMMY_CF_DATA.items()
})

View File

@ -10,11 +10,11 @@ from django.utils.translation import gettext as _
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange from extras.models import ObjectChange
from netbox.models.features import ChangeLoggingMixin from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from .base import ModelTestCase from .base import ModelTestCase
from .utils import disable_warnings, post_data from .utils import add_custom_field_data, disable_warnings, post_data
__all__ = ( __all__ = (
'ModelViewTestCase', 'ModelViewTestCase',
@ -26,7 +26,6 @@ __all__ = (
# UI Tests # UI Tests
# #
class ModelViewTestCase(ModelTestCase): class ModelViewTestCase(ModelTestCase):
""" """
Base TestCase for model views. Subclass to test individual views. Base TestCase for model views. Subclass to test individual views.
@ -166,6 +165,10 @@ class ViewTestCases:
# Try GET with model-level permission # Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200) self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
# Add custom field data if the model supports it
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# Try POST with model-level permission # Try POST with model-level permission
initial_count = self._get_queryset().count() initial_count = self._get_queryset().count()
request = { request = {
@ -265,6 +268,10 @@ class ViewTestCases:
# Try GET with model-level permission # Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
# Add custom field data if the model supports it
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# Try POST with model-level permission # Try POST with model-level permission
request = { request = {
'path': self._get_url('edit', instance), 'path': self._get_url('edit', instance),

View File

@ -76,7 +76,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
role = NestedDeviceRoleSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)

View File

@ -388,7 +388,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
tab = ViewTab( tab = ViewTab(
label=_('Virtual Disks'), label=_('Virtual Disks'),
badge=lambda obj: obj.virtual_disk_count, badge=lambda obj: obj.virtual_disk_count,
permission='virtualization.view_virtual_disk', permission='virtualization.view_virtualdisk',
weight=500 weight=500
) )
actions = { actions = {

View File

@ -98,6 +98,9 @@ class TunnelTerminationSerializer(NetBoxModelSerializer):
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj): def get_termination(self, obj):
if not obj.termination:
return None
serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']} context = {'request': self.context['request']}
return serializer(obj.termination, context=context).data return serializer(obj.termination, context=context).data

View File

@ -136,6 +136,17 @@ class IKEProposalFilterSet(NetBoxModelFilterSet):
group = django_filters.MultipleChoiceFilter( group = django_filters.MultipleChoiceFilter(
choices=DHGroupChoices choices=DHGroupChoices
) )
ike_policy_id = django_filters.ModelMultipleChoiceFilter(
field_name='ike_policies',
queryset=IKEPolicy.objects.all(),
label=_('IKE policy (ID)'),
)
ike_policy = django_filters.ModelMultipleChoiceFilter(
field_name='ike_policies__name',
queryset=IKEPolicy.objects.all(),
to_field_name='name',
label=_('IKE policy (name)'),
)
class Meta: class Meta:
model = IKEProposal model = IKEProposal

View File

@ -75,6 +75,7 @@ class L2VPNIndex(SearchIndex):
fields = ( fields = (
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('identifier', 200),
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )

View File

@ -331,6 +331,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
IKEProposal.objects.bulk_create(ike_proposals) IKEProposal.objects.bulk_create(ike_proposals)
ike_policies = (
IKEPolicy(name='IKE Policy 1'),
IKEPolicy(name='IKE Policy 2'),
IKEPolicy(name='IKE Policy 3'),
)
IKEPolicy.objects.bulk_create(ike_policies)
ike_policies[0].proposals.add(ike_proposals[0])
ike_policies[1].proposals.add(ike_proposals[1])
ike_policies[2].proposals.add(ike_proposals[2])
def test_q(self): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@ -369,6 +379,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'sa_lifetime': [1000, 2000]} params = {'sa_lifetime': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_ike_policy(self):
ike_policies = IKEPolicy.objects.all()[:2]
params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IKEPolicy.objects.all() queryset = IKEPolicy.objects.all()

View File

@ -42,7 +42,7 @@ class WirelessLANImportForm(NetBoxModelImportForm):
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'), label=_('Status'),
choices=WirelessLANStatusChoices, choices=WirelessLANStatusChoices,
help_text='Operational status' help_text=_('Operational status')
) )
vlan = CSVModelChoiceField( vlan = CSVModelChoiceField(
label=_('VLAN'), label=_('VLAN'),

View File

@ -18,11 +18,11 @@ drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.4.1 drf-spectacular-sidecar==2024.4.1
feedparser==6.0.11 feedparser==6.0.11
graphene-django==3.0.0 graphene-django==3.0.0
gunicorn==21.2.0 gunicorn==22.0.0
Jinja2==3.1.3 Jinja2==3.1.3
Markdown==3.6 Markdown==3.6
mkdocs-material==9.5.17 mkdocs-material==9.5.18
mkdocstrings[python-legacy]==0.24.2 mkdocstrings[python-legacy]==0.24.3
netaddr==1.2.1 netaddr==1.2.1
Pillow==10.3.0 Pillow==10.3.0
psycopg[binary,pool]==3.1.18 psycopg[binary,pool]==3.1.18