mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-26 03:38:15 +01:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f6b728b9 | ||
|
|
503c78b0db | ||
|
|
cb05288c4d | ||
|
|
0373b8aade | ||
|
|
580d417aa1 | ||
|
|
8571f428b1 | ||
|
|
276a73f820 | ||
|
|
d8fb5a819f | ||
|
|
f14eac58e4 | ||
|
|
1780acc8a6 | ||
|
|
a3b8262ab0 | ||
|
|
a1ee02cdf0 | ||
|
|
f751afcce7 | ||
|
|
17a321a340 | ||
|
|
cf3969bc6c | ||
|
|
9b9afdcf79 | ||
|
|
50e5bb9717 | ||
|
|
a063b5563c | ||
|
|
8678d1a577 | ||
|
|
839609d101 | ||
|
|
dbcd713fe7 | ||
|
|
d216161014 | ||
|
|
056543e1d2 | ||
|
|
af27bf5eff | ||
|
|
29f029d480 | ||
|
|
bd7d4a3f34 | ||
|
|
de5c5aeb2a | ||
|
|
2e74952ac6 | ||
|
|
7cc215437f | ||
|
|
e84e2a7969 | ||
|
|
2d70b50286 | ||
|
|
01fa2710eb | ||
|
|
12d830bcf2 | ||
|
|
c37dfdc150 | ||
|
|
df910928f2 | ||
|
|
1f800a975f | ||
|
|
c7ae2db8e3 | ||
|
|
ae7d6ffd92 | ||
|
|
011bc5bd78 | ||
|
|
040dbcc875 | ||
|
|
64b2ebdc79 | ||
|
|
4afebd3565 | ||
|
|
28aee9b69a | ||
|
|
426805cd24 | ||
|
|
a331ba65cb | ||
|
|
4ba0ec78cf | ||
|
|
93edf74f7c | ||
|
|
8a77ec70f2 | ||
|
|
0eba3acdb8 | ||
|
|
32083e58c0 | ||
|
|
8e8d302850 | ||
|
|
fde9c1664a | ||
|
|
1a9149d7d4 | ||
|
|
31fb6961e9 | ||
|
|
b408beaed5 | ||
|
|
1b6fc49a3e | ||
|
|
9f25289ce2 | ||
|
|
59510b4bd0 | ||
|
|
1b9e6bed55 | ||
|
|
ba755221bb | ||
|
|
b9cac97b73 | ||
|
|
3dc43861c5 | ||
|
|
b9b4b8ae62 | ||
|
|
98c9f7fbbd | ||
|
|
441e24bca7 | ||
|
|
c8fb948a91 | ||
|
|
a141f7f771 | ||
|
|
f26ac3e7cb | ||
|
|
487f1ccfde | ||
|
|
481d16de08 | ||
|
|
23e201cec6 | ||
|
|
fea8efa149 | ||
|
|
0df7ca4309 | ||
|
|
e4188b5bde | ||
|
|
cd8e977418 | ||
|
|
88e4559b5a | ||
|
|
d606749335 | ||
|
|
ff752dac07 | ||
|
|
3aaf370d4a | ||
|
|
fd5392563f | ||
|
|
79e0d3ae67 | ||
|
|
1d15ba56b9 | ||
|
|
2b4ec9dc20 | ||
|
|
1651a307c8 | ||
|
|
93a05289ad | ||
|
|
04575aa0f8 | ||
|
|
d5733a1e89 | ||
|
|
48168de4ff | ||
|
|
a87d76ad17 | ||
|
|
749fc31bc4 | ||
|
|
ebf6ce1b01 | ||
|
|
b871a6c7a6 |
6
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -13,7 +13,9 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running NetBox?
|
||||
description: >
|
||||
How are you running NetBox? (For issues with the Docker image, please go to the
|
||||
[netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
|
||||
options:
|
||||
- Self-hosted
|
||||
- NetBox Cloud
|
||||
@@ -23,7 +25,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.1
|
||||
placeholder: v3.7.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -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.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -68,6 +68,9 @@ jobs:
|
||||
- name: Collect static files
|
||||
run: python netbox/manage.py collectstatic --no-input
|
||||
|
||||
- name: Check for missing migrations
|
||||
run: python netbox/manage.py makemigrations --check
|
||||
|
||||
- name: Check PEP8 compliance
|
||||
run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/
|
||||
|
||||
|
||||
4
.github/workflows/lock.yml
vendored
4
.github/workflows/lock.yml
vendored
@@ -9,13 +9,15 @@ on:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
discussions: write
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v4
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
discussion-inactive-days: 180
|
||||
issue-lock-reason: 'resolved'
|
||||
|
||||
@@ -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-7-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>
|
||||
|
||||
@@ -105,7 +105,7 @@ mkdocs-material
|
||||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
netaddr
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
Default: `|` (Pipe)
|
||||
|
||||
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ class NewBranchScript(Script):
|
||||
name=f'{site.slug}-switch{i}',
|
||||
site=site,
|
||||
status=DeviceStatusChoices.STATUS_PLANNED,
|
||||
role=switch_role
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.full_clean()
|
||||
switch.save()
|
||||
|
||||
@@ -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,5 +1,73 @@
|
||||
# NetBox v3.7
|
||||
|
||||
## v3.7.3 (2024-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
|
||||
* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
|
||||
* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
|
||||
* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
|
||||
* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
|
||||
* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
|
||||
* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
|
||||
* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
|
||||
* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
|
||||
* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
|
||||
* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
|
||||
* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
|
||||
* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
|
||||
* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
|
||||
* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
|
||||
* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
|
||||
* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
|
||||
* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
|
||||
* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
|
||||
* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
|
||||
* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
|
||||
* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
|
||||
* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
|
||||
* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -234,9 +234,9 @@ class CircuitTermination(
|
||||
|
||||
# Must define either site *or* provider network
|
||||
if self.site is None and self.provider_network is None:
|
||||
raise ValidationError("A circuit termination must attach to either a site or a provider network.")
|
||||
raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
|
||||
if self.site and self.provider_network:
|
||||
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
|
||||
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
@@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
|
||||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import serializers
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
|
||||
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class GitBackend(DataBackend):
|
||||
try:
|
||||
porcelain.clone(self.url, local_path.name, **clone_args)
|
||||
except BaseException as e:
|
||||
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
|
||||
raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
|
||||
|
||||
yield local_path.name
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
||||
super().clean()
|
||||
|
||||
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Cannot upload a file and sync from an existing file")
|
||||
raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
|
||||
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
|
||||
raise forms.ValidationError("Must upload a file or select a data file to sync")
|
||||
raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ class Command(_Command):
|
||||
"""
|
||||
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
|
||||
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(
|
||||
"This command is available for development purposes only. It will\n"
|
||||
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
|
||||
|
||||
@@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
if self.data and item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
|
||||
@@ -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().
|
||||
@@ -154,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
|
||||
"""
|
||||
if self.status == DataSourceStatusChoices.SYNCING:
|
||||
raise SyncError("Cannot initiate sync; syncing already in progress.")
|
||||
raise SyncError(_("Cannot initiate sync; syncing already in progress."))
|
||||
|
||||
# Emit the pre_sync signal
|
||||
pre_sync.send(sender=self.__class__, instance=self)
|
||||
@@ -167,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
backend = self.get_backend()
|
||||
except ModuleNotFoundError as e:
|
||||
raise SyncError(
|
||||
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
|
||||
_("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
|
||||
)
|
||||
with backend.fetch() as local_path:
|
||||
|
||||
|
||||
@@ -181,7 +181,11 @@ class Job(models.Model):
|
||||
"""
|
||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
|
||||
raise ValueError(
|
||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||
choices=', '.join(valid_statuses)
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the job as completed
|
||||
self.status = status
|
||||
|
||||
122
netbox/core/tests/test_models.py
Normal file
122
netbox/core/tests/test_models.py
Normal 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)
|
||||
@@ -166,7 +166,7 @@ class ConfigView(generic.ObjectView):
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -326,6 +326,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
front_image = serializers.URLField(allow_null=True, required=False)
|
||||
rear_image = serializers.URLField(allow_null=True, required=False)
|
||||
|
||||
# Counter fields
|
||||
console_port_template_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -191,6 +191,12 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filtersets.RackFilterSet
|
||||
|
||||
@extend_schema(
|
||||
operation_id='dcim_racks_elevation_retrieve',
|
||||
filters=False,
|
||||
parameters=[serializers.RackElevationDetailFilterSerializer],
|
||||
responses={200: serializers.RackUnitSerializer(many=True)}
|
||||
)
|
||||
@action(detail=True)
|
||||
def elevation(self, request, pk=None):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
|
||||
|
||||
from .lookups import PathContains
|
||||
@@ -41,7 +42,7 @@ class MACAddressField(models.Field):
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid MAC address format: {value}")
|
||||
raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr'
|
||||
@@ -67,7 +68,7 @@ class WWNField(models.Field):
|
||||
try:
|
||||
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
raise ValidationError(f"Invalid WWN format: {value}")
|
||||
raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr8'
|
||||
|
||||
@@ -2,6 +2,8 @@ import django_filters
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
@@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Manufacturer (slug)'),
|
||||
)
|
||||
available_for_device_type = django_filters.ModelChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
method='get_for_device_type'
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
@@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device_type(self, queryset, name, value):
|
||||
"""
|
||||
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
|
||||
manufacturer
|
||||
"""
|
||||
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
|
||||
|
||||
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
|
||||
@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
|
||||
model = Location
|
||||
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit location queryset by assigned site
|
||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackRoleImportForm(NetBoxModelImportForm):
|
||||
slug = SlugField()
|
||||
@@ -727,7 +735,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)')
|
||||
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
raise forms.ValidationError(
|
||||
_("VDC {vdc} is not assigned to device {device}").format(
|
||||
vdc=vdc, device=self.cleaned_data['device']
|
||||
)
|
||||
)
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
|
||||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device.pk)
|
||||
else:
|
||||
self.fields['installed_device'].queryset = Interface.objects.none()
|
||||
self.fields['installed_device'].queryset = Device.objects.none()
|
||||
|
||||
|
||||
class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
raise forms.ValidationError(
|
||||
_("Side {side_upper}: {device} {termination_object} is already connected").format(
|
||||
side_upper=side.upper(), device=device, termination_object=termination_object
|
||||
)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
raise forms.ValidationError(
|
||||
_("{side_upper} side termination not found: {device} {name}").format(
|
||||
side_upper=side.upper(), device=device, name=name
|
||||
)
|
||||
)
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
@@ -1359,6 +1382,10 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text='Assigned tenant'
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
label=_('Status'),
|
||||
choices=VirtualDeviceContextStatusChoices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
|
||||
@@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
|
||||
default_platform = DynamicModelChoiceField(
|
||||
label=_('Default platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'manufacturer_id': ['$manufacturer', 'null'],
|
||||
}
|
||||
)
|
||||
slug = SlugField(
|
||||
label=_('Slug'),
|
||||
@@ -444,7 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
selector=True
|
||||
selector=True,
|
||||
query_params={
|
||||
'available_for_device_type': '$device_type',
|
||||
}
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -160,25 +160,26 @@ class Cable(PrimaryModel):
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
raise ValidationError(_("Must specify a unit when setting a cable length"))
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
||||
|
||||
if self._terminations_modified:
|
||||
|
||||
# Check that all termination objects for either end are of the same type
|
||||
for terms in (self.a_terminations, self.b_terminations):
|
||||
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
|
||||
raise ValidationError("Cannot connect different termination types to same end of cable.")
|
||||
raise ValidationError(_("Cannot connect different termination types to same end of cable."))
|
||||
|
||||
# Check that termination types are compatible
|
||||
if self.a_terminations and self.b_terminations:
|
||||
a_type = self.a_terminations[0]._meta.model_name
|
||||
b_type = self.b_terminations[0]._meta.model_name
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
raise ValidationError(
|
||||
_("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
|
||||
)
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
@@ -323,17 +324,24 @@ class CableTermination(ChangeLoggedModel):
|
||||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
|
||||
app_label=self.termination_type.app_label,
|
||||
model=self.termination_type.model,
|
||||
termination_id=self.termination_id,
|
||||
cable_pk=existing_termination.cable.pk
|
||||
))
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
raise ValidationError(
|
||||
_("Cables cannot be terminated to {type_display} interfaces").format(
|
||||
type_display=self.termination.get_type_display()
|
||||
)
|
||||
)
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
|
||||
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
|
||||
raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
||||
@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
super().clean()
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
|
||||
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
|
||||
device_type=self.device.device_type
|
||||
))
|
||||
|
||||
# Cannot install a device into itself, obviously
|
||||
if self.device == self.installed_device:
|
||||
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
|
||||
raise ValidationError(_("Cannot install a device into itself."))
|
||||
|
||||
# Check that the installed device is not already installed elsewhere
|
||||
|
||||
@@ -875,7 +875,7 @@ class Device(
|
||||
if self.position and self.device_type.u_height == 0:
|
||||
raise ValidationError({
|
||||
'position': _(
|
||||
"A U0 device type ({device_type}) cannot be assigned to a rack position."
|
||||
"A 0U device type ({device_type}) cannot be assigned to a rack position."
|
||||
).format(device_type=self.device_type)
|
||||
})
|
||||
|
||||
|
||||
@@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
|
||||
verbose_name=_('Mark Connected'),
|
||||
)
|
||||
|
||||
def value_link_peer(self, value):
|
||||
return ', '.join([
|
||||
f"{termination.parent_object} > {termination}" for termination in value
|
||||
])
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = columns.TemplateColumn(
|
||||
|
||||
@@ -36,13 +36,17 @@ DEVICEBAY_STATUS = """
|
||||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
<div class="table-badge-group">
|
||||
{% for ip in value.all %}
|
||||
{% 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>
|
||||
{% else %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if value.count >= 3 %}
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }}</a>
|
||||
{% else %}
|
||||
{% for ip in value.all %}
|
||||
{% 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>
|
||||
{% else %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import *
|
||||
@@ -45,7 +46,7 @@ class Mixins:
|
||||
name='Peer Device'
|
||||
)
|
||||
if self.peer_termination_type is None:
|
||||
raise NotImplementedError("Test case must set peer_termination_type")
|
||||
raise NotImplementedError(_("Test case must set peer_termination_type"))
|
||||
peer_obj = self.peer_termination_type.objects.create(
|
||||
device=peer_device,
|
||||
name='Peer Termination'
|
||||
|
||||
@@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
|
||||
Platform(name='Platform 4', slug='platform-4'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
@@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_available_for_device_type(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturers[0],
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
u_height=1
|
||||
)
|
||||
params = {'available_for_device_type': device_type.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Device.objects.all()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.fields import Field
|
||||
@@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
|
||||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import ListField
|
||||
|
||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
||||
from core.api.serializers import JobSerializer
|
||||
@@ -149,7 +151,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
|
||||
|
||||
return value
|
||||
|
||||
@@ -175,6 +177,12 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
||||
choices=CustomFieldChoiceSetBaseChoices,
|
||||
required=False
|
||||
)
|
||||
extra_choices = serializers.ListField(
|
||||
child=serializers.ListField(
|
||||
min_length=2,
|
||||
max_length=2
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
@@ -538,12 +546,12 @@ class ReportInputSerializer(serializers.Serializer):
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['report'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this report.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
|
||||
return value
|
||||
|
||||
|
||||
@@ -588,12 +596,12 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
raise serializers.ValidationError("Scheduling is not enabled for this script.")
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import functools
|
||||
import re
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'Condition',
|
||||
@@ -50,11 +51,13 @@ class Condition:
|
||||
|
||||
def __init__(self, attr, value, op=EQ, negate=False):
|
||||
if op not in self.OPERATORS:
|
||||
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
|
||||
raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
|
||||
op=op, operators=', '.join(self.OPERATORS)
|
||||
))
|
||||
if type(value) not in self.TYPES:
|
||||
raise ValueError(f"Unsupported value type: {type(value)}")
|
||||
raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
|
||||
if op not in self.TYPES[type(value)]:
|
||||
raise ValueError(f"Invalid type for {op} operation: {type(value)}")
|
||||
raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
|
||||
|
||||
self.attr = attr
|
||||
self.value = value
|
||||
@@ -131,14 +134,17 @@ class ConditionSet:
|
||||
"""
|
||||
def __init__(self, ruleset):
|
||||
if type(ruleset) is not dict:
|
||||
raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
|
||||
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
||||
if len(ruleset) != 1:
|
||||
raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
|
||||
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
|
||||
ruleset=len(ruleset)))
|
||||
|
||||
# Determine the logic type
|
||||
logic = list(ruleset.keys())[0]
|
||||
if type(logic) is not str or logic.lower() not in (AND, OR):
|
||||
raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
|
||||
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
|
||||
logic=logic, op_and=AND, op_or=OR
|
||||
))
|
||||
self.logic = logic.lower()
|
||||
|
||||
# Compile the set of Conditions
|
||||
|
||||
@@ -2,6 +2,7 @@ import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from extras.constants import DEFAULT_DASHBOARD
|
||||
@@ -32,7 +33,7 @@ def get_widget_class(name):
|
||||
try:
|
||||
return registry['widgets'][name]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unregistered widget class: {name}")
|
||||
raise ValueError(_("Unregistered widget class: {name}").format(name=name))
|
||||
|
||||
|
||||
def get_dashboard(user):
|
||||
@@ -53,13 +54,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,
|
||||
|
||||
@@ -112,7 +112,9 @@ class DashboardWidget:
|
||||
Params:
|
||||
request: The current request
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__} must define a render() method.")
|
||||
raise NotImplementedError(_("{class_name} must define a render() method.").format(
|
||||
class_name=self.__class__
|
||||
))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -178,7 +180,7 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
try:
|
||||
dict(data)
|
||||
except TypeError:
|
||||
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
|
||||
raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
@@ -232,7 +234,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
try:
|
||||
urlencode(data)
|
||||
except (TypeError, ValueError):
|
||||
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
|
||||
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django_rq import get_queue
|
||||
|
||||
from core.models import Job
|
||||
@@ -71,10 +72,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:
|
||||
@@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username, snapshot
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
|
||||
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
|
||||
action_type=event_rule.action_type
|
||||
))
|
||||
|
||||
|
||||
def process_event_queue(events):
|
||||
@@ -175,4 +178,4 @@ def flush_events(queue):
|
||||
func = import_string(name)
|
||||
func(queue)
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot import events pipeline {name} error: {e}")
|
||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||
|
||||
@@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
||||
try:
|
||||
webhook = Webhook.objects.get(name=action_object)
|
||||
except Webhook.DoesNotExist:
|
||||
raise forms.ValidationError(f"Webhook {action_object} not found")
|
||||
raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
|
||||
self.instance.action_object = webhook
|
||||
# Script
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
@@ -211,7 +211,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
|
||||
try:
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Script {action_object} not found")
|
||||
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
|
||||
self.instance.action_object = module
|
||||
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
|
||||
self.instance.action_parameters = {
|
||||
|
||||
@@ -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>"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from netbox.search.backends import search_backend
|
||||
@@ -62,7 +63,7 @@ class Command(BaseCommand):
|
||||
# Determine which models to reindex
|
||||
indexers = self._get_indexers(*model_labels)
|
||||
if not indexers:
|
||||
raise CommandError("No indexers found!")
|
||||
raise CommandError(_("No indexers found!"))
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.9 on 2024-02-20 17:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0106_bookmark_user_cascade_deletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='cachedvalue',
|
||||
index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
|
||||
),
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
@@ -771,7 +771,7 @@ class Bookmark(models.Model):
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
@@ -57,6 +57,9 @@ class CachedValue(models.Model):
|
||||
ordering = ('weight', 'object_type', 'value', 'object_id')
|
||||
verbose_name = _('cached value')
|
||||
verbose_name_plural = _('cached values')
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from django.utils.functional import classproperty
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
@@ -356,7 +357,7 @@ class BaseScript:
|
||||
return ordered_vars
|
||||
|
||||
def run(self, data, commit):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
raise NotImplementedError(_("The script must define a run() method."))
|
||||
|
||||
# Form rendering
|
||||
|
||||
@@ -367,11 +368,11 @@ class BaseScript:
|
||||
fieldsets.extend(self.fieldsets)
|
||||
else:
|
||||
fields = list(name for name, _ in self._get_vars().items())
|
||||
fieldsets.append(('Script Data', fields))
|
||||
fieldsets.append((_('Script Data'), fields))
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
|
||||
fieldsets.append(('Script Execution Parameters', exec_parameters))
|
||||
fieldsets.append((_('Script Execution Parameters'), exec_parameters))
|
||||
|
||||
return fieldsets
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -12,9 +12,10 @@ from core.signals import job_end, job_start
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.events import process_event_rules
|
||||
from extras.models import EventRule
|
||||
from extras.validators import CustomValidator
|
||||
from extras.validators import run_validators
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
@@ -68,21 +69,23 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
else:
|
||||
return
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
# Create/update an ObjectChange 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()
|
||||
@@ -106,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Run any deletion protection rules for the object. Note that this must occur prior
|
||||
# to queueing any events for the object being deleted, in case a validation error is
|
||||
# raised, causing the deletion to fail.
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
raise AbortRequest(
|
||||
_("Deletion is prevented by a protection rule: {message}").format(message=e)
|
||||
)
|
||||
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
@@ -120,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Django does not automatically send an m2m_changed signal for the reverse direction of a
|
||||
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
|
||||
# trigger one manually. We do this by checking for any reverse M2M relationships on the
|
||||
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
|
||||
# the association. This triggers an m2m_changed signal with the `post_remove` action type
|
||||
# for the forward direction of the relationship, ensuring that the change is recorded.
|
||||
for relation in instance._meta.related_objects:
|
||||
if type(relation) is not ManyToManyRel:
|
||||
continue
|
||||
related_model = relation.related_model
|
||||
related_field_name = relation.remote_field.name
|
||||
if not issubclass(related_model, ChangeLoggingMixin):
|
||||
# We only care about triggering the m2m_changed signal for models which support
|
||||
# change logging
|
||||
continue
|
||||
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
|
||||
obj.snapshot() # Ensure the change record includes the "before" state
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
|
||||
# Enqueue webhooks
|
||||
queue = events_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
@@ -184,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
|
||||
# Custom validation
|
||||
#
|
||||
|
||||
def run_validators(instance, validators):
|
||||
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
||||
|
||||
@receiver(post_clean)
|
||||
def run_save_validators(sender, instance, **kwargs):
|
||||
"""
|
||||
Run any custom validation rules for the model prior to calling save().
|
||||
"""
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
|
||||
|
||||
run_validators(instance, validators)
|
||||
|
||||
|
||||
@receiver(pre_delete)
|
||||
def run_delete_validators(sender, instance, **kwargs):
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
raise AbortRequest(
|
||||
_("Deletion is prevented by a protection rule: {message}").format(
|
||||
message=e
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
@@ -251,7 +257,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 +267,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)
|
||||
|
||||
@@ -14,7 +14,6 @@ from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@@ -251,6 +250,23 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
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):
|
||||
model = CustomLink
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import importlib
|
||||
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -149,3 +151,21 @@ class CustomValidator:
|
||||
if field is not None:
|
||||
raise ValidationError({field: message})
|
||||
raise ValidationError(message)
|
||||
|
||||
|
||||
def run_validators(instance, validators):
|
||||
"""
|
||||
Run the provided iterable of validators for the instance.
|
||||
"""
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
|
||||
group = NestedFHRPGroupSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroupAssignment
|
||||
fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority']
|
||||
fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -3,6 +3,7 @@ from copy import deepcopy
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from netaddr import IPSet
|
||||
@@ -379,7 +380,7 @@ class AvailablePrefixesView(AvailableObjectsView):
|
||||
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||
})
|
||||
else:
|
||||
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
|
||||
raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
|
||||
|
||||
return requested_objects
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import AddrFormatError, IPNetwork
|
||||
|
||||
from . import lookups, validators
|
||||
@@ -32,7 +33,7 @@ class BaseIPField(models.Field):
|
||||
# Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Invalid IP address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IP address format: {address}").format(address=value))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_ipv4_address, validate_ipv6_address
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netaddr import IPAddress, IPNetwork, AddrFormatError
|
||||
|
||||
|
||||
@@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
|
||||
|
||||
class IPAddressFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
|
||||
'invalid': _("Enter a valid IPv4 or IPv6 address (without a mask)."),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
@@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
|
||||
try:
|
||||
validate_ipv6_address(value)
|
||||
except ValidationError:
|
||||
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
|
||||
raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
|
||||
|
||||
try:
|
||||
return IPAddress(value)
|
||||
except ValueError:
|
||||
raise ValidationError('This field requires an IP address without a mask.')
|
||||
raise ValidationError(_('This field requires an IP address without a mask.'))
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
||||
|
||||
class IPNetworkFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
|
||||
'invalid': _("Enter a valid IPv4 or IPv6 address (with CIDR mask)."),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
@@ -52,9 +53,9 @@ class IPNetworkFormField(forms.Field):
|
||||
|
||||
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
|
||||
if len(value.split('/')) != 2:
|
||||
raise ValidationError('CIDR mask (e.g. /24) is required.')
|
||||
raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
|
||||
|
||||
try:
|
||||
return IPNetwork(value)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
|
||||
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -751,4 +751,4 @@ class ServiceCreateForm(ServiceForm):
|
||||
if not self.cleaned_data['description']:
|
||||
self.cleaned_data['description'] = service_template.description
|
||||
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
|
||||
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
|
||||
raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FHRPGroupAssignment
|
||||
brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url']
|
||||
brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
|
||||
bulk_update_data = {
|
||||
'priority': 100,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator, RegexValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def prefix_validator(prefix):
|
||||
if prefix.ip != prefix.cidr.ip:
|
||||
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
|
||||
raise ValidationError(
|
||||
_("{prefix} is not a valid prefix. Did you mean {suggested}?").format(
|
||||
prefix=prefix, suggested=prefix.cidr
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MaxPrefixLengthValidator(BaseValidator):
|
||||
message = 'The prefix length must be less than or equal to %(limit_value)s.'
|
||||
message = _('The prefix length must be less than or equal to %(limit_value)s.')
|
||||
code = 'max_prefix_length'
|
||||
|
||||
def compare(self, a, b):
|
||||
@@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
|
||||
|
||||
|
||||
class MinPrefixLengthValidator(BaseValidator):
|
||||
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
|
||||
message = _('The prefix length must be greater than or equal to %(limit_value)s.')
|
||||
code = 'min_prefix_length'
|
||||
|
||||
def compare(self, a, b):
|
||||
@@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
|
||||
|
||||
DNSValidator = RegexValidator(
|
||||
regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
|
||||
message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
|
||||
message=_('Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names'),
|
||||
code='invalid'
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from netaddr import IPNetwork
|
||||
@@ -58,11 +59,11 @@ class ChoiceField(serializers.Field):
|
||||
if data == '':
|
||||
if self.allow_blank:
|
||||
return data
|
||||
raise ValidationError("This field may not be blank.")
|
||||
raise ValidationError(_("This field may not be blank."))
|
||||
|
||||
# Provide an explicit error message if the request is trying to write a dict or list
|
||||
if isinstance(data, (dict, list)):
|
||||
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
|
||||
raise ValidationError(_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.'))
|
||||
|
||||
# Check for string representations of boolean/integer values
|
||||
if hasattr(data, 'lower'):
|
||||
@@ -82,7 +83,7 @@ class ChoiceField(serializers.Field):
|
||||
except TypeError: # Input is an unhashable type
|
||||
pass
|
||||
|
||||
raise ValidationError(f"{data} is not a valid choice.")
|
||||
raise ValidationError(_("{value} is not a valid choice.").format(value=data))
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
@@ -95,8 +96,8 @@ class ContentTypeField(RelatedField):
|
||||
Represent a ContentType as '<app_label>.<model>'
|
||||
"""
|
||||
default_error_messages = {
|
||||
"does_not_exist": "Invalid content type: {content_type}",
|
||||
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
|
||||
"does_not_exist": _("Invalid content type: {content_type}"),
|
||||
"invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
|
||||
try:
|
||||
return queryset.get(**params)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(f"Related object not found using the provided attributes: {params}")
|
||||
raise ValidationError(
|
||||
_("Related object not found using the provided attributes: {params}").format(params=params))
|
||||
except MultipleObjectsReturned:
|
||||
raise ValidationError(f"Multiple objects match the provided attributes: {params}")
|
||||
raise ValidationError(
|
||||
_("Multiple objects match the provided attributes: {params}").format(params=params)
|
||||
)
|
||||
except FieldError as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
@@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
|
||||
pk = int(data)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
|
||||
f"unrecognized value: {data}"
|
||||
_(
|
||||
"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
|
||||
"unrecognized value: {value}"
|
||||
).format(value=data)
|
||||
)
|
||||
|
||||
# Look up object by PK
|
||||
try:
|
||||
return self.Meta.model.objects.get(pk=pk)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
|
||||
raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
|
||||
|
||||
|
||||
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _Rem
|
||||
from django.contrib.auth.models import Group, AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from users.constants import CONSTRAINT_TOKEN_USER
|
||||
from users.models import ObjectPermission
|
||||
@@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
|
||||
'hubspot': ('HubSpot', 'hubspot'),
|
||||
'keycloak': ('Keycloak', None),
|
||||
'microsoft-graph': ('Microsoft Graph', 'microsoft'),
|
||||
'oidc': ('OpenID Connect', None),
|
||||
'okta': ('Okta', None),
|
||||
'okta-openidconnect': ('Okta (OIDC)', None),
|
||||
'salesforce-oauth2': ('Salesforce', 'salesforce'),
|
||||
@@ -132,7 +134,9 @@ class ObjectPermissionMixin:
|
||||
# Sanity check: Ensure that the requested permission applies to the specified object
|
||||
model = obj._meta.concrete_model
|
||||
if model._meta.label_lower != '.'.join((app_label, model_name)):
|
||||
raise ValueError(f"Invalid permission {perm} for model {model}")
|
||||
raise ValueError(_("Invalid permission {permission} for model {model}").format(
|
||||
permission=perm, model=model
|
||||
))
|
||||
|
||||
# Compile a QuerySet filter that matches all instances of the specified model
|
||||
tokens = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import threading
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.utils import DatabaseError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .parameters import PARAMS
|
||||
|
||||
@@ -63,7 +64,7 @@ class Config:
|
||||
if item in self.defaults:
|
||||
return self.defaults[item]
|
||||
|
||||
raise AttributeError(f"Invalid configuration parameter: {item}")
|
||||
raise AttributeError(_("Invalid configuration parameter: {item}").format(item=item))
|
||||
|
||||
def _populate_from_cache(self):
|
||||
"""Populate config data from Redis cache"""
|
||||
|
||||
@@ -36,3 +36,7 @@ DEFAULT_ACTION_PERMISSIONS = {
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
# General-purpose tokens
|
||||
CENSOR_TOKEN = '********'
|
||||
CENSOR_TOKEN_CHANGED = '***CHANGED***'
|
||||
|
||||
@@ -35,7 +35,9 @@ class CustomFieldsMixin:
|
||||
Return the ContentType of the form's model.
|
||||
"""
|
||||
if not getattr(self, 'model', None):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
|
||||
raise NotImplementedError(_("{class_name} must specify a model class.").format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
return ContentType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
|
||||
@@ -275,16 +275,20 @@ class CustomFieldsMixin(models.Model):
|
||||
# Validate all field values
|
||||
for field_name, value in self.custom_field_data.items():
|
||||
if field_name not in custom_fields:
|
||||
raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
|
||||
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
|
||||
name=field_name
|
||||
))
|
||||
try:
|
||||
custom_fields[field_name].validate(value)
|
||||
except ValidationError as e:
|
||||
raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
|
||||
raise ValidationError(_("Invalid value for custom field '{name}': {error}").format(
|
||||
name=field_name, error=e.message
|
||||
))
|
||||
|
||||
# Check for missing required values
|
||||
for cf in custom_fields.values():
|
||||
if cf.required and cf.name not in self.custom_field_data:
|
||||
raise ValidationError(f"Missing required custom field '{cf.name}'.")
|
||||
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
||||
|
||||
|
||||
class CustomLinksMixin(models.Model):
|
||||
@@ -489,10 +493,10 @@ class SyncedDataMixin(models.Model):
|
||||
# Create/delete AutoSyncRecord as needed
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
if self.auto_sync_enabled:
|
||||
AutoSyncRecord.objects.get_or_create(
|
||||
datafile=self.data_file,
|
||||
AutoSyncRecord.objects.update_or_create(
|
||||
object_type=content_type,
|
||||
object_id=self.pk
|
||||
object_id=self.pk,
|
||||
defaults={'datafile': self.data_file}
|
||||
)
|
||||
else:
|
||||
AutoSyncRecord.objects.filter(
|
||||
@@ -547,7 +551,9 @@ class SyncedDataMixin(models.Model):
|
||||
Inheriting models must override this method with specific logic to copy data from the assigned DataFile
|
||||
to the local instance. This method should *NOT* call save() on the instance.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
|
||||
raise NotImplementedError(_("{class_name} must implement a sync_data() method.").format(
|
||||
class_name=self.__class__
|
||||
))
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
@@ -42,11 +43,11 @@ class PluginMenuItem:
|
||||
self.staff_only = staff_only
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
if buttons is not None:
|
||||
if type(buttons) not in (list, tuple):
|
||||
raise TypeError("Buttons must be passed as a tuple or list.")
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
|
||||
|
||||
@@ -64,9 +65,9 @@ class PluginMenuButton:
|
||||
self.icon_class = icon_class
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
if color is not None:
|
||||
if color not in ButtonColorChoices.values():
|
||||
raise ValueError("Button color must be a choice within ButtonColorChoices.")
|
||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||
self.color = color
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.registry import registry
|
||||
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
||||
from .templates import PluginTemplateExtension
|
||||
@@ -20,18 +21,32 @@ def register_template_extensions(class_list):
|
||||
# Validation
|
||||
for template_extension in class_list:
|
||||
if not inspect.isclass(template_extension):
|
||||
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
|
||||
raise TypeError(
|
||||
_("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
|
||||
template_extension=template_extension
|
||||
)
|
||||
)
|
||||
if not issubclass(template_extension, PluginTemplateExtension):
|
||||
raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!")
|
||||
raise TypeError(
|
||||
_("{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!").format(
|
||||
template_extension=template_extension
|
||||
)
|
||||
)
|
||||
if template_extension.model is None:
|
||||
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
|
||||
raise TypeError(
|
||||
_("PluginTemplateExtension class {template_extension} does not define a valid model!").format(
|
||||
template_extension=template_extension
|
||||
)
|
||||
)
|
||||
|
||||
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
|
||||
|
||||
|
||||
def register_menu(menu):
|
||||
if not isinstance(menu, PluginMenu):
|
||||
raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu")
|
||||
raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(
|
||||
item=menu_link
|
||||
))
|
||||
registry['plugins']['menus'].append(menu)
|
||||
|
||||
|
||||
@@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
|
||||
# Validation
|
||||
for menu_link in class_list:
|
||||
if not isinstance(menu_link, PluginMenuItem):
|
||||
raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem")
|
||||
raise TypeError(_("{menu_link} must be an instance of netbox.plugins.PluginMenuItem").format(
|
||||
menu_link=menu_link
|
||||
))
|
||||
for button in menu_link.buttons:
|
||||
if not isinstance(button, PluginMenuButton):
|
||||
raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton")
|
||||
raise TypeError(_("{button} must be an instance of netbox.plugins.PluginMenuButton").format(
|
||||
button=button
|
||||
))
|
||||
|
||||
registry['plugins']['menu_items'][section_name] = class_list
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'PluginTemplateExtension',
|
||||
@@ -31,7 +32,7 @@ class PluginTemplateExtension:
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
elif not isinstance(extra_context, dict):
|
||||
raise TypeError("extra_context must be a dictionary")
|
||||
raise TypeError(_("extra_context must be a dictionary"))
|
||||
|
||||
return get_template(template_name).render({**self.context, **extra_context})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import collections
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
class Registry(dict):
|
||||
@@ -10,13 +11,13 @@ class Registry(dict):
|
||||
try:
|
||||
return super().__getitem__(key)
|
||||
except KeyError:
|
||||
raise KeyError(f"Invalid store: {key}")
|
||||
raise KeyError(_("Invalid store: {key}").format(key=key))
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
raise TypeError("Cannot add stores to registry after initialization")
|
||||
raise TypeError(_("Cannot add stores to registry after initialization"))
|
||||
|
||||
def __delitem__(self, key):
|
||||
raise TypeError("Cannot delete stores from registry")
|
||||
raise TypeError(_("Cannot delete stores from registry"))
|
||||
|
||||
|
||||
# Initialize the global registry
|
||||
|
||||
@@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.7.1'
|
||||
VERSION = '3.7.3'
|
||||
|
||||
# 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', {})
|
||||
@@ -572,7 +571,6 @@ SOCIAL_AUTH_PIPELINE = (
|
||||
'social_core.pipeline.social_auth.social_uid',
|
||||
'social_core.pipeline.social_auth.social_user',
|
||||
'social_core.pipeline.user.get_username',
|
||||
'social_core.pipeline.social_auth.associate_by_email',
|
||||
'social_core.pipeline.user.create_user',
|
||||
'social_core.pipeline.social_auth.associate_user',
|
||||
'netbox.authentication.user_default_groups_handler',
|
||||
@@ -726,8 +724,10 @@ LANGUAGES = (
|
||||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('fr', _('French')),
|
||||
('ja', _('Japanese')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('tr', _('Turkish')),
|
||||
)
|
||||
|
||||
LOCALE_PATHS = (
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name='DummyModel',
|
||||
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)),
|
||||
('number', models.IntegerField(default=100)),
|
||||
],
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
@@ -320,7 +321,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if type(field.widget) is not HiddenInput
|
||||
}
|
||||
|
||||
def _save_object(self, model_form, request):
|
||||
def _save_object(self, import_form, model_form, request):
|
||||
|
||||
# Save the primary object
|
||||
obj = self.save_object(model_form, request)
|
||||
@@ -345,11 +346,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
related_obj = f.save()
|
||||
related_obj_pks.append(related_obj.pk)
|
||||
else:
|
||||
# Replicate errors on the related object form to the primary form for display
|
||||
# Replicate errors on the related object form to the import form for display and abort
|
||||
for subfield_name, errors in f.errors.items():
|
||||
for err in errors:
|
||||
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
|
||||
model_form.add_error(None, err_msg)
|
||||
if subfield_name == '__all__':
|
||||
err_msg = f"{field_name}[{i}]: {err}"
|
||||
else:
|
||||
err_msg = f"{field_name}[{i}] {subfield_name}: {err}"
|
||||
import_form.add_error(None, err_msg)
|
||||
raise AbortTransaction()
|
||||
|
||||
# Enforce object-level permissions on related objects
|
||||
@@ -390,7 +394,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
try:
|
||||
instance = prefetched_objects[object_id]
|
||||
except KeyError:
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
@@ -416,7 +420,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
restrict_form_fields(model_form, request.user)
|
||||
|
||||
if model_form.is_valid():
|
||||
obj = self._save_object(model_form, request)
|
||||
obj = self._save_object(form, model_form, request)
|
||||
saved_objects.append(obj)
|
||||
else:
|
||||
# Replicate model form errors for display
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.signals import clear_events
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
@@ -101,7 +102,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
request: The current request
|
||||
parent: The parent object
|
||||
"""
|
||||
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
|
||||
raise NotImplementedError(_('{class_name} must implement get_children()').format(
|
||||
class_name=self.__class__.__name__
|
||||
))
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<th scope="row">{% trans "Account Created" %}</th>
|
||||
<td>{{ request.user.date_joined|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last Login" %}</th>
|
||||
<td>{{ request.user.last_login|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Superuser" %}</th>
|
||||
<td>{% checkmark request.user.is_superuser %}</td>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<td>{{ object.u_height|floatformat }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Exclude From Utilization" %})</td>
|
||||
<td>{% trans "Exclude From Utilization" %}</td>
|
||||
<td>{% checkmark object.exclude_from_utilization %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<th scope="row">{% trans "Account Created" %}</th>
|
||||
<td>{{ object.date_joined|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last Login" %}</th>
|
||||
<td>{{ object.last_login|annotated_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Active" %}</th>
|
||||
<td>{% checkmark object.is_active %}</td>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="offset-sm-3">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li role="presentation" class="nav-item">
|
||||
<button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}">
|
||||
<button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}">
|
||||
{% trans "VLAN" %}
|
||||
</button>
|
||||
</li>
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="tab-content p-0 border-0">
|
||||
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
|
||||
<div class="tab-pane {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
|
||||
{% render_field form.vlan %}
|
||||
</div>
|
||||
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user