Merge branch 'develop' into 16640-custom-json-field

This commit is contained in:
Arthur Hanson 2024-07-29 17:50:30 +07:00
commit 51070079e4
101 changed files with 107765 additions and 27248 deletions

View File

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

View File

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

View File

@ -16,6 +16,6 @@ jobs:
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')" if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
with: with:
# Weighted assignments # Weighted assignments
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps assignees: arthanson:3, jeremystretch:3, DanSheps
numOfAssignee: 1 numOfAssignee: 1
abortIfPreviousAssignees: true abortIfPreviousAssignees: true

View File

@ -40,7 +40,7 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed. * First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated. * Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the bottom left corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
* If you can't find any existing issues (open or closed) that seem to match yours, you're welcome to [submit a new bug report](https://github.com/netbox-community/netbox/issues/new?label=type%3A+bug&template=bug_report.yaml). Be sure to complete the entire report template, including detailed steps that someone triaging your issue can follow to confirm the reported behavior. (If we're not able to replicate the bug based on the information provided, we'll ask for additional detail.) * If you can't find any existing issues (open or closed) that seem to match yours, you're welcome to [submit a new bug report](https://github.com/netbox-community/netbox/issues/new?label=type%3A+bug&template=bug_report.yaml). Be sure to complete the entire report template, including detailed steps that someone triaging your issue can follow to confirm the reported behavior. (If we're not able to replicate the bug based on the information provided, we'll ask for additional detail.)

View File

@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a> <a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a> <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a> <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-10-blue" alt="Languages supported" /></a> <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a> <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p> <p></p>
</div> </div>

View File

@ -16,7 +16,7 @@ Administrators are encouraged to adhere to industry best practices concerning th
## Reporting a Suspected Vulnerability ## Reporting a Suspected Vulnerability
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions: If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions:
* Affects the most recent stable release of NetBox, or a current beta release * Affects the most recent stable release of NetBox, or a current beta release
* Affects a NetBox instance installed and configured per the official documentation * Affects a NetBox instance installed and configured per the official documentation
@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous. Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project. For any security concerns regarding the community-maintained Docker image for NetBox, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties ### Bug Bounties

View File

@ -40,3 +40,22 @@ REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options. NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.) Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
#### Configuring the SSO module's appearance
The way a remote authentication backend is displayed to the user on the login
page may be adjusted via the `SOCIAL_AUTH_BACKEND_ATTRS` parameter, defaulting
to an empty dictionary. This dictionary maps a `social_core` module's name (ie.
`REMOTE_AUTH_BACKEND.name`) to a couple of parameters, `(display_name, icon)`.
The `display_name` is the name displayed to the user on the login page. The
icon may either be the URL of an icon; refer to a [Material Design
Icons](https://github.com/google/material-design-icons) icon's name; or be
`None` for no icon.
For instance, the OIDC backend may be customized with
```python
SOCIAL_AUTH_BACKEND_ATTRS = {
'oidc': ("My awesome SSO", "login"),
}
```

View File

@ -31,6 +31,17 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
--- ---
## SENTRY_SEND_DEFAULT_PII
Default: False
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
!!! warning "Sensitive data"
If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged.
---
## SENTRY_TAGS ## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example: An optional dictionary of tag names and values to apply to Sentry error reports.For example:

View File

@ -177,7 +177,7 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
Default: None (local storage) Default: None (local storage)
The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used. The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
@ -187,7 +187,7 @@ The configuration parameters for the specified storage backend are defined under
Default: Empty Default: Empty
A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail. A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
If `STORAGE_BACKEND` is not defined, this setting will be ignored. If `STORAGE_BACKEND` is not defined, this setting will be ignored.

View File

@ -113,7 +113,7 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
* **Tag:** Current version (e.g. `v3.3.1`) * **Tag:** Current version (e.g. `v3.3.1`)
* **Target:** `master` * **Target:** `master`
* **Title:** Version and date (e.g. `v3.3.1 - 2022-08-25`) * **Title:** Version and date (e.g. `v3.3.1 - 2022-08-25`)
* **Description:** Copy from the pull request body * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
Once created, the release will become available for users to install. Once created, the release will become available for users to install.
@ -135,4 +135,6 @@ First, run the `build-site` action, by navigating to Actions > build-site > Run
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag. Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _Cache_ in the left-nav, click the _Clear Cache_ button, and confirm the clear operation.
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated. Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.

View File

@ -20,6 +20,8 @@ Then, commit the change and push to the `develop` branch on GitHub. Any new stri
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md). Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
Check the Transifex dashboard for languages that are not marked _ready for use_, being sure to click _Show all languages_ if it appears at the bottom of the list. Use machine translation to round out any not-ready languages. It's not necessary to review the machine translation immediately as the translation teams will handle that aspect; the goal at this stage is to get translations included in the Transifex pull request.
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right. To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right.
![Transifex manual sync](../media/development/transifex_sync.png) ![Transifex manual sync](../media/development/transifex_sync.png)

View File

@ -84,11 +84,11 @@ To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/vi
```python ```python
# api/views.py # api/views.py
from netbox.api.viewsets import ModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from my_plugin.models import MyModel from my_plugin.models import MyModel
from .serializers import MyModelSerializer from .serializers import MyModelSerializer
class MyModelViewSet(ModelViewSet): class MyModelViewSet(NetBoxModelViewSet):
queryset = MyModel.objects.all() queryset = MyModel.objects.all()
serializer_class = MyModelSerializer serializer_class = MyModelSerializer
``` ```

View File

@ -70,3 +70,19 @@ DROP TABLE
netbox=> DROP TABLE pluginname_bar; netbox=> DROP TABLE pluginname_bar;
DROP TABLE DROP TABLE
``` ```
### Remove the Django Migration Records
After removing the tables created by a plugin, the migrations that created the tables need to be removed from Django's migration history as well. This is necessary to make it possible to reinstall the plugin at a later time. If the migration history were left in place, Django would skip all migrations that were executed in the course of a previous installation, which would cause the plugin to fail after reinstallation.
```no-highlight
netbox=> SELECT * FROM django_migrations WHERE app='pluginname';
id | app | name | applied
-----+------------+------------------------+-------------------------------
492 | pluginname | 0001_initial | 2023-12-21 11:59:59.325995+00
493 | pluginname | 0002_add_foo | 2023-12-21 11:59:59.330026+00
netbox=> DELETE FROM django_migrations WHERE app='pluginname';
```
!!! warning
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.

View File

