Merge branch 'netbox-community:develop' into develop
This commit is contained in:
commit
f613fe7650
|
@ -23,7 +23,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.1
|
||||
placeholder: v3.7.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -14,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.1
|
||||
placeholder: v3.7.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -9,6 +9,7 @@ on:
|
|||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
discussions: write
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
|
|
|
@ -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.)
|
||||
|
||||
* 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
|
||||
* All tests pass when run with `./manage.py test`
|
||||
* PEP 8 compliance is enforced, with the exception that lines may be
|
||||
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:
|
||||
* 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.
|
||||
|
@ -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.
|
||||
|
||||
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!
|
||||
|
||||
## :heart: Other Ways to Contribute
|
||||
|
|
|
@ -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/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://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>
|
||||
<p></p>
|
||||
</div>
|
||||
|
|
|
@ -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.
|
||||
|
||||
!!! note
|
||||
These system defaults will be overridden by a user's selected language/locale when [localization](./system.md#enable_localization) is enabled.
|
||||
|
||||
```python
|
||||
DATE_FORMAT = 'N j, Y' # June 26, 2016
|
||||
SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26
|
||||
|
|
|
@ -69,15 +69,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
|||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## GIT_PATH
|
||||
|
||||
Default: `git`
|
||||
|
||||
The system path to the `git` executable, used by the synchronization backend for remote git repositories.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
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.
|
||||
|
|
|
@ -14,7 +14,7 @@ The IKE version employed (v1 or v2).
|
|||
|
||||
### 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
|
||||
|
||||
|
|
|
@ -47,3 +47,14 @@ class ReminderWidget(DashboardWidget):
|
|||
def render(self, request):
|
||||
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
|
||||
```
|
||||
|
|
|
@ -20,4 +20,4 @@ backends = [MyDataBackend]
|
|||
!!! tip
|
||||
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
|
||||
|
|
|
@ -1,15 +1,39 @@
|
|||
# NetBox v3.7
|
||||
|
||||
## v3.7.2 (FUTURE)
|
||||
## v3.7.3 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
|||
model = DataSource
|
||||
fields = [
|
||||
'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',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
|||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Enforce unique space')
|
||||
label=_('Enabled')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
|
|
|
@ -14,6 +14,7 @@ from django.utils import timezone
|
|||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import JobsMixin
|
||||
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)"
|
||||
})
|
||||
|
||||
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):
|
||||
"""
|
||||
Enqueue a background job to synchronize the DataSource by calling sync().
|
||||
|
|
|
@ -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)
|
|
@ -727,7 +727,7 @@ class PowerOutletImportForm(NetBoxModelImportForm):
|
|||
help_text=_('Local power port which feeds this outlet')
|
||||
)
|
||||
feed_leg = CSVChoiceField(
|
||||
label=_('Feed lag'),
|
||||
label=_('Feed leg'),
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
required=False,
|
||||
help_text=_('Electrical phase (for three-phase circuits)')
|
||||
|
@ -1359,6 +1359,10 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
|
|||
to_field_name='name',
|
||||
help_text='Assigned tenant'
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
label=_('Status'),
|
||||
choices=VirtualDeviceContextStatusChoices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import graphene
|
||||
from circuits.graphql.types import CircuitTerminationType
|
||||
from circuits.models import CircuitTermination
|
||||
from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
|
||||
from circuits.models import CircuitTermination, ProviderNetwork
|
||||
from dcim.graphql.types import (
|
||||
ConsolePortTemplateType,
|
||||
ConsolePortType,
|
||||
|
@ -167,3 +167,42 @@ class InventoryItemComponentType(graphene.Union):
|
|||
return PowerPortType
|
||||
if type(instance) is RearPort:
|
||||
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
|
||||
|
|
|
@ -13,7 +13,7 @@ class CabledObjectMixin:
|
|||
|
||||
|
||||
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):
|
||||
# Handle empty values
|
||||
|
|
|
@ -58,7 +58,11 @@ class DeviceComponentsView(generic.ObjectChildrenView):
|
|||
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()
|
||||
template_name = 'dcim/devicetype/component_templates.html'
|
||||
viewname = None # Used for return_url resolution
|
||||
|
|
|
@ -53,13 +53,13 @@ def get_dashboard(user):
|
|||
return dashboard
|
||||
|
||||
|
||||
def get_default_dashboard():
|
||||
def get_default_dashboard(config=None):
|
||||
from extras.models import 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())
|
||||
dashboard.layout.append({
|
||||
'id': id,
|
||||
|
|
|
@ -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):
|
||||
try:
|
||||
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
|
||||
if username:
|
||||
user = get_user_model().objects.get(username=username)
|
||||
except ObjectDoesNotExist:
|
||||
else:
|
||||
user = None
|
||||
|
||||
for event_rule in event_rules:
|
||||
|
|
|
@ -142,10 +142,12 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
|||
}
|
||||
help_texts = {
|
||||
'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."
|
||||
),
|
||||
'link_url': _("Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>."),
|
||||
).format(example="<code>{{ object }}</code>"),
|
||||
'link_url': _(
|
||||
"Jinja2 template code for the link URL. Reference the object as {example}."
|
||||
).format(example="<code>{{ object }}</code>"),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -8,6 +8,16 @@ __all__ = (
|
|||
|
||||
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
|
||||
def path(self):
|
||||
return os.path.splitext(self.file_path)[0]
|
||||
|
|
|
@ -120,34 +120,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||
if self.model._meta.model_name == 'device':
|
||||
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(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':
|
||||
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)
|
||||
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(
|
||||
(Q(
|
||||
regions__tree_id=OuterRef(f'{region_field}__tree_id'),
|
||||
regions__level__lte=OuterRef(f'{region_field}__level'),
|
||||
regions__lft__lte=OuterRef(f'{region_field}__lft'),
|
||||
regions__rght__gte=OuterRef(f'{region_field}__rght'),
|
||||
regions__tree_id=OuterRef('site__region__tree_id'),
|
||||
regions__level__lte=OuterRef('site__region__level'),
|
||||
regions__lft__lte=OuterRef('site__region__lft'),
|
||||
regions__rght__gte=OuterRef('site__region__rght'),
|
||||
) | Q(regions=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
||||
base_query.add(
|
||||
(Q(
|
||||
site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'),
|
||||
site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'),
|
||||
site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'),
|
||||
site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'),
|
||||
site_groups__tree_id=OuterRef('site__group__tree_id'),
|
||||
site_groups__level__lte=OuterRef('site__group__level'),
|
||||
site_groups__lft__lte=OuterRef('site__group__lft'),
|
||||
site_groups__rght__gte=OuterRef('site__group__rght'),
|
||||
) | Q(site_groups=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
|
|
@ -68,21 +68,23 @@ def handle_changed_object(sender, instance, **kwargs):
|
|||
else:
|
||||
return
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
# Create/update an ObejctChange record for this change
|
||||
objectchange = instance.to_objectchange(action)
|
||||
# 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_id=instance.pk,
|
||||
request_id=request.id
|
||||
).update(
|
||||
postchange_data=instance.to_objectchange(action).postchange_data
|
||||
)
|
||||
else:
|
||||
objectchange = instance.to_objectchange(action)
|
||||
if objectchange and objectchange.has_changes:
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
).first()
|
||||
):
|
||||
prev_change.postchange_data = objectchange.postchange_data
|
||||
prev_change.save()
|
||||
elif objectchange and objectchange.has_changes:
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
queue = events_queue.get()
|
||||
|
@ -251,7 +253,8 @@ def process_job_start_event_rules(sender, **kwargs):
|
|||
Process event rules for jobs starting.
|
||||
"""
|
||||
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)
|
||||
|
@ -260,4 +263,5 @@ def process_job_end_event_rules(sender, **kwargs):
|
|||
Process event rules for jobs terminating.
|
||||
"""
|
||||
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)
|
||||
|
|
|
@ -270,7 +270,12 @@ class ConfigContextTest(TestCase):
|
|||
tag = Tag.objects.first()
|
||||
cluster_type = ClusterType.objects.create(name="Cluster Type")
|
||||
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(
|
||||
name="region",
|
||||
|
@ -354,6 +359,41 @@ class ConfigContextTest(TestCase):
|
|||
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())
|
||||
|
||||
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):
|
||||
"""
|
||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||
|
|
|
@ -1057,16 +1057,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
report.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name,
|
||||
report.result = jobs.filter(
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
|
||||
|
@ -1078,6 +1076,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
|
||||
|
||||
if form.is_valid():
|
||||
|
@ -1086,6 +1085,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||
if not get_workers_for_queue('default'):
|
||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'report': report,
|
||||
})
|
||||
|
||||
|
@ -1103,6 +1103,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||
return redirect('extras:report_result', job_pk=job.pk)
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'form': form,
|
||||
|
@ -1117,8 +1118,10 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
|
|||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
return render(request, 'extras/report/source.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'tab': 'source',
|
||||
|
@ -1133,13 +1136,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
|||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[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 = module.get_jobs(report.class_name)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
|
@ -1149,6 +1146,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
|||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/report/jobs.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'table': jobs_table,
|
||||
|
@ -1232,19 +1230,11 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
|||
def get(self, request, module, name):
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
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', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
|
@ -1256,6 +1246,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
|||
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
|
||||
# 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 render(request, 'extras/script.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
|
@ -1293,8 +1285,10 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
|
|||
def get(self, request, module, name):
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
|
||||
return render(request, 'extras/script/source.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'tab': 'source',
|
||||
|
@ -1309,13 +1303,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
|||
def get(self, request, module, name):
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[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 = module.get_jobs(script.class_name)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
|
@ -1325,6 +1313,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
|||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/script/jobs.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'table': jobs_table,
|
||||
|
|
|
@ -254,7 +254,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Treat as 100% utilized')
|
||||
label=_('Treat as fully utilized')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
|
@ -298,7 +298,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
|||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Treat as 100% utilized')
|
||||
label=_('Treat as fully utilized')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
|
|
|
@ -240,7 +240,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||
)
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Marked as 100% utilized'),
|
||||
label=_('Treat as fully utilized'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
|
@ -279,7 +279,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||
)
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Marked as 100% utilized'),
|
||||
label=_('Treat as fully utilized'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
|
|
|
@ -268,7 +268,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
|||
mark_utilized = models.BooleanField(
|
||||
verbose_name=_('mark utilized'),
|
||||
default=False,
|
||||
help_text=_("Treat as 100% utilized")
|
||||
help_text=_("Treat as fully utilized")
|
||||
)
|
||||
|
||||
# Cached depth & child counts
|
||||
|
@ -427,10 +427,10 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
|||
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
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():
|
||||
child_ranges.add(iprange.range)
|
||||
available_ips = prefix - child_ips - child_ranges
|
||||
child_ranges.append(iprange.range)
|
||||
available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
|
||||
|
||||
# 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):
|
||||
|
@ -535,7 +535,7 @@ class IPRange(PrimaryModel):
|
|||
mark_utilized = models.BooleanField(
|
||||
verbose_name=_('mark utilized'),
|
||||
default=False,
|
||||
help_text=_("Treat as 100% utilized")
|
||||
help_text=_("Treat as fully utilized")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
|
|
|
@ -1068,6 +1068,12 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView):
|
|||
instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id'))
|
||||
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')
|
||||
class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView):
|
||||
|
|
|
@ -36,3 +36,7 @@ DEFAULT_ACTION_PERMISSIONS = {
|
|||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
# General-purpose tokens
|
||||
CENSOR_TOKEN = '********'
|
||||
CENSOR_TOKEN_CHANGED = '***CHANGED***'
|
||||
|
|
|
@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
|
|||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.7.2-dev'
|
||||
VERSION = '3.7.3-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -122,7 +122,6 @@ EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
|
|||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
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)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||
|
@ -726,8 +725,10 @@ LANGUAGES = (
|
|||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('fr', _('French')),
|
||||
('ja', _('Japanese')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('tr', _('Turkish')),
|
||||
)
|
||||
|
||||
LOCALE_PATHS = (
|
||||
|
|
|
@ -2,14 +2,17 @@ import re
|
|||
from collections import namedtuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
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.search import LookupTypes
|
||||
from netbox.search.backends import search_backend
|
||||
|
@ -33,7 +36,13 @@ class HomeView(View):
|
|||
return redirect('login')
|
||||
|
||||
# Construct the user's custom dashboard layout
|
||||
dashboard = get_dashboard(request.user).get_layout()
|
||||
try:
|
||||
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.)
|
||||
new_release = None
|
||||
|
|
|
@ -1,45 +1,37 @@
|
|||
{% extends 'dcim/devicetype/base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% extends 'generic/object_children.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
{% load perms %}
|
||||
|
||||
{% block content %}
|
||||
{% if perms.dcim.change_devicetype %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<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 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>
|
||||
{% endif %}
|
||||
<div class="float-end">
|
||||
<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>
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% block bulk_edit_controls %}
|
||||
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
|
||||
{% if 'bulk_edit' in actions and bulk_edit_view %}
|
||||
<button type="submit" name="_edit"
|
||||
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
|
||||
class="btn btn-warning btn-sm">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</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 %}
|
||||
{% endblock content %}
|
||||
{% endif %}
|
||||
{% endblock bulk_extra_controls %}
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
</li>
|
||||
<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 %}">
|
||||
{% trans "Jobs" %} {% badge module.jobs.count %}
|
||||
{% trans "Jobs" %} {% badge job_count %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
</li>
|
||||
<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 %}">
|
||||
{% trans "Jobs" %} {% badge module.jobs.count %}
|
||||
{% trans "Jobs" %} {% badge job_count %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -134,7 +134,7 @@
|
|||
{% with first_available_ip=object.get_first_available_ip %}
|
||||
{% if first_available_ip %}
|
||||
{% 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 %}
|
||||
{{ first_available_ip }}
|
||||
{% endif %}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -27,6 +27,13 @@ def get_serializer_for_model(model, prefix=''):
|
|||
# Serializers for Django's auth models are in the users app
|
||||
if app_name == 'auth':
|
||||
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'
|
||||
try:
|
||||
return dynamic_import(serializer_name)
|
||||
|
|
|
@ -105,7 +105,12 @@ class JSONField(_JSONField):
|
|||
return value
|
||||
if value in ('', None):
|
||||
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):
|
||||
|
|
|
@ -24,8 +24,9 @@ def can_view(user, instance):
|
|||
|
||||
|
||||
@register.filter()
|
||||
def can_add(user, instance):
|
||||
return _check_permission(user, instance, 'add')
|
||||
def can_add(user, model):
|
||||
permission = get_permission_for_model(model, 'add')
|
||||
return user.has_perm(perm=permission)
|
||||
|
||||
|
||||
@register.filter()
|
||||
|
|
|
@ -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
|
||||
if app_label == 'auth' and model_name in ['group', 'user']:
|
||||
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}'
|
||||
# Append the action, if any
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class VirtualizationConfig(AppConfig):
|
||||
name = 'virtualization'
|
||||
|
@ -9,5 +11,10 @@ class VirtualizationConfig(AppConfig):
|
|||
from .models import VirtualMachine
|
||||
from utilities.counters import connect_counters
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(VirtualMachine, 'cluster', {
|
||||
'site': 'site',
|
||||
})
|
||||
|
||||
# Register counters
|
||||
connect_counters(VirtualMachine)
|
||||
|
|
|
@ -96,7 +96,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
|
|||
}
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Site'),
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
|
|
|
@ -164,7 +164,7 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm):
|
|||
)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'preshared_key', 'description', 'comments',
|
||||
'mode', 'preshared_key', 'description', 'comments',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -174,7 +174,8 @@ class IKEPolicyImportForm(NetBoxModelImportForm):
|
|||
)
|
||||
mode = CSVChoiceField(
|
||||
label=_('Mode'),
|
||||
choices=IKEModeChoices
|
||||
choices=IKEModeChoices,
|
||||
required=False
|
||||
)
|
||||
proposals = CSVModelMultipleChoiceField(
|
||||
queryset=IKEProposal.objects.all(),
|
||||
|
|
|
@ -141,7 +141,7 @@ class TunnelCreateForm(TunnelForm):
|
|||
)
|
||||
|
||||
fieldsets = (
|
||||
(_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
|
||||
(_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')),
|
||||
(_('Security'), ('ipsec_profile',)),
|
||||
(_('Tenancy'), ('tenant_group', 'tenant')),
|
||||
(_('First Termination'), (
|
||||
|
@ -265,9 +265,15 @@ class TunnelTerminationForm(NetBoxModelForm):
|
|||
def __init__(self, *args, initial=None, **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'].queryset = VirtualMachine.objects.all()
|
||||
self.fields['parent'].widget.attrs['selector'] = 'virtualization.virtualmachine'
|
||||
self.fields['termination'].queryset = VMInterface.objects.all()
|
||||
self.fields['termination'].widget.add_query_params({
|
||||
'virtual_machine_id': '$parent',
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -79,7 +79,8 @@ class IKEPolicy(PrimaryModel):
|
|||
)
|
||||
mode = models.CharField(
|
||||
verbose_name=_('mode'),
|
||||
choices=IKEModeChoices
|
||||
choices=IKEModeChoices,
|
||||
blank=True
|
||||
)
|
||||
proposals = models.ManyToManyField(
|
||||
to='vpn.IKEProposal',
|
||||
|
@ -109,6 +110,17 @@ class IKEPolicy(PrimaryModel):
|
|||
def get_absolute_url(self):
|
||||
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
|
||||
|
|
|
@ -305,7 +305,6 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||
cls.form_data = {
|
||||
'name': 'IKE Policy X',
|
||||
'version': IKEVersionChoices.VERSION_2,
|
||||
'mode': IKEModeChoices.AGGRESSIVE,
|
||||
'proposals': [p.pk for p in ike_proposals],
|
||||
'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])
|
||||
cls.csv_data = (
|
||||
"name,version,mode,proposals",
|
||||
f"IKE Proposal 4,2,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 5,2,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 6,2,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 4,1,main,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 5,1,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 6,2,,\"{ike_proposal_names}\"",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
@ -327,7 +326,7 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
'version': IKEVersionChoices.VERSION_2,
|
||||
'version': IKEVersionChoices.VERSION_1,
|
||||
'mode': IKEModeChoices.AGGRESSIVE,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
bleach==6.1.0
|
||||
Django==4.2.9
|
||||
django-cors-headers==4.3.1
|
||||
django-debug-toolbar==4.2.0
|
||||
django-debug-toolbar==4.3.0
|
||||
django-filter==23.5
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.14.0
|
||||
|
@ -14,22 +14,22 @@ django-taggit==5.0.1
|
|||
django-tables2==2.7.0
|
||||
django-timezone-field==6.1.0
|
||||
djangorestframework==3.14.0
|
||||
drf-spectacular==0.27.0
|
||||
drf-spectacular-sidecar==2024.1.1
|
||||
drf-spectacular==0.27.1
|
||||
drf-spectacular-sidecar==2024.2.1
|
||||
feedparser==6.0.11
|
||||
graphene-django==3.0.0
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.3
|
||||
Markdown==3.5.2
|
||||
mkdocs-material==9.5.4
|
||||
mkdocs-material==9.5.7
|
||||
mkdocstrings[python-legacy]==0.24.0
|
||||
netaddr==0.10.1
|
||||
Pillow==10.2.0
|
||||
psycopg[binary,pool]==3.1.17
|
||||
psycopg[binary,pool]==3.1.18
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.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
|
||||
tablib==3.5.0
|
||||
tzdata==2023.4
|
||||
|
|
Loading…
Reference in New Issue