Merge pull request #15048 from netbox-community/develop

Release v3.7.2
This commit is contained in:
Jeremy Stretch 2024-02-05 14:10:13 -05:00 committed by GitHub
commit 426805cd24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 30082 additions and 2466 deletions

View File

@ -23,7 +23,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.1 placeholder: v3.7.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: v3.7.1 placeholder: v3.7.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -68,6 +68,9 @@ jobs:
- name: Collect static files - name: Collect static files
run: python netbox/manage.py collectstatic --no-input run: python netbox/manage.py collectstatic --no-input
- name: Check for missing migrations
run: python netbox/manage.py makemigrations --check
- name: Check PEP8 compliance - name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/ run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/

View File

@ -9,13 +9,15 @@ on:
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
discussions: write
jobs: jobs:
lock: lock:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v4 - uses: dessant/lock-threads@v5
with: with:
issue-inactive-days: 90 issue-inactive-days: 90
pr-inactive-days: 30 pr-inactive-days: 30
discussion-inactive-days: 180
issue-lock-reason: 'resolved' issue-lock-reason: 'resolved'

View File

@ -86,12 +86,16 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.) * In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
* All code submissions should meet the following criteria (CI will enforce these checks): * All code submissions must meet the following criteria (CI will enforce these checks where feasible):
* Consist entirely of original work
* Python syntax is valid * Python syntax is valid
* All tests pass when run with `./manage.py test` * All tests pass when run with `./manage.py test`
* PEP 8 compliance is enforced, with the exception that lines may be * PEP 8 compliance is enforced, with the exception that lines may be
greater than 80 characters in length greater than 80 characters in length
> [!CAUTION]
> Any contributions which include AI-generated or reproduced content will be rejected.
* Some other tips to keep in mind: * Some other tips to keep in mind:
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.) * If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment. * Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
@ -117,8 +121,6 @@ We're always looking for motivated individuals to join the maintainers team and
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items. We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
Many maintainers petition their employer to grant some of their paid time to work on NetBox. In doing so, your employer becomes eligible to be featured as a [NetBox sponsor](https://github.com/netbox-community/netbox/wiki/Sponsorship).
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team! Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
## :heart: Other Ways to Contribute ## :heart: Other Ways to Contribute

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-4-blue" alt="Languages supported" /></a> <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-6-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>

View File

@ -10,6 +10,9 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below. You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below.
!!! note
These system defaults will be overridden by a user's selected language/locale when [localization](./system.md#enable_localization) is enabled.
```python ```python
DATE_FORMAT = 'N j, Y' # June 26, 2016 DATE_FORMAT = 'N j, Y' # June 26, 2016
SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26 SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26

View File

@ -69,15 +69,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
Default: False Default: False
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules. Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding any configured [system defaults](./date-time.md#date-and-time-formatting)) based on the browser locale as well as translate certain strings from third party modules.
---
## GIT_PATH
Default: `git`
The system path to the `git` executable, used by the synchronization backend for remote git repositories.
--- ---

View File

@ -58,3 +58,6 @@ You should see output similar to the following:
If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem. If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem.
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup. Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
!!! note
There is a bug in the current stable release of gunicorn (v21.2.0) where automatic restarts of the worker processes can result in 502 errors under heavy load. (See [gunicorn bug #3038](https://github.com/benoitc/gunicorn/issues/3038) for more detail.) Users who encounter this issue may opt to downgrade to an earlier, unaffected release of gunicorn (`pip install gunicorn==20.1.0`). Note, however, that this earlier release does not officially support Python 3.11.

View File

@ -14,7 +14,7 @@ The IKE version employed (v1 or v2).
### Mode ### Mode
The IKE mode employed (main or aggressive). The mode employed (main or aggressive) when IKEv1 is in use. This setting is not supported for IKEv2.
### Proposals ### Proposals

View File

@ -47,3 +47,14 @@ class ReminderWidget(DashboardWidget):
def render(self, request): def render(self, request):
return self.config.get('content') return self.config.get('content')
``` ```
## Initialization
To register the widget, it becomes essential to import the widget module. The recommended approach is to accomplish this within the `ready` method situated in your `PluginConfig`:
```python
class FooBarConfig(PluginConfig):
def ready(self):
super().ready()
from . import widgets # point this to the above widget module you created
```

View File

@ -20,4 +20,4 @@ backends = [MyDataBackend]
!!! tip !!! tip
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance. The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
::: core.data_backends.DataBackend ::: netbox.data_backends.DataBackend

View File

@ -1,5 +1,38 @@
# NetBox v3.7 # NetBox v3.7
## v3.7.2 (2024-02-05)
### Enhancements
* [#13729](https://github.com/netbox-community/netbox/issues/13729) - Omit sensitive data source parameters from change log data
* [#14645](https://github.com/netbox-community/netbox/issues/14645) - Limit the number of assigned IP addresses displayed under interfaces list
### Bug Fixes
* [#14500](https://github.com/netbox-community/netbox/issues/14500) - Optimize calculation of available child prefixes & ranges when viewing a prefix
* [#14511](https://github.com/netbox-community/netbox/issues/14511) - Fix GraphQL support for interfaces connected to provider networks
* [#14572](https://github.com/netbox-community/netbox/issues/14572) - Correct the number of jobs listed for individual report & script modules
* [#14703](https://github.com/netbox-community/netbox/issues/14703) - Revert to the default layout when encountering a misconfigured dashboard
* [#14755](https://github.com/netbox-community/netbox/issues/14755) - Fix validation of choice values & labels when creating a custom field choice set via the REST API
* [#14838](https://github.com/netbox-community/netbox/issues/14838) - Avoid corrupting JSON data when changing the action type while editing an event rule
* [#14839](https://github.com/netbox-community/netbox/issues/14839) - Fix form validation error when attempting to terminate a tunnel to a virtual machine interface
* [#14840](https://github.com/netbox-community/netbox/issues/14840) - Fix `NoReverseMatch` exception when rendering a custom field which references a user
* [#14847](https://github.com/netbox-community/netbox/issues/14847) - IKE policy mode may be set inly when IKEv1 is selected
* [#14851](https://github.com/netbox-community/netbox/issues/14851) - Automatically remove any associated bookmarks when deleting a user
* [#14879](https://github.com/netbox-community/netbox/issues/14879) - Include custom fields in REST API representation of data sources
* [#14885](https://github.com/netbox-community/netbox/issues/14885) - Add missing "group" field to VPN tunnel creation form
* [#14892](https://github.com/netbox-community/netbox/issues/14892) - Fix exception when running report/script via command line due to missing username
* [#14920](https://github.com/netbox-community/netbox/issues/14920) - Include button to display available status choices when bulk importing virtual device contexts
* [#14945](https://github.com/netbox-community/netbox/issues/14945) - Fix "select all" button for device type components
* [#14947](https://github.com/netbox-community/netbox/issues/14947) - Ensure that application & removal of tags is always recorded in an object's change log
* [#14962](https://github.com/netbox-community/netbox/issues/14962) - Fix config context rendering for VMs assigned directly to a site (rather than via a cluster)
* [#14999](https://github.com/netbox-community/netbox/issues/14999) - Fix "create & add another" link for interface FHRP group assignment
* [#15015](https://github.com/netbox-community/netbox/issues/15015) - Pre-populate assigned tenant when allocating next available IP address under prefix view
* [#15020](https://github.com/netbox-community/netbox/issues/15020) - Automatically update all VMs when changing a cluster's assigned site
* [#15025](https://github.com/netbox-community/netbox/issues/15025) - The `can_add()` template filter should accept a model (not an instance)
---
## v3.7.1 (2024-01-17) ## v3.7.1 (2024-01-17)
### Bug Fixes ### Bug Fixes

View File

@ -36,7 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
model = DataSource model = DataSource
fields = [ fields = [
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments', 'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count', 'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
] ]

View File

@ -21,7 +21,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect(), widget=BulkEditNullBooleanSelect(),
label=_('Enforce unique space') label=_('Enabled')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),

View File

@ -9,9 +9,9 @@ class Command(_Command):
""" """
This built-in management command enables the creation of new database schema migration files, which should This built-in management command enables the creation of new database schema migration files, which should
never be required by and ordinary user. We prevent this command from executing unless the configuration never be required by and ordinary user. We prevent this command from executing unless the configuration
indicates that the user is a developer (i.e. configuration.DEVELOPER == True). indicates that the user is a developer (i.e. configuration.DEVELOPER == True), or it was run with --check.
""" """
if not settings.DEVELOPER: if not kwargs['check_changes'] and not settings.DEVELOPER:
raise CommandError( raise CommandError(
"This command is available for development purposes only. It will\n" "This command is available for development purposes only. It will\n"
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n" "NOT resolve any issues with missing or unapplied migrations. For assistance,\n"

View File

@ -14,6 +14,7 @@ from django.utils import timezone
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from netbox.models.features import JobsMixin from netbox.models.features import JobsMixin
from netbox.registry import registry from netbox.registry import registry
@ -130,6 +131,28 @@ class DataSource(JobsMixin, PrimaryModel):
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)" 'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
}) })
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
# Censor any backend parameters marked as sensitive in the serialized data
pre_change_params = {}
post_change_params = {}
if objectchange.prechange_data:
pre_change_params = objectchange.prechange_data.get('parameters') or {} # parameters may be None
if objectchange.postchange_data:
post_change_params = objectchange.postchange_data.get('parameters') or {}
for param in self.backend_class.sensitive_parameters:
if post_change_params.get(param):
if post_change_params[param] != pre_change_params.get(param):
# Set the "changed" token if the parameter's value has been modified
post_change_params[param] = CENSOR_TOKEN_CHANGED
else:
post_change_params[param] = CENSOR_TOKEN
if pre_change_params.get(param):
pre_change_params[param] = CENSOR_TOKEN
return objectchange
def enqueue_sync_job(self, request): def enqueue_sync_job(self, request):
""" """
Enqueue a background job to synchronize the DataSource by calling sync(). Enqueue a background job to synchronize the DataSource by calling sync().

View File

@ -0,0 +1,122 @@
from django.test import TestCase
from core.models import DataSource
from extras.choices import ObjectChangeActionChoices
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
class DataSourceChangeLoggingTestCase(TestCase):
def test_password_added_on_create(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'jeff',
'password': 'foobar123',
}
)
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_CREATE)
self.assertIsNone(objectchange.prechange_data)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
def test_password_added_on_update(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/'
)
datasource.snapshot()
# Add a blank password
datasource.parameters = {
'username': 'jeff',
'password': '',
}
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertIsNone(objectchange.prechange_data['parameters'])
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
# Add a password
datasource.parameters = {
'username': 'jeff',
'password': 'foobar123',
}
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
def test_password_changed(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'jeff',
'password': 'password1',
}
)
datasource.snapshot()
# Change the password
datasource.parameters['password'] = 'password2'
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
def test_password_removed_on_update(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'jeff',
'password': 'foobar123',
}
)
datasource.snapshot()
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
# Remove the password
datasource.parameters['password'] = ''
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
def test_password_not_modified(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'username1',
'password': 'foobar123',
}
)
datasource.snapshot()
# Remove the password
datasource.parameters['username'] = 'username2'
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'username1')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)

View File

@ -727,7 +727,7 @@ class PowerOutletImportForm(NetBoxModelImportForm):
help_text=_('Local power port which feeds this outlet') help_text=_('Local power port which feeds this outlet')
) )
feed_leg = CSVChoiceField( feed_leg = CSVChoiceField(
label=_('Feed lag'), label=_('Feed leg'),
choices=PowerOutletFeedLegChoices, choices=PowerOutletFeedLegChoices,
required=False, required=False,
help_text=_('Electrical phase (for three-phase circuits)') help_text=_('Electrical phase (for three-phase circuits)')
@ -1359,6 +1359,10 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text='Assigned tenant' help_text='Assigned tenant'
) )
status = CSVChoiceField(
label=_('Status'),
choices=VirtualDeviceContextStatusChoices,
)
class Meta: class Meta:
fields = [ fields = [

View File

@ -1,6 +1,6 @@
import graphene import graphene
from circuits.graphql.types import CircuitTerminationType from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
from circuits.models import CircuitTermination from circuits.models import CircuitTermination, ProviderNetwork
from dcim.graphql.types import ( from dcim.graphql.types import (
ConsolePortTemplateType, ConsolePortTemplateType,
ConsolePortType, ConsolePortType,
@ -167,3 +167,42 @@ class InventoryItemComponentType(graphene.Union):
return PowerPortType return PowerPortType
if type(instance) is RearPort: if type(instance) is RearPort:
return RearPortType return RearPortType
class ConnectedEndpointType(graphene.Union):
class Meta:
types = (
CircuitTerminationType,
ConsolePortType,
ConsoleServerPortType,
FrontPortType,
InterfaceType,
PowerFeedType,
PowerOutletType,
PowerPortType,
ProviderNetworkType,
RearPortType,
)
@classmethod
def resolve_type(cls, instance, info):
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) is ConsolePortType:
return ConsolePortType
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) is FrontPort:
return FrontPortType
if type(instance) is Interface:
return InterfaceType
if type(instance) is PowerFeed:
return PowerFeedType
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) is PowerPort:
return PowerPortType
if type(instance) is ProviderNetwork:
return ProviderNetworkType
if type(instance) is RearPort:
return RearPortType

View File

@ -13,7 +13,7 @@ class CabledObjectMixin:
class PathEndpointMixin: class PathEndpointMixin:
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType') connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.ConnectedEndpointType')
def resolve_connected_endpoints(self, info): def resolve_connected_endpoints(self, info):
# Handle empty values # Handle empty values

View File

@ -36,6 +36,9 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """ INTERFACE_IPADDRESSES = """
<div class="table-badge-group"> <div class="table-badge-group">
{% if value.count >= 3 %}
<a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a>
{% else %}
{% for ip in value.all %} {% for ip in value.all %}
{% if ip.status != 'active' %} {% if ip.status != 'active' %}
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a> <a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
@ -43,6 +46,7 @@ INTERFACE_IPADDRESSES = """
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a> <a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %}
</div> </div>
""" """

View File

@ -58,7 +58,11 @@ class DeviceComponentsView(generic.ObjectChildrenView):
return self.child_model.objects.restrict(request.user, 'view').filter(device=parent) return self.child_model.objects.restrict(request.user, 'view').filter(device=parent)
class DeviceTypeComponentsView(DeviceComponentsView): class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html' template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution viewname = None # Used for return_url resolution

View File

@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import ListField
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
from core.api.serializers import JobSerializer from core.api.serializers import JobSerializer
@ -175,6 +176,12 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
choices=CustomFieldChoiceSetBaseChoices, choices=CustomFieldChoiceSetBaseChoices,
required=False required=False
) )
extra_choices = serializers.ListField(
child=serializers.ListField(
min_length=2,
max_length=2
)
)
class Meta: class Meta:
model = CustomFieldChoiceSet model = CustomFieldChoiceSet

View File

@ -53,13 +53,13 @@ def get_dashboard(user):
return dashboard return dashboard
def get_default_dashboard(): def get_default_dashboard(config=None):
from extras.models import Dashboard from extras.models import Dashboard
dashboard = Dashboard() dashboard = Dashboard()
default_config = settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD config = config or settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD
for widget in default_config: for widget in config:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
dashboard.layout.append({ dashboard.layout.append({
'id': id, 'id': id,

View File

@ -71,10 +71,10 @@ def enqueue_object(queue, instance, user, request_id, action):
}) })
def process_event_rules(event_rules, model_name, event, data, username, snapshots=None, request_id=None): def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
try: if username:
user = get_user_model().objects.get(username=username) user = get_user_model().objects.get(username=username)
except ObjectDoesNotExist: else:
user = None user = None
for event_rule in event_rules: for event_rule in event_rules:

View File

@ -142,10 +142,12 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
} }
help_texts = { help_texts = {
'link_text': _( 'link_text': _(
"Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. Links " "Jinja2 template code for the link text. Reference the object as {example}. Links "
"which render as empty text will not be displayed." "which render as empty text will not be displayed."
), ).format(example="<code>{{ object }}</code>"),
'link_url': _("Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>."), 'link_url': _(
"Jinja2 template code for the link URL. Reference the object as {example}."
).format(example="<code>{{ object }}</code>"),
} }

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.9 on 2024-01-19 19:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('extras', '0105_customfield_min_max_values'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -8,6 +8,16 @@ __all__ = (
class PythonModuleMixin: class PythonModuleMixin:
def get_jobs(self, name):
"""
Returns a list of Jobs associated with this specific script or report module
:param name: The class name of the script or report
:return: List of Jobs associated with this
"""
return self.jobs.filter(
name=name
)
@property @property
def path(self): def path(self):
return os.path.splitext(self.file_path)[0] return os.path.splitext(self.file_path)[0]

View File

@ -771,7 +771,7 @@ class Bookmark(models.Model):
) )
user = models.ForeignKey( user = models.ForeignKey(
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT on_delete=models.CASCADE
) )
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()

View File

@ -120,34 +120,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
if self.model._meta.model_name == 'device': if self.model._meta.model_name == 'device':
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND) base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND) base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
region_field = 'site__region'
sitegroup_field = 'site__group'
elif self.model._meta.model_name == 'virtualmachine': elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
base_query.add(Q(device_types=None), Q.AND) base_query.add(Q(device_types=None), Q.AND)
region_field = 'cluster__site__region'
sitegroup_field = 'cluster__site__group' base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
base_query.add( base_query.add(
(Q( (Q(
regions__tree_id=OuterRef(f'{region_field}__tree_id'), regions__tree_id=OuterRef('site__region__tree_id'),
regions__level__lte=OuterRef(f'{region_field}__level'), regions__level__lte=OuterRef('site__region__level'),
regions__lft__lte=OuterRef(f'{region_field}__lft'), regions__lft__lte=OuterRef('site__region__lft'),
regions__rght__gte=OuterRef(f'{region_field}__rght'), regions__rght__gte=OuterRef('site__region__rght'),
) | Q(regions=None)), ) | Q(regions=None)),
Q.AND Q.AND
) )
base_query.add( base_query.add(
(Q( (Q(
site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'), site_groups__tree_id=OuterRef('site__group__tree_id'),
site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'), site_groups__level__lte=OuterRef('site__group__level'),
site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'), site_groups__lft__lte=OuterRef('site__group__lft'),
site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'), site_groups__rght__gte=OuterRef('site__group__rght'),
) | Q(site_groups=None)), ) | Q(site_groups=None)),
Q.AND Q.AND
) )

View File

@ -68,18 +68,20 @@ def handle_changed_object(sender, instance, **kwargs):
else: else:
return return
# Record an ObjectChange if applicable # Create/update an ObejctChange record for this change
if m2m_changed: objectchange = instance.to_objectchange(action)
ObjectChange.objects.filter( # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
if m2m_changed and (
prev_change := ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance), changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk, changed_object_id=instance.pk,
request_id=request.id request_id=request.id
).update( ).first()
postchange_data=instance.to_objectchange(action).postchange_data ):
) prev_change.postchange_data = objectchange.postchange_data
else: prev_change.save()
objectchange = instance.to_objectchange(action) elif objectchange and objectchange.has_changes:
if objectchange and objectchange.has_changes:
objectchange.user = request.user objectchange.user = request.user
objectchange.request_id = request.id objectchange.request_id = request.id
objectchange.save() objectchange.save()
@ -251,7 +253,8 @@ def process_job_start_event_rules(sender, **kwargs):
Process event rules for jobs starting. Process event rules for jobs starting.
""" """
event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type) event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type)
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, sender.user.username) username = sender.user.username if sender.user else None
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
@receiver(job_end) @receiver(job_end)
@ -260,4 +263,5 @@ def process_job_end_event_rules(sender, **kwargs):
Process event rules for jobs terminating. Process event rules for jobs terminating.
""" """
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type) event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type)
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, sender.user.username) username = sender.user.username if sender.user else None
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)

View File

@ -14,7 +14,6 @@ from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model() User = get_user_model()
@ -251,6 +250,23 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
) )
CustomFieldChoiceSet.objects.bulk_create(choice_sets) CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_invalid_choice_items(self):
"""
Attempting to define each choice as a single-item list should return a 400 error.
"""
self.add_permissions('extras.add_customfieldchoiceset')
data = {
"name": "test",
"extra_choices": [
["choice1"],
["choice2"],
["choice3"],
]
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(response.status_code, 400)
class CustomLinkTest(APIViewTestCases.APIViewTestCase): class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink model = CustomLink

View File

@ -270,7 +270,12 @@ class ConfigContextTest(TestCase):
tag = Tag.objects.first() tag = Tag.objects.first()
cluster_type = ClusterType.objects.create(name="Cluster Type") cluster_type = ClusterType.objects.create(name="Cluster Type")
cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster_group = ClusterGroup.objects.create(name="Cluster Group")
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) cluster = Cluster.objects.create(
name="Cluster",
group=cluster_group,
type=cluster_type,
site=site,
)
region_context = ConfigContext.objects.create( region_context = ConfigContext.objects.create(
name="region", name="region",
@ -354,6 +359,41 @@ class ConfigContextTest(TestCase):
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data() annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context()) self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
def test_virtualmachine_site_context(self):
"""
Check that config context associated with a site applies to a VM whether the VM is assigned
directly to that site or via its cluster.
"""
site = Site.objects.first()
cluster_type = ClusterType.objects.create(name="Cluster Type")
cluster = Cluster.objects.create(name="Cluster", type=cluster_type, site=site)
vm_role = DeviceRole.objects.first()
# Create a ConfigContext associated with the site
context = ConfigContext.objects.create(
name="context1",
weight=100,
data={"foo": True}
)
context.sites.add(site)
# Create one VM assigned directly to the site, and one assigned via the cluster
vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
# Check that their individually-rendered config contexts are identical
self.assertEqual(
vm1.get_config_context(),
vm2.get_config_context()
)
# Check that their annotated config contexts are identical
vms = VirtualMachine.objects.filter(pk__in=(vm1.pk, vm2.pk)).annotate_config_context_data()
self.assertEqual(
vms[0].get_config_context(),
vms[1].get_config_context()
)
def test_multiple_tags_return_distinct_objects(self): def test_multiple_tags_return_distinct_objects(self):
""" """
Tagged items use a generic relationship, which results in duplicate rows being returned when queried. Tagged items use a generic relationship, which results in duplicate rows being returned when queried.

View File

@ -1057,16 +1057,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name): def get(self, request, module, name):
module = get_report_module(module, request) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
object_type = ContentType.objects.get(app_label='extras', model='reportmodule') report.result = jobs.filter(
report.result = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=report.name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first() ).first()
return render(request, 'extras/report.html', { return render(request, 'extras/report.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'report': report, 'report': report,
'form': ReportForm(scheduling_enabled=report.scheduling_enabled), 'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
@ -1078,6 +1076,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
module = get_report_module(module, request) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled) form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
if form.is_valid(): if form.is_valid():
@ -1086,6 +1085,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
if not get_workers_for_queue('default'): if not get_workers_for_queue('default'):
messages.error(request, "Unable to run report: RQ worker process not running.") messages.error(request, "Unable to run report: RQ worker process not running.")
return render(request, 'extras/report.html', { return render(request, 'extras/report.html', {
'job_count': jobs.count(),
'report': report, 'report': report,
}) })
@ -1103,6 +1103,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
return redirect('extras:report_result', job_pk=job.pk) return redirect('extras:report_result', job_pk=job.pk)
return render(request, 'extras/report.html', { return render(request, 'extras/report.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'report': report, 'report': report,
'form': form, 'form': form,
@ -1117,8 +1118,10 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name): def get(self, request, module, name):
module = get_report_module(module, request) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
return render(request, 'extras/report/source.html', { return render(request, 'extras/report/source.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'report': report, 'report': report,
'tab': 'source', 'tab': 'source',
@ -1133,13 +1136,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name): def get(self, request, module, name):
module = get_report_module(module, request) module = get_report_module(module, request)
report = module.reports[name]() report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=report.class_name
)
jobs_table = JobTable( jobs_table = JobTable(
data=jobs, data=jobs,
@ -1149,6 +1146,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
jobs_table.configure(request) jobs_table.configure(request)
return render(request, 'extras/report/jobs.html', { return render(request, 'extras/report/jobs.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'report': report, 'report': report,
'table': jobs_table, 'table': jobs_table,
@ -1232,19 +1230,11 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name): def get(self, request, module, name):
module = get_script_module(module, request) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
form = script.as_form(initial=normalize_querydict(request.GET)) form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending Job (use the latest one by creation timestamp)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=script.name,
).exclude(
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'script': script, 'script': script,
'form': form, 'form': form,
@ -1256,6 +1246,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
module = get_script_module(module, request) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
form = script.as_form(request.POST, request.FILES) form = script.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running # Allow execution only if RQ worker process is running
@ -1279,6 +1270,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
return redirect('extras:script_result', job_pk=job.pk) return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'script': script, 'script': script,
'form': form, 'form': form,
@ -1293,8 +1285,10 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name): def get(self, request, module, name):
module = get_script_module(module, request) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
return render(request, 'extras/script/source.html', { return render(request, 'extras/script/source.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'script': script, 'script': script,
'tab': 'source', 'tab': 'source',
@ -1309,13 +1303,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name): def get(self, request, module, name):
module = get_script_module(module, request) module = get_script_module(module, request)
script = module.scripts[name]() script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=script.class_name
)
jobs_table = JobTable( jobs_table = JobTable(
data=jobs, data=jobs,
@ -1325,6 +1313,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
jobs_table.configure(request) jobs_table.configure(request)
return render(request, 'extras/script/jobs.html', { return render(request, 'extras/script/jobs.html', {
'job_count': jobs.count(),
'module': module, 'module': module,
'script': script, 'script': script,
'table': jobs_table, 'table': jobs_table,

View File

@ -254,7 +254,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
mark_utilized = forms.NullBooleanField( mark_utilized = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect(), widget=BulkEditNullBooleanSelect(),
label=_('Treat as 100% utilized') label=_('Treat as fully utilized')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),
@ -298,7 +298,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
mark_utilized = forms.NullBooleanField( mark_utilized = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect(), widget=BulkEditNullBooleanSelect(),
label=_('Treat as 100% utilized') label=_('Treat as fully utilized')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),

View File

@ -240,7 +240,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
) )
mark_utilized = forms.NullBooleanField( mark_utilized = forms.NullBooleanField(
required=False, required=False,
label=_('Marked as 100% utilized'), label=_('Treat as fully utilized'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
@ -279,7 +279,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
) )
mark_utilized = forms.NullBooleanField( mark_utilized = forms.NullBooleanField(
required=False, required=False,
label=_('Marked as 100% utilized'), label=_('Treat as fully utilized'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )

View File

@ -268,7 +268,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
mark_utilized = models.BooleanField( mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'), verbose_name=_('mark utilized'),
default=False, default=False,
help_text=_("Treat as 100% utilized") help_text=_("Treat as fully utilized")
) )
# Cached depth & child counts # Cached depth & child counts
@ -427,10 +427,10 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
prefix = netaddr.IPSet(self.prefix) prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
child_ranges = netaddr.IPSet() child_ranges = []
for iprange in self.get_child_ranges(): for iprange in self.get_child_ranges():
child_ranges.add(iprange.range) child_ranges.append(iprange.range)
available_ips = prefix - child_ips - child_ranges available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31): if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
@ -535,7 +535,7 @@ class IPRange(PrimaryModel):
mark_utilized = models.BooleanField( mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'), verbose_name=_('mark utilized'),
default=False, default=False,
help_text=_("Treat as 100% utilized") help_text=_("Treat as fully utilized")
) )
clone_fields = ( clone_fields = (

View File

@ -1068,6 +1068,12 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView):
instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id')) instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id'))
return instance return instance
def get_extra_addanother_params(self, request):
return {
'interface_type': request.GET.get('interface_type'),
'interface_id': request.GET.get('interface_id'),
}
@register_model_view(FHRPGroupAssignment, 'delete') @register_model_view(FHRPGroupAssignment, 'delete')
class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView):

View File

@ -36,3 +36,7 @@ DEFAULT_ACTION_PERMISSIONS = {
'bulk_edit': {'change'}, 'bulk_edit': {'change'},
'bulk_delete': {'delete'}, 'bulk_delete': {'delete'},
} }
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'

View File

@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup # Environment setup
# #
VERSION = '3.7.1' VERSION = '3.7.2'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -122,7 +122,6 @@ EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440) FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
GIT_PATH = getattr(configuration, 'GIT_PATH', 'git')
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
@ -726,8 +725,10 @@ LANGUAGES = (
('en', _('English')), ('en', _('English')),
('es', _('Spanish')), ('es', _('Spanish')),
('fr', _('French')), ('fr', _('French')),
('ja', _('Japanese')),
('pt', _('Portuguese')), ('pt', _('Portuguese')),
('ru', _('Russian')), ('ru', _('Russian')),
('tr', _('Turkish')),
) )
LOCALE_PATHS = ( LOCALE_PATHS = (

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='DummyModel', name='DummyModel',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=20)), ('name', models.CharField(max_length=20)),
('number', models.IntegerField(default=100)), ('number', models.IntegerField(default=100)),
], ],

View File

@ -2,14 +2,17 @@ import re
from collections import namedtuple from collections import namedtuple
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.generic import View from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from packaging import version from packaging import version
from extras.dashboard.utils import get_dashboard from extras.constants import DEFAULT_DASHBOARD
from extras.dashboard.utils import get_dashboard, get_default_dashboard
from netbox.forms import SearchForm from netbox.forms import SearchForm
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
@ -33,7 +36,13 @@ class HomeView(View):
return redirect('login') return redirect('login')
# Construct the user's custom dashboard layout # Construct the user's custom dashboard layout
try:
dashboard = get_dashboard(request.user).get_layout() dashboard = get_dashboard(request.user).get_layout()
except Exception:
messages.error(request, _(
"There was an error loading the dashboard configuration. A default dashboard is in use."
))
dashboard = get_default_dashboard(config=DEFAULT_DASHBOARD).get_layout()
# Check whether a new release is available. (Only for staff/superusers.) # Check whether a new release is available. (Only for staff/superusers.)
new_release = None new_release = None

View File

@ -1,45 +1,37 @@
{% extends 'dcim/devicetype/base.html' %} {% extends 'generic/object_children.html' %}
{% load render_table from django_tables2 %}
{% load helpers %} {% load helpers %}
{% load i18n %} {% load i18n %}
{% load perms %}
{% block content %} {% block bulk_edit_controls %}
{% if perms.dcim.change_devicetype %} {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
<form method="post"> {% if 'bulk_edit' in actions and bulk_edit_view %}
{% csrf_token %} <button type="submit" name="_edit"
<div class="card"> formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
<h5 class="card-header">{{ title }}</h5> class="btn btn-warning btn-sm">
<div class="card-body htmx-container table-responsive" id="object_list"> <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
{% include 'htmx/table.html' %}
</div>
<div class="card-footer noprint">
{% if table.rows %}
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-sm btn-warning">
<span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %}
</button>
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-sm btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
</button>
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-sm btn-danger">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button> </button>
{% endif %} {% endif %}
<div class="float-end"> {% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if request.user|can_add:child_model %}
<div class="bulk-button-group">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm"> <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% trans "Add" %} {{ title }} {% trans "Add" %} {{ title }}
</a> </a>
</div> </div>
<div class="clearfix"></div>
</div>
</div>
</form>
{% else %}
<div class="card">
<h5 class="card-header">{{ title }}</h5>
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
{% endif %} {% endif %}
{% endblock content %} {% endblock bulk_extra_controls %}

View File

@ -34,7 +34,7 @@
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:report_jobs' module=report.module name=report.class_name %}"> <a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:report_jobs' module=report.module name=report.class_name %}">
{% trans "Jobs" %} {% badge module.jobs.count %} {% trans "Jobs" %} {% badge job_count %}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -33,7 +33,7 @@
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}"> <a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}">
{% trans "Jobs" %} {% badge module.jobs.count %} {% trans "Jobs" %} {% badge job_count %}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -134,7 +134,7 @@
{% with first_available_ip=object.get_first_available_ip %} {% with first_available_ip=object.get_first_available_ip %}
{% if first_available_ip %} {% if first_available_ip %}
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}">{{ first_available_ip }}</a> <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}">{{ first_available_ip }}</a>
{% else %} {% else %}
{{ first_available_ip }} {{ first_available_ip }}
{% endif %} {% endif %}

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

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

View File

@ -27,6 +27,13 @@ def get_serializer_for_model(model, prefix=''):
# Serializers for Django's auth models are in the users app # Serializers for Django's auth models are in the users app
if app_name == 'auth': if app_name == 'auth':
app_name = 'users' app_name = 'users'
# Account for changes using Proxy model
if app_name == 'users':
if model_name == 'NetBoxUser':
model_name = 'User'
elif model_name == 'NetBoxGroup':
model_name = 'Group'
serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer' serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer'
try: try:
return dynamic_import(serializer_name) return dynamic_import(serializer_name)

View File

@ -105,7 +105,12 @@ class JSONField(_JSONField):
return value return value
if value in ('', None): if value in ('', None):
return '' return ''
return json.dumps(value, sort_keys=True, indent=4) if type(value) is str:
try:
value = json.loads(value, cls=self.decoder)
except json.decoder.JSONDecodeError:
return value
return json.dumps(value, sort_keys=True, indent=4, ensure_ascii=False, cls=self.encoder)
class MACAddressField(forms.Field): class MACAddressField(forms.Field):

View File

@ -24,8 +24,9 @@ def can_view(user, instance):
@register.filter() @register.filter()
def can_add(user, instance): def can_add(user, model):
return _check_permission(user, instance, 'add') permission = get_permission_for_model(model, 'add')
return user.has_perm(perm=permission)
@register.filter() @register.filter()

View File

@ -52,6 +52,8 @@ def get_viewname(model, action=None, rest_api=False):
# Alter the app_label for group and user model_name to point to users app # Alter the app_label for group and user model_name to point to users app
if app_label == 'auth' and model_name in ['group', 'user']: if app_label == 'auth' and model_name in ['group', 'user']:
app_label = 'users' app_label = 'users'
if app_label == 'users' and model._meta.proxy and model_name in ['netboxuser', 'netboxgroup']:
model_name = model._meta.proxy_for_model._meta.model_name
viewname = f'{app_label}-api:{model_name}' viewname = f'{app_label}-api:{model_name}'
# Append the action, if any # Append the action, if any

View File

@ -1,5 +1,7 @@
from django.apps import AppConfig from django.apps import AppConfig
from netbox import denormalized
class VirtualizationConfig(AppConfig): class VirtualizationConfig(AppConfig):
name = 'virtualization' name = 'virtualization'
@ -9,5 +11,10 @@ class VirtualizationConfig(AppConfig):
from .models import VirtualMachine from .models import VirtualMachine
from utilities.counters import connect_counters from utilities.counters import connect_counters
# Register denormalized fields
denormalized.register(VirtualMachine, 'cluster', {
'site': 'site',
})
# Register counters # Register counters
connect_counters(VirtualMachine) connect_counters(VirtualMachine)

View File

@ -96,7 +96,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
} }
) )
description = forms.CharField( description = forms.CharField(
label=_('Site'), label=_('Description'),
max_length=200, max_length=200,
required=False required=False
) )

View File

@ -164,7 +164,7 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm):
)), )),
) )
nullable_fields = ( nullable_fields = (
'preshared_key', 'description', 'comments', 'mode', 'preshared_key', 'description', 'comments',
) )

View File

@ -174,7 +174,8 @@ class IKEPolicyImportForm(NetBoxModelImportForm):
) )
mode = CSVChoiceField( mode = CSVChoiceField(
label=_('Mode'), label=_('Mode'),
choices=IKEModeChoices choices=IKEModeChoices,
required=False
) )
proposals = CSVModelMultipleChoiceField( proposals = CSVModelMultipleChoiceField(
queryset=IKEProposal.objects.all(), queryset=IKEProposal.objects.all(),

View File

@ -141,7 +141,7 @@ class TunnelCreateForm(TunnelForm):
) )
fieldsets = ( fieldsets = (
(_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')),
(_('Security'), ('ipsec_profile',)), (_('Security'), ('ipsec_profile',)),
(_('Tenancy'), ('tenant_group', 'tenant')), (_('Tenancy'), ('tenant_group', 'tenant')),
(_('First Termination'), ( (_('First Termination'), (
@ -265,9 +265,15 @@ class TunnelTerminationForm(NetBoxModelForm):
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE: if (get_field_value(self, 'type') is None and
self.instance.pk and isinstance(self.instance.termination.parent_object, VirtualMachine)):
self.fields['type'].initial = TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE
# If initial or self.data is set and the type is a VIRTUALMACHINE type, swap the field querysets.
if get_field_value(self, 'type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
self.fields['parent'].label = _('Virtual Machine') self.fields['parent'].label = _('Virtual Machine')
self.fields['parent'].queryset = VirtualMachine.objects.all() self.fields['parent'].queryset = VirtualMachine.objects.all()
self.fields['parent'].widget.attrs['selector'] = 'virtualization.virtualmachine'
self.fields['termination'].queryset = VMInterface.objects.all() self.fields['termination'].queryset = VMInterface.objects.all()
self.fields['termination'].widget.add_query_params({ self.fields['termination'].widget.add_query_params({
'virtual_machine_id': '$parent', 'virtual_machine_id': '$parent',

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2024-01-20 09:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0003_ipaddress_multiple_tunnel_terminations'),
]
operations = [
migrations.AlterField(
model_name='ikepolicy',
name='mode',
field=models.CharField(blank=True),
),
]

View File

@ -79,7 +79,8 @@ class IKEPolicy(PrimaryModel):
) )
mode = models.CharField( mode = models.CharField(
verbose_name=_('mode'), verbose_name=_('mode'),
choices=IKEModeChoices choices=IKEModeChoices,
blank=True
) )
proposals = models.ManyToManyField( proposals = models.ManyToManyField(
to='vpn.IKEProposal', to='vpn.IKEProposal',
@ -109,6 +110,17 @@ class IKEPolicy(PrimaryModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('vpn:ikepolicy', args=[self.pk]) return reverse('vpn:ikepolicy', args=[self.pk])
def clean(self):
super().clean()
# Mode is required
if self.version == IKEVersionChoices.VERSION_1 and not self.mode:
raise ValidationError(_("Mode is required for selected IKE version"))
# Mode cannot be used
if self.version == IKEVersionChoices.VERSION_2 and self.mode:
raise ValidationError(_("Mode cannot be used for selected IKE version"))
# #
# IPSec # IPSec

View File

@ -305,7 +305,6 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'name': 'IKE Policy X', 'name': 'IKE Policy X',
'version': IKEVersionChoices.VERSION_2, 'version': IKEVersionChoices.VERSION_2,
'mode': IKEModeChoices.AGGRESSIVE,
'proposals': [p.pk for p in ike_proposals], 'proposals': [p.pk for p in ike_proposals],
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
@ -313,9 +312,9 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ike_proposal_names = ','.join([p.name for p in ike_proposals]) ike_proposal_names = ','.join([p.name for p in ike_proposals])
cls.csv_data = ( cls.csv_data = (
"name,version,mode,proposals", "name,version,mode,proposals",
f"IKE Proposal 4,2,aggressive,\"{ike_proposal_names}\"", f"IKE Proposal 4,1,main,\"{ike_proposal_names}\"",
f"IKE Proposal 5,2,aggressive,\"{ike_proposal_names}\"", f"IKE Proposal 5,1,aggressive,\"{ike_proposal_names}\"",
f"IKE Proposal 6,2,aggressive,\"{ike_proposal_names}\"", f"IKE Proposal 6,2,,\"{ike_proposal_names}\"",
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -327,7 +326,7 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
'version': IKEVersionChoices.VERSION_2, 'version': IKEVersionChoices.VERSION_1,
'mode': IKEModeChoices.AGGRESSIVE, 'mode': IKEModeChoices.AGGRESSIVE,
} }

View File

@ -1,7 +1,7 @@
bleach==6.1.0 bleach==6.1.0
Django==4.2.9 Django==4.2.9
django-cors-headers==4.3.1 django-cors-headers==4.3.1
django-debug-toolbar==4.2.0 django-debug-toolbar==4.3.0
django-filter==23.5 django-filter==23.5
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14.0 django-mptt==0.14.0
@ -14,22 +14,22 @@ django-taggit==5.0.1
django-tables2==2.7.0 django-tables2==2.7.0
django-timezone-field==6.1.0 django-timezone-field==6.1.0
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-spectacular==0.27.0 drf-spectacular==0.27.1
drf-spectacular-sidecar==2024.1.1 drf-spectacular-sidecar==2024.2.1
feedparser==6.0.11 feedparser==6.0.11
graphene-django==3.0.0 graphene-django==3.0.0
gunicorn==21.2.0 gunicorn==21.2.0
Jinja2==3.1.3 Jinja2==3.1.3
Markdown==3.5.2 Markdown==3.5.2
mkdocs-material==9.5.4 mkdocs-material==9.5.7
mkdocstrings[python-legacy]==0.24.0 mkdocstrings[python-legacy]==0.24.0
netaddr==0.10.1 netaddr==0.10.1
Pillow==10.2.0 Pillow==10.2.0
psycopg[binary,pool]==3.1.17 psycopg[binary,pool]==3.1.18
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.31.0
social-auth-app-django==5.4.0 social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.5.1 social-auth-core[openidconnect]==4.5.2
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.5.0 tablib==3.5.0
tzdata==2023.4 tzdata==2023.4