@ -1,6 +1,70 @@
# NetBox v4.0 # NetBox v4.0
## v4.0.7 (FUTURE) ## v4.0.9 (FUTURE)
---
## v4.0.8 (2024-07-26)
### Enhancements
* [#14640](https://github.com/netbox-community/netbox/issues/14640) - Add Dutch language support
* [#14792](https://github.com/netbox-community/netbox/issues/14792) - Add Polish language support
* [#15375](https://github.com/netbox-community/netbox/issues/15375) - Enable customization of SSO backend name & icon
* [#15660](https://github.com/netbox-community/netbox/issues/15660) - Add Czech language support
* [#15696](https://github.com/netbox-community/netbox/issues/15696) - Add Danish language support
* [#16793](https://github.com/netbox-community/netbox/issues/16793) - Add Italian language support
* [#16933](https://github.com/netbox-community/netbox/issues/16933) - Enable toggling true/false marks on BooleanColumn
* [#16943](https://github.com/netbox-community/netbox/issues/16943) - Expand navigation breadcrumbs on job view to include the parent object
### Bug Fixes
* [#16357](https://github.com/netbox-community/netbox/issues/16357) - Replicate assigned type & tenant for cable when clicking "create an add another"
* [#16402](https://github.com/netbox-community/netbox/issues/16402) - Remove inoperative links from report result view
* [#16536](https://github.com/netbox-community/netbox/issues/16536) - Revert `role` & `role_id` filters for device components to `device_role` & `device_role_id` to avoid conflict with inventory item `role` field
* [#16624](https://github.com/netbox-community/netbox/issues/16624) - Correct OpenAPI schema definitions for several fields
* [#16760](https://github.com/netbox-community/netbox/issues/16760) - Fix data source syncing using git via a local path
* [#16819](https://github.com/netbox-community/netbox/issues/16819) - Highlight parent device in rack when viewing child device
* [#16838](https://github.com/netbox-community/netbox/issues/16838) - ActionsColumn should render extra buttons even when no stock actions are enabled
* [#16867](https://github.com/netbox-community/netbox/issues/16867) - Fix exception when a dashboard list widget references a model which has been removed
* [#16963](https://github.com/netbox-community/netbox/issues/16963) - Fix filtering of "accounts" link under providers list
* [#16964](https://github.com/netbox-community/netbox/issues/16964) - Ensure configured password validators are enforced
---
## v4.0.7 (2024-07-09)
### Enhancements
* [#14554](https://github.com/netbox-community/netbox/issues/14554) - Add support for [django-storage-swift](https://github.com/dennisv/django-storage-swift) storage backend
* [#16424](https://github.com/netbox-community/netbox/issues/16424) - Enable filtering of devices by cluster and cluster group
* [#16716](https://github.com/netbox-community/netbox/issues/16716) - Display NAT address (if any) for OOB IP address under device view
* [#16725](https://github.com/netbox-community/netbox/issues/16725) - Always position the admin section last in the navigation menu
* [#16791](https://github.com/netbox-community/netbox/issues/16791) - Add 200 & 400 Gbps selections for circuit termination port speed
* [#16802](https://github.com/netbox-community/netbox/issues/16802) - Introduce `SENTRY_SEND_DEFAULT_PII` configuration parameter and disable PII export by default
* [#16817](https://github.com/netbox-community/netbox/issues/16817) - Add 200 & 400 Gbps selections for circuit commit rate
### Bug Fixes
* [#16523](https://github.com/netbox-community/netbox/issues/16523) - Restore highlighting of current device in virtual chassis members panel
* [#16654](https://github.com/netbox-community/netbox/issues/16654) - Fix parent item assignment for inventory item bulk import
* [#16657](https://github.com/netbox-community/netbox/issues/16657) - Fix translation of object types in global search
* [#16679](https://github.com/netbox-community/netbox/issues/16679) - Avoid overwriting custom JSON fields during bulk edit
* [#16689](https://github.com/netbox-community/netbox/issues/16689) - System configuration view should reflect static parameters when no config revisions exist
* [#16714](https://github.com/netbox-community/netbox/issues/16714) - Fix cloning of device types with 0U height
* [#16721](https://github.com/netbox-community/netbox/issues/16721) - Fix errant API request after deselecting a rack in device edit form
* [#16723](https://github.com/netbox-community/netbox/issues/16723) - Fix escaping of path to virtual environment in `upgrade.sh`
* [#16735](https://github.com/netbox-community/netbox/issues/16735) - Object list "results" tab should show a count of zero when empty
* [#16747](https://github.com/netbox-community/netbox/issues/16747) - Avoid clearing entire search cache when manually reindexing specific apps/models
* [#16758](https://github.com/netbox-community/netbox/issues/16758) - Ensure manually selected lagnuage persists across browser sessions
* [#16779](https://github.com/netbox-community/netbox/issues/16779) - Fix saved filter selection for child object lists
* [#16780](https://github.com/netbox-community/netbox/issues/16780) - IKE proposal created via REST API should not require authentication_algorithm
* [#16796](https://github.com/netbox-community/netbox/issues/16796) - Allow assignment of VM with no site to a cluster with a site
* [#16806](https://github.com/netbox-community/netbox/issues/16806) - Fix redirect URL when creating contact assignments with "add another" button
* [#16807](https://github.com/netbox-community/netbox/issues/16807) - Fix layout of VLAN edit form when custom fields are present
* [#16808](https://github.com/netbox-community/netbox/issues/16808) - Fix event rule triggering in scenario where objects are updated immediately prior to deletion
* [#16813](https://github.com/netbox-community/netbox/issues/16813) - Fix AttributeError exception when filtering bookmarks in dashboard widget by object type
* [#16843](https://github.com/netbox-community/netbox/issues/16843) - Permit creation of IKE policies via REST API without specifying an IKE mode
--- ---

View File

@ -44,10 +44,20 @@ class LoginView(View):
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
def gen_auth_data(self, name, url, params): def gen_auth_data(self, name, url, params):
display_name, icon_name = get_auth_backend_display(name) display_name, icon_source = get_auth_backend_display(name)
icon_name = None
icon_img = None
if icon_source:
if '://' in icon_source:
icon_img = icon_source
else:
icon_name = icon_source
return { return {
'display_name': display_name, 'display_name': display_name,
'icon_name': icon_name, 'icon_name': icon_name,
'icon_img': icon_img,
'url': f'{url}?{urlencode(params)}', 'url': f'{url}?{urlencode(params)}',
} }
@ -111,7 +121,7 @@ class LoginView(View):
# Set the user's preferred language (if any) # Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'): if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language) response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
return response return response
@ -206,7 +216,7 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie # Set/clear language cookie
if language := form.cleaned_data['locale.language']: if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language) response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
else: else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME) response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)

View File

@ -38,6 +38,8 @@ class CircuitCommitRateChoices(ChoiceSet):
(25000000, '25 Gbps'), (25000000, '25 Gbps'),
(40000000, '40 Gbps'), (40000000, '40 Gbps'),
(100000000, '100 Gbps'), (100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'), (1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'), (2048, 'E1 (2.048 Mbps)'),
] ]
@ -69,6 +71,8 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
(25000000, '25 Gbps'), (25000000, '25 Gbps'),
(40000000, '40 Gbps'), (40000000, '40 Gbps'),
(100000000, '100 Gbps'), (100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'), (1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'), (2048, 'E1 (2.048 Mbps)'),
] ]

View File

@ -66,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'tags') fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class CircuitImportForm(NetBoxModelImportForm): class CircuitImportForm(NetBoxModelImportForm):

View File

@ -25,7 +25,7 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
account_count = columns.LinkedCountColumn( account_count = columns.LinkedCountColumn(
accessor=tables.A('accounts__count'), accessor=tables.A('accounts__count'),
viewname='circuits:provideraccount_list', viewname='circuits:provideraccount_list',
url_params={'account_id': 'pk'}, url_params={'provider_id': 'pk'},
verbose_name=_('Account Count') verbose_name=_('Account Count')
) )
asns = columns.ManyToManyColumn( asns = columns.ManyToManyColumn(

View File

@ -84,9 +84,7 @@ class GitBackend(DataBackend):
clone_args = { clone_args = {
"branch": self.params.get('branch'), "branch": self.params.get('branch'),
"config": self.config, "config": self.config,
"depth": 1,
"errstream": porcelain.NoneStream(), "errstream": porcelain.NoneStream(),
"quiet": True,
} }
if self.url_scheme in ('http', 'https'): if self.url_scheme in ('http', 'https'):
@ -97,6 +95,9 @@ class GitBackend(DataBackend):
"password": self.params.get('password'), "password": self.params.get('password'),
} }
) )
if self.url_scheme:
clone_args["quiet"] = True
clone_args["depth"] = 1
logger.debug(f"Cloning git repo: {self.url}") logger.debug(f"Cloning git repo: {self.url}")
try: try:

View File

@ -19,6 +19,7 @@ REVISION_BUTTONS = """
class ConfigRevisionTable(NetBoxTable): class ConfigRevisionTable(NetBoxTable):
is_active = columns.BooleanColumn( is_active = columns.BooleanColumn(
verbose_name=_('Is Active'), verbose_name=_('Is Active'),
false_mark=None
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',), actions=('delete',),

View File

@ -555,7 +555,7 @@ class SystemView(UserPassesTestMixin, View):
config = ConfigRevision.objects.get(pk=cache.get('config_version')) config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist: except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found # Fall back to using the active config data if no record is found
config = ConfigRevision(data=get_config().defaults) config = get_config()
# Raw data export # Raw data export
if 'export' in request.GET: if 'export' in request.GET:

View File

@ -13,7 +13,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
""" """
Legacy serializer for pre-v3.3 connections Legacy serializer for pre-v3.3 connections
""" """
connected_endpoints_type = serializers.SerializerMethodField(read_only=True) connected_endpoints_type = serializers.SerializerMethodField(read_only=True, allow_null=True)
connected_endpoints = serializers.SerializerMethodField(read_only=True) connected_endpoints = serializers.SerializerMethodField(read_only=True)
connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
@ -22,7 +22,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
if endpoints := obj.connected_endpoints: if endpoints := obj.connected_endpoints:
return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
@extend_schema_field(serializers.ListField) @extend_schema_field(serializers.ListField(allow_null=True))
def get_connected_endpoints(self, obj): def get_connected_endpoints(self, obj):
""" """
Return the appropriate serializer for the type of connected object. Return the appropriate serializer for the type of connected object.

View File

@ -91,7 +91,7 @@ class CablePathSerializer(serializers.ModelSerializer):
class CabledObjectSerializer(serializers.ModelSerializer): class CabledObjectSerializer(serializers.ModelSerializer):
cable = CableSerializer(nested=True, read_only=True, allow_null=True) cable = CableSerializer(nested=True, read_only=True, allow_null=True)
cable_end = serializers.CharField(read_only=True) cable_end = serializers.CharField(read_only=True)
link_peers_type = serializers.SerializerMethodField(read_only=True) link_peers_type = serializers.SerializerMethodField(read_only=True, allow_null=True)
link_peers = serializers.SerializerMethodField(read_only=True) link_peers = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True)

View File

@ -88,7 +88,7 @@ class DeviceSerializer(NetBoxModelSerializer):
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer(allow_null=True))
def get_parent_device(self, obj): def get_parent_device(self, obj):
try: try:
device_bay = obj.parent_bay device_bay = obj.parent_bay

View File

@ -20,7 +20,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink
@ -1018,6 +1018,17 @@ class DeviceFilterSet(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
label=_('VM cluster (ID)'), label=_('VM cluster (ID)'),
) )
cluster_group = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label=_('Cluster group (slug)'),
)
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group',
queryset=ClusterGroup.objects.all(),
label=_('Cluster group (ID)'),
)
model = django_filters.ModelMultipleChoiceFilter( model = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug', field_name='device_type__slug',
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
@ -1378,12 +1389,12 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='model', to_field_name='model',
label=_('Device type (model)'), label=_('Device type (model)'),
) )
role_id = django_filters.ModelMultipleChoiceFilter( device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__role', field_name='device__role',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'), label=_('Device role (ID)'),
) )
role = django_filters.ModelMultipleChoiceFilter( device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__role__slug', field_name='device__role__slug',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
to_field_name='slug', to_field_name='slug',

View File

@ -174,9 +174,6 @@ class RackRoleImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = RackRole model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags') fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class RackImportForm(NetBoxModelImportForm): class RackImportForm(NetBoxModelImportForm):
@ -384,9 +381,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags') fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class PlatformImportForm(NetBoxModelImportForm): class PlatformImportForm(NetBoxModelImportForm):
@ -1052,7 +1046,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ( fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'device', 'name', 'label', 'role', 'manufacturer', 'parent', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags', 'component_type', 'component_name', 'description', 'tags', 'component_type', 'component_name',
) )
@ -1104,9 +1098,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description') fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
# #
@ -1183,9 +1174,6 @@ class CableImportForm(NetBoxModelImportForm):
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
] ]
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
def _clean_side(self, side): def _clean_side(self, side):
""" """

View File

@ -14,6 +14,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN from vpn.models import L2VPN
from wireless.choices import * from wireless.choices import *
@ -655,6 +656,7 @@ class DeviceFilterForm(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
name=_('Components') name=_('Components')
), ),
FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet( FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', 'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
'has_virtual_device_context', 'has_virtual_device_context',
@ -821,6 +823,16 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -88,6 +88,8 @@ class Cable(PrimaryModel):
null=True null=True
) )
clone_fields = ('tenant', 'type',)
class Meta: class Meta:
ordering = ('pk',) ordering = ('pk',)
verbose_name = _('cable') verbose_name = _('cable')

View File

@ -63,7 +63,10 @@ class DeviceRoleTable(NetBoxTable):
verbose_name=_('VMs') verbose_name=_('VMs')
) )
color = columns.ColorColumn() color = columns.ColorColumn()
vm_role = columns.BooleanColumn() vm_role = columns.BooleanColumn(
verbose_name=_('VM role'),
false_mark=None
)
config_template = tables.Column( config_template = tables.Column(
linkify=True linkify=True
) )
@ -329,6 +332,7 @@ class CableTerminationTable(NetBoxTable):
) )
mark_connected = columns.BooleanColumn( mark_connected = columns.BooleanColumn(
verbose_name=_('Mark Connected'), verbose_name=_('Mark Connected'),
false_mark=None
) )
class Meta: class Meta:
@ -586,7 +590,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
} }
) )
mgmt_only = columns.BooleanColumn( mgmt_only = columns.BooleanColumn(
verbose_name=_('Management Only') verbose_name=_('Management Only'),
false_mark=None
) )
speed_formatted = columns.TemplateColumn( speed_formatted = columns.TemplateColumn(
template_code='{% load helpers %}{{ value|humanize_speed }}', template_code='{% load helpers %}{{ value|humanize_speed }}',
@ -913,6 +918,7 @@ class InventoryItemTable(DeviceComponentTable):
) )
discovered = columns.BooleanColumn( discovered = columns.BooleanColumn(
verbose_name=_('Discovered'), verbose_name=_('Discovered'),
false_mark=None
) )
parent = tables.Column( parent = tables.Column(
linkify=True, linkify=True,

View File

@ -86,7 +86,8 @@ class DeviceTypeTable(NetBoxTable):
linkify=True linkify=True
) )
is_full_depth = columns.BooleanColumn( is_full_depth = columns.BooleanColumn(
verbose_name=_('Full Depth') verbose_name=_('Full Depth'),
false_mark=None
) )
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
@ -98,7 +99,10 @@ class DeviceTypeTable(NetBoxTable):
verbose_name=_('U Height'), verbose_name=_('U Height'),
template_code='{{ value|floatformat }}' template_code='{{ value|floatformat }}'
) )
exclude_from_utilization = columns.BooleanColumn() exclude_from_utilization = columns.BooleanColumn(
verbose_name=_('Exclude from utilization'),
false_mark=None
)
weight = columns.TemplateColumn( weight = columns.TemplateColumn(
verbose_name=_('Weight'), verbose_name=_('Weight'),
template_code=WEIGHT, template_code=WEIGHT,
@ -221,7 +225,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
verbose_name=_('Enabled'), verbose_name=_('Enabled'),
) )
mgmt_only = columns.BooleanColumn( mgmt_only = columns.BooleanColumn(
verbose_name=_('Management Only') verbose_name=_('Management Only'),
false_mark=None
) )
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('edit', 'delete'), actions=('edit', 'delete'),

View File

@ -9,7 +9,7 @@ from ipam.models import ASN, IPAddress, RIR, VRF
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType, ClusterGroup
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model() User = get_user_model()
@ -32,11 +32,11 @@ class DeviceComponentFilterSetTests:
params = {'device_type': [device_types[0].model, device_types[1].model]} params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self): def test_device_role(self):
role = DeviceRole.objects.all()[:2] role = DeviceRole.objects.all()[:2]
params = {'role_id': [role[0].pk, role[1].pk]} params = {'device_role_id': [role[0].pk, role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'role': [role[0].slug, role[1].slug]} params = {'device_role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -1959,10 +1959,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = ( clusters = (
Cluster(name='Cluster 1', type=cluster_type), Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0]),
Cluster(name='Cluster 2', type=cluster_type), Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1]),
Cluster(name='Cluster 3', type=cluster_type), Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2]),
) )
Cluster.objects.bulk_create(clusters) Cluster.objects.bulk_create(clusters)
@ -2213,6 +2219,13 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]} params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster_group(self):
cluster_groups = ClusterGroup.objects.all()[:2]
params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_model(self): def test_model(self):
params = {'model': ['model-1', 'model-2']} params = {'model': ['model-1', 'model-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -4534,6 +4547,13 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device_type': [device_types[0].model, device_types[1].model]} params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_role(self):
role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [role[0].pk, role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_role(self): def test_role(self):
role = DeviceRole.objects.all()[:2] role = DeviceRole.objects.all()[:2]
params = {'role_id': [role[0].pk, role[1].pk]} params = {'role_id': [role[0].pk, role[1].pk]}

View File

@ -31,6 +31,7 @@ from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
) )
from virtualization.filtersets import VirtualMachineFilterSet from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -679,6 +680,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
child_model = RackReservation child_model = RackReservation
table = tables.RackReservationTable table = tables.RackReservationTable
filterset = filtersets.RackReservationFilterSet filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
template_name = 'dcim/rack/reservations.html' template_name = 'dcim/rack/reservations.html'
tab = ViewTab( tab = ViewTab(
label=_('Reservations'), label=_('Reservations'),
@ -697,6 +699,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
child_model = Device child_model = Device
table = tables.DeviceTable table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
template_name = 'dcim/rack/non_racked_devices.html' template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab( tab = ViewTab(
label=_('Non-Racked Devices'), label=_('Non-Racked Devices'),
@ -1835,6 +1838,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
child_model = ConsolePort child_model = ConsolePort
table = tables.DeviceConsolePortTable table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html', template_name = 'dcim/device/consoleports.html',
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
@ -1850,6 +1854,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
child_model = ConsoleServerPort child_model = ConsoleServerPort
table = tables.DeviceConsoleServerPortTable table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html' template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
@ -1865,6 +1870,7 @@ class DevicePowerPortsView(DeviceComponentsView):
child_model = PowerPort child_model = PowerPort
table = tables.DevicePowerPortTable table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html' template_name = 'dcim/device/powerports.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
@ -1880,6 +1886,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
child_model = PowerOutlet child_model = PowerOutlet
table = tables.DevicePowerOutletTable table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html' template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
@ -1895,6 +1902,7 @@ class DeviceInterfacesView(DeviceComponentsView):
child_model = Interface child_model = Interface
table = tables.DeviceInterfaceTable table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
template_name = 'dcim/device/interfaces.html' template_name = 'dcim/device/interfaces.html'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
@ -1916,6 +1924,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
child_model = FrontPort child_model = FrontPort
table = tables.DeviceFrontPortTable table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html' template_name = 'dcim/device/frontports.html'
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
@ -1931,6 +1940,7 @@ class DeviceRearPortsView(DeviceComponentsView):
child_model = RearPort child_model = RearPort
table = tables.DeviceRearPortTable table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html' template_name = 'dcim/device/rearports.html'
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
@ -1946,6 +1956,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
child_model = ModuleBay child_model = ModuleBay
table = tables.DeviceModuleBayTable table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html' template_name = 'dcim/device/modulebays.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@ -1965,6 +1976,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
child_model = DeviceBay child_model = DeviceBay
table = tables.DeviceDeviceBayTable table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@ -1984,6 +1996,7 @@ class DeviceInventoryView(DeviceComponentsView):
child_model = InventoryItem child_model = InventoryItem
table = tables.DeviceInventoryItemTable table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html' template_name = 'dcim/device/inventory.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@ -2062,6 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine child_model = VirtualMachine
table = VirtualMachineTable table = VirtualMachineTable
filterset = VirtualMachineFilterSet filterset = VirtualMachineFilterSet
filterset_form = VirtualMachineFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Virtual Machines'), label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(), badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@ -2944,6 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem child_model = InventoryItem
table = tables.InventoryItemTable table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Children'), label=_('Children'),
badge=lambda obj: obj.child_items.count(), badge=lambda obj: obj.child_items.count(),

View File

@ -39,7 +39,7 @@ class ScriptSerializer(ValidatedModelSerializer):
def get_display(self, obj): def get_display(self, obj):
return f'{obj.name} ({obj.module})' return f'{obj.name} ({obj.module})'
@extend_schema_field(serializers.CharField()) @extend_schema_field(serializers.CharField(allow_null=True))
def get_description(self, obj): def get_description(self, obj):
if obj.python_class: if obj.python_class:
return obj.python_class().description return obj.python_class().description

View File

@ -251,6 +251,10 @@ class ObjectListWidget(DashboardWidget):
def render(self, request): def render(self, request):
app_label, model_name = self.config['model'].split('.') app_label, model_name = self.config['model'].split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class() model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
if not model:
logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
return
viewname = get_viewname(model, action='list') viewname = get_viewname(model, action='list')
# Evaluate user's permission. Note that this controls only whether the HTMX element is # Evaluate user's permission. Note that this controls only whether the HTMX element is
@ -381,17 +385,17 @@ class BookmarksWidget(DashboardWidget):
if request.user.is_anonymous: if request.user.is_anonymous:
bookmarks = list() bookmarks = list()
else: else:
user_bookmarks = Bookmark.objects.filter(user=request.user) bookmarks = Bookmark.objects.filter(user=request.user)
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower())
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
else:
bookmarks = user_bookmarks.order_by(self.config['order_by'])
if object_types := self.config.get('object_types'): if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types) models = get_models_from_content_types(object_types)
content_types = ObjectType.objects.get_for_models(*models).values() content_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=content_types) bookmarks = bookmarks.filter(object_type__in=content_types)
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
else:
bookmarks = bookmarks.order_by(self.config['order_by'])
if max_items := self.config.get('max_items'): if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items] bookmarks = bookmarks[:max_items]

View File

@ -63,6 +63,9 @@ def enqueue_object(queue, instance, user, request_id, action):
if key in queue: if key in queue:
queue[key]['data'] = serialize_for_event(instance) queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange'] queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
# If the object is being deleted, update any prior "update" event to "delete"
if action == ObjectChangeActionChoices.ACTION_DELETE:
queue[key]['event'] = action
else: else:
queue[key] = { queue[key] = {
'content_type': ContentType.objects.get_for_model(instance), 'content_type': ContentType.objects.get_for_model(instance),

View File

@ -228,9 +228,6 @@ class TagImportForm(CSVModelForm):
class Meta: class Meta:
model = Tag model = Tag
fields = ('name', 'slug', 'color', 'description') fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class JournalEntryImportForm(NetBoxModelImportForm): class JournalEntryImportForm(NetBoxModelImportForm):

View File

@ -66,11 +66,16 @@ class Command(BaseCommand):
raise CommandError(_("No indexers found!")) raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.') self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models (if not being lazy) # Clear cached values for the specified models (if not being lazy)
if not kwargs['lazy']: if not kwargs['lazy']:
if model_labels:
content_types = [ContentType.objects.get_for_model(model) for model in indexers.keys()]
else:
content_types = None
self.stdout.write('Clearing cached values... ', ending='') self.stdout.write('Clearing cached values... ', ending='')
self.stdout.flush() self.stdout.flush()
deleted_count = search_backend.clear() deleted_count = search_backend.clear(object_types=content_types)
self.stdout.write(f'{deleted_count} entries deleted.') self.stdout.write(f'{deleted_count} entries deleted.')
# Index models # Index models

View File

@ -490,7 +490,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# JSON # JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON: elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = JSONField(required=required, initial=json.dumps(initial) if initial else '') field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
# Object # Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

View File

@ -47,7 +47,8 @@ class CustomFieldTable(NetBoxTable):
verbose_name=_('Object Types') verbose_name=_('Object Types')
) )
required = columns.BooleanColumn( required = columns.BooleanColumn(
verbose_name=_('Required') verbose_name=_('Required'),
false_mark=None
) )
ui_visible = columns.ChoiceFieldColumn( ui_visible = columns.ChoiceFieldColumn(
verbose_name=_('Visible') verbose_name=_('Visible')
@ -72,6 +73,7 @@ class CustomFieldTable(NetBoxTable):
) )
is_cloneable = columns.BooleanColumn( is_cloneable = columns.BooleanColumn(
verbose_name=_('Is Cloneable'), verbose_name=_('Is Cloneable'),
false_mark=None
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -105,6 +107,7 @@ class CustomFieldChoiceSetTable(NetBoxTable):
) )
order_alphabetically = columns.BooleanColumn( order_alphabetically = columns.BooleanColumn(
verbose_name=_('Order Alphabetically'), verbose_name=_('Order Alphabetically'),
false_mark=None
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -129,6 +132,7 @@ class CustomLinkTable(NetBoxTable):
) )
new_window = columns.BooleanColumn( new_window = columns.BooleanColumn(
verbose_name=_('New Window'), verbose_name=_('New Window'),
false_mark=None
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -150,6 +154,7 @@ class ExportTemplateTable(NetBoxTable):
) )
as_attachment = columns.BooleanColumn( as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'), verbose_name=_('As Attachment'),
false_mark=None
) )
data_source = tables.Column( data_source = tables.Column(
verbose_name=_('Data Source'), verbose_name=_('Data Source'),
@ -218,6 +223,7 @@ class SavedFilterTable(NetBoxTable):
) )
shared = columns.BooleanColumn( shared = columns.BooleanColumn(
verbose_name=_('Shared'), verbose_name=_('Shared'),
false_mark=None
) )
def value_parameters(self, value): def value_parameters(self, value):

View File

@ -390,13 +390,36 @@ class EventRuleTest(APITestCase):
request.id = uuid.uuid4() request.id = uuid.uuid4()
request.user = self.user request.user = self.user
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue") # Test create & update
with event_tracking(request): with event_tracking(request):
site = Site(name='Site 1', slug='site-1') site = Site(name='Site 1', slug='site-1')
site.save() site.save()
site.description = 'foo'
# Save the site a second time
site.save() site.save()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue") self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
self.queue.empty()
# Test multiple updates
site = Site.objects.create(name='Site 2', slug='site-2')
with event_tracking(request):
site.description = 'foo'
site.save()
site.description = 'bar'
site.save()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
self.queue.empty()
# Test update & delete
site = Site.objects.create(name='Site 3', slug='site-3')
with event_tracking(request):
site.description = 'foo'
site.save()
site.delete()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
self.queue.empty()

View File

@ -86,7 +86,8 @@ class RIRTable(NetBoxTable):
linkify=True linkify=True
) )
is_private = columns.BooleanColumn( is_private = columns.BooleanColumn(
verbose_name=_('Private') verbose_name=_('Private'),
false_mark=None
) )
aggregate_count = columns.LinkedCountColumn( aggregate_count = columns.LinkedCountColumn(
viewname='ipam:aggregate_list', viewname='ipam:aggregate_list',
@ -258,10 +259,12 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
linkify=True linkify=True
) )
is_pool = columns.BooleanColumn( is_pool = columns.BooleanColumn(
verbose_name=_('Pool') verbose_name=_('Pool'),
false_mark=None
) )
mark_utilized = columns.BooleanColumn( mark_utilized = columns.BooleanColumn(
verbose_name=_('Marked Utilized') verbose_name=_('Marked Utilized'),
false_mark=None
) )
utilization = PrefixUtilizationColumn( utilization = PrefixUtilizationColumn(
verbose_name=_('Utilization'), verbose_name=_('Utilization'),
@ -314,7 +317,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
linkify=True linkify=True
) )
mark_utilized = columns.BooleanColumn( mark_utilized = columns.BooleanColumn(
verbose_name=_('Marked Utilized') verbose_name=_('Marked Utilized'),
false_mark=None
) )
utilization = columns.UtilizationColumn( utilization = columns.UtilizationColumn(
verbose_name=_('Utilization'), verbose_name=_('Utilization'),
@ -386,7 +390,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
assigned = columns.BooleanColumn( assigned = columns.BooleanColumn(
accessor='assigned_object_id', accessor='assigned_object_id',
linkify=lambda record: record.assigned_object.get_absolute_url(), linkify=lambda record: record.assigned_object.get_absolute_url(),
verbose_name=_('Assigned') verbose_name=_('Assigned'),
false_mark=None
) )
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),

View File

@ -211,6 +211,7 @@ class InterfaceVLANTable(NetBoxTable):
) )
tagged = columns.BooleanColumn( tagged = columns.BooleanColumn(
verbose_name=_('Tagged'), verbose_name=_('Tagged'),
false_mark=None
) )
site = tables.Column( site = tables.Column(
verbose_name=_('Site'), verbose_name=_('Site'),

View File

@ -30,7 +30,8 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('RD') verbose_name=_('RD')
) )
enforce_unique = columns.BooleanColumn( enforce_unique = columns.BooleanColumn(
verbose_name=_('Unique') verbose_name=_('Unique'),
false_mark=None
) )
import_targets = columns.TemplateColumn( import_targets = columns.TemplateColumn(
verbose_name=_('Import Targets'), verbose_name=_('Import Targets'),

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext as _
from circuits.models import Provider from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site from dcim.models import Interface, Site
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView from tenancy.views import ObjectContactsView
@ -14,6 +15,7 @@ from utilities.query import count_related
from utilities.tables import get_table_ordering from utilities.tables import get_table_ordering
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface from virtualization.models import VMInterface
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import PrefixStatusChoices from .choices import PrefixStatusChoices
@ -206,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN child_model = ASN
table = tables.ASNTable table = tables.ASNTable
filterset = filtersets.ASNFilterSet filterset = filtersets.ASNFilterSet
filterset_form = forms.ASNFilterForm
tab = ViewTab( tab = ViewTab(
label=_('ASNs'), label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(), badge=lambda x: x.get_child_asns().count(),
@ -337,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
child_model = Prefix child_model = Prefix
table = tables.PrefixTable table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/aggregate/prefixes.html' template_name = 'ipam/aggregate/prefixes.html'
tab = ViewTab( tab = ViewTab(
label=_('Prefixes'), label=_('Prefixes'),
@ -523,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
child_model = Prefix child_model = Prefix
table = tables.PrefixTable table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/prefix/prefixes.html' template_name = 'ipam/prefix/prefixes.html'
tab = ViewTab( tab = ViewTab(
label=_('Child Prefixes'), label=_('Child Prefixes'),
@ -558,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
child_model = IPRange child_model = IPRange
table = tables.IPRangeTable table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet filterset = filtersets.IPRangeFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/prefix/ip_ranges.html' template_name = 'ipam/prefix/ip_ranges.html'
tab = ViewTab( tab = ViewTab(
label=_('Child Ranges'), label=_('Child Ranges'),
@ -584,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab( tab = ViewTab(
label=_('IP Addresses'), label=_('IP Addresses'),
@ -683,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/iprange/ip_addresses.html' template_name = 'ipam/iprange/ip_addresses.html'
tab = ViewTab( tab = ViewTab(
label=_('IP Addresses'), label=_('IP Addresses'),
@ -885,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Related IPs'), label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(), badge=lambda x: x.get_related_ips().count(),
@ -957,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN child_model = VLAN
table = tables.VLANTable table = tables.VLANTable
filterset = filtersets.VLANFilterSet filterset = filtersets.VLANFilterSet
filterset_form = forms.VLANFilterForm
tab = ViewTab( tab = ViewTab(
label=_('VLANs'), label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(), badge=lambda x: x.get_child_vlans().count(),
@ -1112,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface child_model = Interface
table = tables.VLANDevicesTable table = tables.VLANDevicesTable
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
filterset_form = InterfaceFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Device Interfaces'), label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(), badge=lambda x: x.get_interfaces().count(),
@ -1129,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface child_model = VMInterface
table = tables.VLANVirtualMachinesTable table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet filterset = VMInterfaceFilterSet
filterset_form = VMInterfaceFilterForm
tab = ViewTab( tab = ViewTab(
label=_('VM Interfaces'), label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(), badge=lambda x: x.get_vminterfaces().count(),

View File

@ -49,12 +49,15 @@ AUTH_BACKEND_ATTRS = {
'okta-openidconnect': ('Okta (OIDC)', None), 'okta-openidconnect': ('Okta (OIDC)', None),
'salesforce-oauth2': ('Salesforce', 'salesforce'), 'salesforce-oauth2': ('Salesforce', 'salesforce'),
} }
# Override with potential user configuration
AUTH_BACKEND_ATTRS.update(getattr(settings, 'SOCIAL_AUTH_BACKEND_ATTRS', {}))
def get_auth_backend_display(name): def get_auth_backend_display(name):
""" """
Return the user-friendly name and icon name for a remote authentication backend, if known. Defaults to the Return the user-friendly name and icon name for a remote authentication backend, if
raw backend name and no icon. known. Obtained from the defaults dictionary AUTH_BACKEND_ATTRS, overridden by the
setting `SOCIAL_AUTH_BACKEND_ATTRS`. Defaults to the raw backend name and no icon.
""" """
return AUTH_BACKEND_ATTRS.get(name, (name, None)) return AUTH_BACKEND_ATTRS.get(name, (name, None))

View File

@ -1,7 +1,7 @@
import re import re
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
@ -36,7 +36,8 @@ class SearchForm(forms.Form):
lookup = forms.ChoiceField( lookup = forms.ChoiceField(
choices=LOOKUP_CHOICES, choices=LOOKUP_CHOICES,
initial=LookupTypes.PARTIAL, initial=LookupTypes.PARTIAL,
required=False required=False,
label=_('Lookup')
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -47,6 +47,11 @@ class CoreMiddleware:
with event_tracking(request): with event_tracking(request):
response = self.get_response(request) response = self.get_response(request)
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
# Attach the unique request ID as an HTTP header. # Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id response['X-Request-ID'] = request.id

View File

@ -462,16 +462,13 @@ MENUS = [
PROVISIONING_MENU, PROVISIONING_MENU,
CUSTOMIZATION_MENU, CUSTOMIZATION_MENU,
OPERATIONS_MENU, OPERATIONS_MENU,
ADMIN_MENU,
] ]
# # Add top-level plugin menus
# Add plugin menus
#
for menu in registry['plugins']['menus']: for menu in registry['plugins']['menus']:
MENUS.append(menu) MENUS.append(menu)
# Add the default "plugins" menu
if registry['plugins']['menu_items']: if registry['plugins']['menu_items']:
# Build the default plugins menu # Build the default plugins menu
@ -485,3 +482,6 @@ if registry['plugins']['menu_items']:
groups=groups groups=groups
) )
MENUS.append(plugins_menu) MENUS.append(plugins_menu)
# Add the admin menu last
MENUS.append(ADMIN_MENU)

View File

@ -8,6 +8,7 @@ from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window from django.db.models.functions import window
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
import netaddr import netaddr
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@ -39,7 +40,7 @@ class SearchBackend:
# Organize choices by category # Organize choices by category
categories = defaultdict(dict) categories = defaultdict(dict)
for label, idx in registry['search'].items(): for label, idx in registry['search'].items():
categories[idx.get_category()][label] = title(idx.model._meta.verbose_name) categories[idx.get_category()][label] = _(title(idx.model._meta.verbose_name))
# Compile a nested tuple of choices for form rendering # Compile a nested tuple of choices for form rendering
results = ( results = (

View File

@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup # Environment setup
# #
VERSION = '4.0.7-dev' VERSION = '4.0.9-dev'
HOSTNAME = platform.node() HOSTNAME = platform.node()
# Set the base directory two levels up # Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -147,6 +147,7 @@ SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
SENTRY_SEND_DEFAULT_PII = getattr(configuration, 'SENTRY_SEND_DEFAULT_PII', False)
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
@ -225,6 +226,23 @@ if STORAGE_BACKEND is not None:
return globals().get(name, default) return globals().get(name, default)
storages.utils.setting = _setting storages.utils.setting = _setting
# django-storage-swift
elif STORAGE_BACKEND == 'swift.storage.SwiftStorage':
try:
import swift.utils # type: ignore
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'swift':
raise ImproperlyConfigured(
f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
"It can be installed by running 'pip install django-storage-swift'."
)
raise e
# Load all SWIFT_* settings from the user configuration
for param, value in STORAGE_CONFIG.items():
if param.startswith('SWIFT_'):
globals()[param] = value
if STORAGE_CONFIG and STORAGE_BACKEND is None: if STORAGE_CONFIG and STORAGE_BACKEND is None:
warnings.warn( warnings.warn(
"STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be " "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
@ -536,7 +554,7 @@ if SENTRY_ENABLED:
release=VERSION, release=VERSION,
sample_rate=SENTRY_SAMPLE_RATE, sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True, send_default_pii=SENTRY_SEND_DEFAULT_PII,
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
) )
@ -721,11 +739,16 @@ RQ_QUEUES.update({
# Supported translation languages # Supported translation languages
LANGUAGES = ( LANGUAGES = (
('cs', _('Czech')),
('da', _('Danish')),
('de', _('German')), ('de', _('German')),
('en', _('English')), ('en', _('English')),
('es', _('Spanish')), ('es', _('Spanish')),
('fr', _('French')), ('fr', _('French')),
('it', _('Italian')),
('ja', _('Japanese')), ('ja', _('Japanese')),
('nl', _('Dutch')),
('pl', _('Polish')),
('pt', _('Portuguese')), ('pt', _('Portuguese')),
('ru', _('Russian')), ('ru', _('Russian')),
('tr', _('Turkish')), ('tr', _('Turkish')),

View File

@ -194,14 +194,23 @@ class BooleanColumn(tables.Column):
Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
character. character.
""" """
TRUE_MARK = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold"></i></span>')
FALSE_MARK = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>')
EMPTY_MARK = mark_safe('<span class="text-muted">&mdash;</span>') # Placeholder
def __init__(self, *args, true_mark=TRUE_MARK, false_mark=FALSE_MARK, **kwargs):
self.true_mark = true_mark
self.false_mark = false_mark
super().__init__(*args, **kwargs)
def render(self, value): def render(self, value):
if value: if value is None:
rendered = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>' return self.EMPTY_MARK
elif value is None: if value and self.true_mark:
rendered = '<span class="text-muted">&mdash;</span>' return self.true_mark
else: if not value and self.false_mark:
rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>' return self.false_mark
return mark_safe(rendered) return self.EMPTY_MARK
def value(self, value): def value(self, value):
return str(value) return str(value)
@ -249,7 +258,7 @@ class ActionsColumn(tables.Column):
def render(self, record, table, **kwargs): def render(self, record, table, **kwargs):
# Skip dummy records (e.g. available VLANs) or those with no actions # Skip dummy records (e.g. available VLANs) or those with no actions
if not getattr(record, 'pk', None) or not self.actions: if not getattr(record, 'pk', None) or not (self.actions or self.extra_buttons):
return '' return ''
model = table.Meta.model model = table.Meta.model

View File

@ -176,7 +176,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
'model': model, 'model': model,
'table': table, 'table': table,
'actions': actions, 'actions': actions,
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'prerequisite_model': get_prerequisite_model(self.queryset), 'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request), **self.get_extra_context(request),
} }

View File

@ -87,12 +87,14 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model: The model class which represents the child objects child_model: The model class which represents the child objects
table: The django-tables2 Table class used to render the child objects list table: The django-tables2 Table class used to render the child objects list
filterset: A django-filter FilterSet that is applied to the queryset filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.) action names must be prefixed with `bulk_`. (See ActionsMixin.)
""" """
child_model = None child_model = None
table = None table = None
filterset = None filterset = None
filterset_form = None
template_name = 'generic/object_children.html' template_name = 'generic/object_children.html'
def get_children(self, request, parent): def get_children(self, request, parent):
@ -152,6 +154,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
'table': table, 'table': table,
'table_config': f'{table.name}_config', 'table_config': f'{table.name}_config',
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'actions': actions, 'actions': actions,
'tab': self.tab, 'tab': self.tab,
'return_url': request.get_full_path(), 'return_url': request.get_full_path(),

Binary file not shown.

Binary file not shown.

View File

@ -27,10 +27,10 @@
"bootstrap": "5.3.3", "bootstrap": "5.3.3",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"gridstack": "10.2.1", "gridstack": "10.3.1",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"query-string": "9.0.0", "query-string": "9.1.0",
"sass": "1.77.6", "sass": "1.77.8",
"tom-select": "2.3.1", "tom-select": "2.3.1",
"typeface-inter": "3.18.1", "typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13" "typeface-roboto-mono": "1.1.13"

View File

@ -74,20 +74,25 @@ export class DynamicTomSelect extends TomSelect {
load(value: string) { load(value: string) {
const self = this; const self = this;
const url = self.getRequestUrl(value);
// Automatically clear any cached options. (Only options included // Automatically clear any cached options. (Only options included
// in the API response should be present.) // in the API response should be present.)
self.clearOptions(); self.clearOptions();
addClasses(self.wrapper, self.settings.loadingClass);
self.loading++;
// Populate the null option (if any) if not searching // Populate the null option (if any) if not searching
if (self.nullOption && !value) { if (self.nullOption && !value) {
self.addOption(self.nullOption); self.addOption(self.nullOption);
} }
// Get the API request URL. If none is provided, abort as no request can be made.
const url = self.getRequestUrl(value);
if (!url) {
return;
}
addClasses(self.wrapper, self.settings.loadingClass);
self.loading++;
// Make the API request // Make the API request
fetch(url) fetch(url)
.then(response => response.json()) .then(response => response.json())
@ -129,6 +134,9 @@ export class DynamicTomSelect extends TomSelect {
for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) { for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
if (value) { if (value) {
url = replaceAll(url, result[1], value.toString()); url = replaceAll(url, result[1], value.toString());
} else {
// No value is available to replace the token; abort.
return '';
} }
} }
} }

View File

@ -1754,10 +1754,10 @@ graphql@16.8.1:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw== integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
gridstack@10.2.1: gridstack@10.3.1:
version "10.2.1" version "10.3.1"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca" resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.1.tgz#4ed704279c40094fc1b9e3318f20b573f2fe9f40"
integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw== integrity sha512-Ra82k/88gdeiu3ZP40COS4bI4sGhNQlZAaAQ6szfPfr68zVpsXxiyLKr5zYcTpKX4jjcwyNsNNdcV1tDJc71fA==
has-bigints@^1.0.1, has-bigints@^1.0.2: has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2" version "1.0.2"
@ -2346,10 +2346,10 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
query-string@9.0.0: query-string@9.1.0:
version "9.0.0" version "9.1.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.0.0.tgz#1fe177cd95545600f0deab93f5fb02fd4e3e7273" resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.0.tgz#5f12a4653a4ba56021e113b5cf58e56581823e7a"
integrity sha512-4EWwcRGsO2H+yzq6ddHcVqkCQ2EFUSfDMEjF8ryp8ReymyZhIuaFRGLomeOQLkrzacMHoyky2HW0Qe30UbzkKw== integrity sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==
dependencies: dependencies:
decode-uri-component "^0.4.1" decode-uri-component "^0.4.1"
filter-obj "^5.1.0" filter-obj "^5.1.0"
@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sass@1.77.6: sass@1.77.8:
version "1.77.6" version "1.77.8"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4" resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.8.tgz#9f18b449ea401759ef7ec1752a16373e296b52bd"
integrity sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q== integrity sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==
dependencies: dependencies:
chokidar ">=3.0.0 <4.0.0" chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0" immutable "^4.0.0"

View File

@ -7,7 +7,11 @@
<html <html
lang="en" lang="en"
data-netbox-url-name="{{ request.resolver_match.url_name }}" data-netbox-url-name="{{ request.resolver_match.url_name }}"
data-netbox-base-path="{{ settings.BASE_PATH }}" data-netbox-version="{{ settings.VERSION }}"
{% if request.user.is_authenticated %}
data-netbox-user-name="{{ request.user.username }}"
data-netbox-user-id="{{ request.user.pk }}"
{% endif %}
> >
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@ -4,6 +4,18 @@
{% load perms %} {% load perms %}
{% load i18n %} {% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object|meta:"verbose_name_plural"|bettertitle }}</a>
</li>
{% with parent_jobs_viewname=object.object|viewname:"jobs" %}
<li class="breadcrumb-item">
<a href="{% url parent_jobs_viewname pk=object.object.pk %}">{{ object.object }}</a>
</li>
{% endwith %}
{% endblock breadcrumbs %}
{% block control-buttons %} {% block control-buttons %}
{% if request.user|can_delete:object %} {% if request.user|can_delete:object %}
{% delete_button object %} {% delete_button object %}

View File

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

View File

@ -31,7 +31,7 @@
<td class="d-flex justify-content-between align-items-start"> <td class="d-flex justify-content-between align-items-start">
{% if object.rack %} {% if object.rack %}
{{ object.rack|linkify }} {{ object.rack|linkify }}
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}"> <a href="{{ object.rack.get_absolute_url }}?device={% firstof object.parent_bay.device.pk object.pk %}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i> <i class="mdi mdi-view-day-outline"></i>
</a> </a>
{% else %} {% else %}
@ -125,28 +125,30 @@
</div> </div>
</h5> </h5>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <thead>
<th>{% trans "Device" %}</th> <tr class="border-bottom">
<th>{% trans "Position" %}</th> <th>{% trans "Device" %}</th>
<th>{% trans "Master" %}</th> <th>{% trans "Position" %}</th>
<th>{% trans "Priority" %}</th> <th>{% trans "Master" %}</th>
<th>{% trans "Priority" %}</th>
</tr> </tr>
</thead>
<tbody>
{% for vc_member in vc_members %} {% for vc_member in vc_members %}
<tr{% if vc_member == object %} class="info"{% endif %}> <tr{% if vc_member == object %} class="table-primary"{% endif %}>
<td> <td>{{ vc_member|linkify }}</td>
{{ vc_member|linkify }} <td>{% badge vc_member.vc_position show_empty=True %}</td>
</td> <td>
<td> {% if object.virtual_chassis.master == vc_member %}
{% badge vc_member.vc_position show_empty=True %} {% checkmark True %}
</td> {% else %}
<td> {{ ''|placeholder }}
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %} {% endif %}
</td> </td>
<td> <td>{{ vc_member.vc_priority|placeholder }}</td>
{{ vc_member.vc_priority|placeholder }} </tr>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody>
</table> </table>
</div> </div>
{% endif %} {% endif %}
@ -221,6 +223,11 @@
<td> <td>
{% if object.oob_ip %} {% if object.oob_ip %}
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a> <a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
{% if object.oob_ip.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.oob_ip.nat_inside.get_absolute_url }}">{{ object.oob_ip.nat_inside.address.ip }}</a>)
{% elif object.oob_ip.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "oob_ip" %} {% copy_content "oob_ip" %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}

View File

@ -24,7 +24,7 @@
<table class="table table-hover"> <table class="table table-hover">
{% for test, data in tests.items %} {% for test, data in tests.items %}
<tr> <tr>
<td class="font-monospace"><a href="#{{ test }}">{{ test }}</a></td> <td class="font-monospace">{{ test }}</td>
<td class="text-end report-stats"> <td class="text-end report-stats">
<span class="badge text-bg-success">{{ data.success }}</span> <span class="badge text-bg-success">{{ data.success }}</span>
<span class="badge text-bg-info">{{ data.info }}</span> <span class="badge text-bg-info">{{ data.info }}</span>

View File

@ -48,7 +48,7 @@ Context:
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true"> <a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% trans "Results" %} {% trans "Results" %}
<span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count }}{% endif %}</span> <span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count|default:"0" }}{% endif %}</span>
</a> </a>
</li> </li>
{% if filter_form %} {% if filter_form %}

View File

@ -13,14 +13,16 @@
</div> </div>
</div> </div>
<div class="col-auto d-print-none"> {% if filter_form %}
<div class="input-group"> <div class="col-auto d-print-none">
<div class="input-group-text"> <div class="input-group">
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i> <div class="input-group-text">
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
</div>
{{ filter_form.filter_id }}
</div> </div>
{{ filter_form.filter_id }}
</div> </div>
</div> {% endif %}
<div class="col-auto ms-auto d-print-none"> <div class="col-auto ms-auto d-print-none">
{% if request.user.is_authenticated and table_modal %} {% if request.user.is_authenticated and table_modal %}

View File

@ -53,10 +53,6 @@
{% endwith %} {% endwith %}
</div> </div>
<div class="field-group my-5">
{% render_field form.comments %}
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="field-group my-5"> <div class="field-group my-5">
<div class="row"> <div class="row">
@ -65,4 +61,8 @@
{% render_custom_fields form %} {% render_custom_fields form %}
</div> </div>
{% endif %} {% endif %}
<div class="field-group my-5">
{% render_field form.comments %}
</div>
{% endblock %} {% endblock %}

View File

@ -78,7 +78,8 @@
{% for backend in auth_backends %} {% for backend in auth_backends %}
<div class="col"> <div class="col">
<a href="{{ backend.url }}" class="btn w-100"> <a href="{{ backend.url }}" class="btn w-100">
{% if backend.icon_name %}<i class="mdi mdi-{{ backend.icon_name }}"></i>{% endif %} {% if backend.icon_name %}<i class="mdi mdi-{{ backend.icon_name }}"></i>
{% elif backend.icon_img %}<img src="{{ backend.icon_img }}" height="24" class="me-2" />{% endif %}
{{ backend.display_name }} {{ backend.display_name }}
</a> </a>
</div> </div>

View File

@ -13,6 +13,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
child_model = ContactAssignment child_model = ContactAssignment
table = tables.ContactAssignmentTable table = tables.ContactAssignmentTable
filterset = filtersets.ContactAssignmentFilterSet filterset = filtersets.ContactAssignmentFilterSet
filterset_form = forms.ContactAssignmentFilterForm
template_name = 'tenancy/object_contacts.html' template_name = 'tenancy/object_contacts.html'
tab = ViewTab( tab = ViewTab(
label=_('Contacts'), label=_('Contacts'),
@ -364,7 +365,7 @@ class ContactAssignmentEditView(generic.ObjectEditView):
def get_extra_addanother_params(self, request): def get_extra_addanother_params(self, request):
return { return {
'content_type': request.GET.get('content_type'), 'object_type': request.GET.get('object_type'),
'object_id': request.GET.get('object_id'), 'object_id': request.GET.get('object_id'),
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model, password_validation
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
@ -61,6 +61,14 @@ class UserSerializer(ValidatedModelSerializer):
'password': {'write_only': True} 'password': {'write_only': True}
} }
def validate(self, data):
# Enforce password validation rules (if configured)
if not self.nested and data.get('password'):
password_validation.validate_password(data['password'], self.instance)
return super().validate(data)
def create(self, validated_data): def create(self, validated_data):
""" """
Extract the password from validated data and set it separately to ensure proper hash generation. Extract the password from validated data and set it separately to ensure proper hash generation.

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model, password_validation
from django.contrib.postgres.forms import SimpleArrayField from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -227,6 +227,10 @@ class UserForm(forms.ModelForm):
if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']: if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']:
raise forms.ValidationError(_("Passwords do not match! Please check your input and try again.")) raise forms.ValidationError(_("Passwords do not match! Please check your input and try again."))
# Enforce password validation rules (if configured)
if self.cleaned_data['password']:
password_validation.validate_password(self.cleaned_data['password'], self.instance)
class GroupForm(forms.ModelForm): class GroupForm(forms.ModelForm):
users = DynamicModelMultipleChoiceField( users = DynamicModelMultipleChoiceField(

View File

@ -1,4 +1,5 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from core.models import ObjectType from core.models import ObjectType
@ -93,6 +94,31 @@ class UserTest(APIViewTestCases.APIViewTestCase):
user.refresh_from_db() user.refresh_from_db()
self.assertTrue(user.check_password(data['password'])) self.assertTrue(user.check_password(data['password']))
@override_settings(AUTH_PASSWORD_VALIDATORS=[{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {'min_length': 8}
}])
def test_password_validation_enforced(self):
"""
Test that any configured password validation rules (AUTH_PASSWORD_VALIDATORS) are enforced.
"""
self.add_permissions('users.add_user')
data = {
'username': 'new_user',
'password': 'foo',
}
url = reverse('users-api:user-list')
# Password too short
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 400)
# Password long enough
data['password'] = 'foobar123'
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 201)
class GroupTest(APIViewTestCases.APIViewTestCase): class GroupTest(APIViewTestCases.APIViewTestCase):
model = Group model = Group

View File

@ -1,6 +1,8 @@
from django.test import override_settings
from core.models import ObjectType from core.models import ObjectType
from users.models import * from users.models import *
from utilities.testing import ViewTestCases, create_test_user from utilities.testing import ViewTestCases, create_test_user, extract_form_failures
class UserTestCase( class UserTestCase(
@ -58,6 +60,34 @@ class UserTestCase(
'last_name': 'newlastname', 'last_name': 'newlastname',
} }
@override_settings(AUTH_PASSWORD_VALIDATORS=[{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {'min_length': 8}
}])
def test_password_validation_enforced(self):
"""
Test that any configured password validation rules (AUTH_PASSWORD_VALIDATORS) are enforced.
"""
self.add_permissions('users.add_user')
data = {
'username': 'new_user',
'password': 'foo',
'confirm_password': 'foo',
}
# Password too short
request = {
'path': self._get_url('add'),
'data': data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
# Password long enough
data['password'] = 'foobar123'
data['confirm_password'] = 'foobar123'
self.assertHttpStatus(self.client.post(**request), 302)
class GroupTestCase( class GroupTestCase(
ViewTestCases.GetObjectViewTestCase, ViewTestCases.GetObjectViewTestCase,

View File

@ -2,6 +2,7 @@ from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models from django.db import models
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utilities.ordering import naturalize from utilities.ordering import naturalize
@ -26,6 +27,7 @@ class ColorField(models.CharField):
def formfield(self, **kwargs): def formfield(self, **kwargs):
kwargs['widget'] = ColorSelect kwargs['widget'] = ColorSelect
kwargs['help_text'] = mark_safe(_('RGB color in hexadecimal. Example: ') + '<code>00ff00</code>')
return super().formfield(**kwargs) return super().formfield(**kwargs)

View File

@ -55,7 +55,7 @@ def prepare_cloned_fields(instance):
for key, value in attrs.items(): for key, value in attrs.items():
if type(value) in (list, tuple): if type(value) in (list, tuple):
params.extend([(key, v) for v in value]) params.extend([(key, v) for v in value])
elif value not in (False, None): elif value is not False and value is not None:
params.append((key, value)) params.append((key, value))
else: else:
params.append((key, '')) params.append((key, ''))

View File

@ -19,10 +19,9 @@ from .base import ModelTestCase
from .utils import disable_warnings from .utils import disable_warnings
from ipam.graphql.types import IPAddressFamilyType from ipam.graphql.types import IPAddressFamilyType
from strawberry.field import StrawberryField from strawberry.types.lazy_type import LazyType
from strawberry.lazy_type import LazyType from strawberry.types.base import StrawberryList, StrawberryOptional
from strawberry.type import StrawberryList, StrawberryOptional from strawberry.types.union import StrawberryUnion
from strawberry.union import StrawberryUnion
__all__ = ( __all__ = (
'APITestCase', 'APITestCase',

View File

@ -179,8 +179,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
'cluster': _('A virtual machine must be assigned to a site and/or cluster.') 'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
}) })
# Validate site for cluster & device # Validate site for cluster & VM
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
raise ValidationError({ raise ValidationError({
'cluster': _( 'cluster': _(
'The selected cluster ({cluster}) is not assigned to this site ({site}).' 'The selected cluster ({cluster}) is not assigned to this site ({site}).'

View File

@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
from jinja2.exceptions import TemplateError from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet from dcim.filtersets import DeviceFilterSet
from dcim.forms import DeviceFilterForm
from dcim.models import Device from dcim.models import Device
from dcim.tables import DeviceTable from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
@ -173,6 +174,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine child_model = VirtualMachine
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
filterset = filtersets.VirtualMachineFilterSet filterset = filtersets.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Virtual Machines'), label=_('Virtual Machines'),
badge=lambda obj: obj.virtual_machines.count(), badge=lambda obj: obj.virtual_machines.count(),
@ -190,6 +192,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
child_model = Device child_model = Device
table = DeviceTable table = DeviceTable
filterset = DeviceFilterSet filterset = DeviceFilterSet
filterset_form = DeviceFilterForm
template_name = 'virtualization/cluster/devices.html' template_name = 'virtualization/cluster/devices.html'
actions = { actions = {
'add': {'add'}, 'add': {'add'},
@ -350,6 +353,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface child_model = VMInterface
table = tables.VirtualMachineVMInterfaceTable table = tables.VirtualMachineVMInterfaceTable
filterset = filtersets.VMInterfaceFilterSet filterset = filtersets.VMInterfaceFilterSet
filterset_form = forms.VMInterfaceFilterForm
template_name = 'virtualization/virtualmachine/interfaces.html' template_name = 'virtualization/virtualmachine/interfaces.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@ -375,6 +379,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
child_model = VirtualDisk child_model = VirtualDisk
table = tables.VirtualMachineVirtualDiskTable table = tables.VirtualMachineVirtualDiskTable
filterset = filtersets.VirtualDiskFilterSet filterset = filtersets.VirtualDiskFilterSet
filterset_form = forms.VirtualDiskFilterForm
template_name = 'virtualization/virtualmachine/virtual_disks.html' template_name = 'virtualization/virtualmachine/virtual_disks.html'
tab = ViewTab( tab = ViewTab(
label=_('Virtual Disks'), label=_('Virtual Disks'),

View File

@ -25,7 +25,8 @@ class IKEProposalSerializer(NetBoxModelSerializer):
choices=EncryptionAlgorithmChoices choices=EncryptionAlgorithmChoices
) )
authentication_algorithm = ChoiceField( authentication_algorithm = ChoiceField(
choices=AuthenticationAlgorithmChoices choices=AuthenticationAlgorithmChoices,
required=False
) )
group = ChoiceField( group = ChoiceField(
choices=DHGroupChoices choices=DHGroupChoices
@ -49,7 +50,8 @@ class IKEPolicySerializer(NetBoxModelSerializer):
choices=IKEVersionChoices choices=IKEVersionChoices
) )
mode = ChoiceField( mode = ChoiceField(
choices=IKEModeChoices choices=IKEModeChoices,
required=False
) )
proposals = SerializedPKRelatedField( proposals = SerializedPKRelatedField(
queryset=IKEProposal.objects.all(), queryset=IKEProposal.objects.all(),

View File

@ -1,4 +1,4 @@
Django==5.0.6 Django==5.0.7
django-cors-headers==4.4.0 django-cors-headers==4.4.0
django-debug-toolbar==4.3.0 django-debug-toolbar==4.3.0
django-filter==24.2 django-filter==24.2
@ -12,26 +12,26 @@ django-rich==1.9.0
django-rq==2.10.2 django-rq==2.10.2
django-taggit==5.0.1 django-taggit==5.0.1
django-tables2==2.7.0 django-tables2==2.7.0
django-timezone-field==6.1.0 django-timezone-field==7.0
djangorestframework==3.15.2 djangorestframework==3.15.2
drf-spectacular==0.27.2 drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.6.1 drf-spectacular-sidecar==2024.7.1
feedparser==6.0.11 feedparser==6.0.11
gunicorn==22.0.0 gunicorn==22.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.6 Markdown==3.6
mkdocs-material==9.5.27 mkdocs-material==9.5.30
mkdocstrings[python-legacy]==0.25.1 mkdocstrings[python-legacy]==0.25.2
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.17 nh3==0.2.18
Pillow==10.3.0 Pillow==10.4.0
psycopg[c,pool]==3.1.19 psycopg[c,pool]==3.2.1
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.32.3 requests==2.32.3
social-auth-app-django==5.4.1 social-auth-app-django==5.4.2
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.235.0 strawberry-graphql==0.237.2
strawberry-graphql-django==0.44.2 strawberry-graphql-django==0.47.1
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.6.1 tablib==3.6.1
tzdata==2024.1 tzdata==2024.1

Some files were not shown because too many files have changed in this diff Show More