mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-08 00:28:16 -06:00
Merge branch 'develop' into 16640-custom-json-field
This commit is contained in:
commit
51070079e4
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -26,7 +26,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.0.6
|
||||
placeholder: v4.0.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.0.6
|
||||
placeholder: v4.0.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/workflows/auto-assign-issue.yml
vendored
2
.github/workflows/auto-assign-issue.yml
vendored
@ -16,6 +16,6 @@ jobs:
|
||||
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
|
||||
with:
|
||||
# Weighted assignments
|
||||
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps
|
||||
assignees: arthanson:3, jeremystretch:3, DanSheps
|
||||
numOfAssignee: 1
|
||||
abortIfPreviousAssignees: true
|
||||
|
@ -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.
|
||||
|
||||
* 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.)
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-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>
|
||||
<p></p>
|
||||
</div>
|
||||
|
@ -16,7 +16,7 @@ Administrators are encouraged to adhere to industry best practices concerning th
|
||||
|
||||
## 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 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.
|
||||
|
||||
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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
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"),
|
||||
}
|
||||
```
|
||||
|
@ -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
|
||||
|
||||
An optional dictionary of tag names and values to apply to Sentry error reports.For example:
|
||||
|
@ -177,7 +177,7 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
|
||||
|
||||
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.
|
||||
|
||||
@ -187,7 +187,7 @@ The configuration parameters for the specified storage backend are defined under
|
||||
|
||||
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.
|
||||
|
||||
|
@ -113,7 +113,7 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
|
||||
* **Tag:** Current version (e.g. `v3.3.1`)
|
||||
* **Target:** `master`
|
||||
* **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.
|
||||
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
|
@ -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).
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
@ -84,11 +84,11 @@ To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/vi
|
||||
|
||||
```python
|
||||
# api/views.py
|
||||
from netbox.api.viewsets import ModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from my_plugin.models import MyModel
|
||||
from .serializers import MyModelSerializer
|
||||
|
||||
class MyModelViewSet(ModelViewSet):
|
||||
class MyModelViewSet(NetBoxModelViewSet):
|
||||
queryset = MyModel.objects.all()
|
||||
serializer_class = MyModelSerializer
|
||||
```
|
||||
|
@ -70,3 +70,19 @@ DROP TABLE
|
||||
netbox=> DROP TABLE pluginname_bar;
|
||||
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.
|
||||
|
@ -1,6 +1,70 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
|
@ -44,10 +44,20 @@ class LoginView(View):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
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 {
|
||||
'display_name': display_name,
|
||||
'icon_name': icon_name,
|
||||
'icon_img': icon_img,
|
||||
'url': f'{url}?{urlencode(params)}',
|
||||
}
|
||||
|
||||
@ -111,7 +121,7 @@ class LoginView(View):
|
||||
|
||||
# Set the user's preferred language (if any)
|
||||
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
|
||||
|
||||
@ -206,7 +216,7 @@ class UserConfigView(LoginRequiredMixin, View):
|
||||
|
||||
# Set/clear language cookie
|
||||
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:
|
||||
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||
|
||||
|
@ -38,6 +38,8 @@ class CircuitCommitRateChoices(ChoiceSet):
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(200000000, '200 Gbps'),
|
||||
(400000000, '400 Gbps'),
|
||||
(1544, 'T1 (1.544 Mbps)'),
|
||||
(2048, 'E1 (2.048 Mbps)'),
|
||||
]
|
||||
@ -69,6 +71,8 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(200000000, '200 Gbps'),
|
||||
(400000000, '400 Gbps'),
|
||||
(1544, 'T1 (1.544 Mbps)'),
|
||||
(2048, 'E1 (2.048 Mbps)'),
|
||||
]
|
||||
|
@ -66,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
class CircuitImportForm(NetBoxModelImportForm):
|
||||
|
@ -25,7 +25,7 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
account_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('accounts__count'),
|
||||
viewname='circuits:provideraccount_list',
|
||||
url_params={'account_id': 'pk'},
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('Account Count')
|
||||
)
|
||||
asns = columns.ManyToManyColumn(
|
||||
|
@ -84,9 +84,7 @@ class GitBackend(DataBackend):
|
||||
clone_args = {
|
||||
"branch": self.params.get('branch'),
|
||||
"config": self.config,
|
||||
"depth": 1,
|
||||
"errstream": porcelain.NoneStream(),
|
||||
"quiet": True,
|
||||
}
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
@ -97,6 +95,9 @@ class GitBackend(DataBackend):
|
||||
"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}")
|
||||
try:
|
||||
|
@ -19,6 +19,7 @@ REVISION_BUTTONS = """
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
false_mark=None
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',),
|
||||
|
@ -555,7 +555,7 @@ class SystemView(UserPassesTestMixin, View):
|
||||
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# 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
|
||||
if 'export' in request.GET:
|
||||
|
@ -13,7 +13,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
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_reachable = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@ -22,7 +22,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
|
||||
if endpoints := obj.connected_endpoints:
|
||||
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):
|
||||
"""
|
||||
Return the appropriate serializer for the type of connected object.
|
||||
|
@ -91,7 +91,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
class CabledObjectSerializer(serializers.ModelSerializer):
|
||||
cable = CableSerializer(nested=True, read_only=True, allow_null=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)
|
||||
_occupied = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
|
@ -88,7 +88,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@extend_schema_field(NestedDeviceSerializer)
|
||||
@extend_schema_field(NestedDeviceSerializer(allow_null=True))
|
||||
def get_parent_device(self, obj):
|
||||
try:
|
||||
device_bay = obj.parent_bay
|
||||
|
@ -20,7 +20,7 @@ from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
@ -1018,6 +1018,17 @@ class DeviceFilterSet(
|
||||
queryset=Cluster.objects.all(),
|
||||
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(
|
||||
field_name='device_type__slug',
|
||||
queryset=DeviceType.objects.all(),
|
||||
@ -1378,12 +1389,12 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='model',
|
||||
label=_('Device type (model)'),
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
device_role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__role',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label=_('Device role (ID)'),
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
device_role = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__role__slug',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
|
@ -174,9 +174,6 @@ class RackRoleImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
class RackImportForm(NetBoxModelImportForm):
|
||||
@ -384,9 +381,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
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):
|
||||
@ -1052,7 +1046,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
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',
|
||||
)
|
||||
|
||||
@ -1104,9 +1098,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = InventoryItemRole
|
||||
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',
|
||||
'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):
|
||||
"""
|
||||
|
@ -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.rendering import FieldSet
|
||||
from utilities.forms.widgets import NumberWithOptions
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import *
|
||||
|
||||
@ -655,6 +656,7 @@ class DeviceFilterForm(
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
|
||||
name=_('Components')
|
||||
),
|
||||
FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
|
||||
FieldSet(
|
||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
||||
'has_virtual_device_context',
|
||||
@ -821,6 +823,16 @@ class DeviceFilterForm(
|
||||
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)
|
||||
|
||||
|
||||
|
@ -88,6 +88,8 @@ class Cable(PrimaryModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
clone_fields = ('tenant', 'type',)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
verbose_name = _('cable')
|
||||
|
@ -63,7 +63,10 @@ class DeviceRoleTable(NetBoxTable):
|
||||
verbose_name=_('VMs')
|
||||
)
|
||||
color = columns.ColorColumn()
|
||||
vm_role = columns.BooleanColumn()
|
||||
vm_role = columns.BooleanColumn(
|
||||
verbose_name=_('VM role'),
|
||||
false_mark=None
|
||||
)
|
||||
config_template = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -329,6 +332,7 @@ class CableTerminationTable(NetBoxTable):
|
||||
)
|
||||
mark_connected = columns.BooleanColumn(
|
||||
verbose_name=_('Mark Connected'),
|
||||
false_mark=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -586,7 +590,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
}
|
||||
)
|
||||
mgmt_only = columns.BooleanColumn(
|
||||
verbose_name=_('Management Only')
|
||||
verbose_name=_('Management Only'),
|
||||
false_mark=None
|
||||
)
|
||||
speed_formatted = columns.TemplateColumn(
|
||||
template_code='{% load helpers %}{{ value|humanize_speed }}',
|
||||
@ -913,6 +918,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
)
|
||||
discovered = columns.BooleanColumn(
|
||||
verbose_name=_('Discovered'),
|
||||
false_mark=None
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
|
@ -86,7 +86,8 @@ class DeviceTypeTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_full_depth = columns.BooleanColumn(
|
||||
verbose_name=_('Full Depth')
|
||||
verbose_name=_('Full Depth'),
|
||||
false_mark=None
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
@ -98,7 +99,10 @@ class DeviceTypeTable(NetBoxTable):
|
||||
verbose_name=_('U Height'),
|
||||
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(
|
||||
verbose_name=_('Weight'),
|
||||
template_code=WEIGHT,
|
||||
@ -221,7 +225,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
mgmt_only = columns.BooleanColumn(
|
||||
verbose_name=_('Management Only')
|
||||
verbose_name=_('Management Only'),
|
||||
false_mark=None
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('edit', 'delete'),
|
||||
|
@ -9,7 +9,7 @@ from ipam.models import ASN, IPAddress, RIR, VRF
|
||||
from netbox.choices import ColorChoices
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
@ -32,11 +32,11 @@ class DeviceComponentFilterSetTests:
|
||||
params = {'device_type': [device_types[0].model, device_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
def test_device_role(self):
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@ -1959,10 +1959,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
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 = (
|
||||
Cluster(name='Cluster 1', type=cluster_type),
|
||||
Cluster(name='Cluster 2', type=cluster_type),
|
||||
Cluster(name='Cluster 3', type=cluster_type),
|
||||
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
@ -2213,6 +2219,13 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
|
||||
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):
|
||||
params = {'model': ['model-1', 'model-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]}
|
||||
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):
|
||||
role = DeviceRole.objects.all()[:2]
|
||||
params = {'role_id': [role[0].pk, role[1].pk]}
|
||||
|
@ -31,6 +31,7 @@ from utilities.views import (
|
||||
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||
)
|
||||
from virtualization.filtersets import VirtualMachineFilterSet
|
||||
from virtualization.forms import VirtualMachineFilterForm
|
||||
from virtualization.models import VirtualMachine
|
||||
from virtualization.tables import VirtualMachineTable
|
||||
from . import filtersets, forms, tables
|
||||
@ -679,6 +680,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
|
||||
child_model = RackReservation
|
||||
table = tables.RackReservationTable
|
||||
filterset = filtersets.RackReservationFilterSet
|
||||
filterset_form = forms.RackReservationFilterForm
|
||||
template_name = 'dcim/rack/reservations.html'
|
||||
tab = ViewTab(
|
||||
label=_('Reservations'),
|
||||
@ -697,6 +699,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
|
||||
child_model = Device
|
||||
table = tables.DeviceTable
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
filterset_form = forms.DeviceFilterForm
|
||||
template_name = 'dcim/rack/non_racked_devices.html'
|
||||
tab = ViewTab(
|
||||
label=_('Non-Racked Devices'),
|
||||
@ -1835,6 +1838,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
|
||||
child_model = ConsolePort
|
||||
table = tables.DeviceConsolePortTable
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
template_name = 'dcim/device/consoleports.html',
|
||||
tab = ViewTab(
|
||||
label=_('Console Ports'),
|
||||
@ -1850,6 +1854,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
|
||||
child_model = ConsoleServerPort
|
||||
table = tables.DeviceConsoleServerPortTable
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
template_name = 'dcim/device/consoleserverports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Console Server Ports'),
|
||||
@ -1865,6 +1870,7 @@ class DevicePowerPortsView(DeviceComponentsView):
|
||||
child_model = PowerPort
|
||||
table = tables.DevicePowerPortTable
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
template_name = 'dcim/device/powerports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Power Ports'),
|
||||
@ -1880,6 +1886,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
|
||||
child_model = PowerOutlet
|
||||
table = tables.DevicePowerOutletTable
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
template_name = 'dcim/device/poweroutlets.html'
|
||||
tab = ViewTab(
|
||||
label=_('Power Outlets'),
|
||||
@ -1895,6 +1902,7 @@ class DeviceInterfacesView(DeviceComponentsView):
|
||||
child_model = Interface
|
||||
table = tables.DeviceInterfaceTable
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
template_name = 'dcim/device/interfaces.html'
|
||||
tab = ViewTab(
|
||||
label=_('Interfaces'),
|
||||
@ -1916,6 +1924,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
|
||||
child_model = FrontPort
|
||||
table = tables.DeviceFrontPortTable
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
template_name = 'dcim/device/frontports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Front Ports'),
|
||||
@ -1931,6 +1940,7 @@ class DeviceRearPortsView(DeviceComponentsView):
|
||||
child_model = RearPort
|
||||
table = tables.DeviceRearPortTable
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
template_name = 'dcim/device/rearports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Rear Ports'),
|
||||
@ -1946,6 +1956,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
child_model = ModuleBay
|
||||
table = tables.DeviceModuleBayTable
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
template_name = 'dcim/device/modulebays.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
@ -1965,6 +1976,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
child_model = DeviceBay
|
||||
table = tables.DeviceDeviceBayTable
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
@ -1984,6 +1996,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
child_model = InventoryItem
|
||||
table = tables.DeviceInventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
template_name = 'dcim/device/inventory.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
@ -2062,6 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
||||
child_model = VirtualMachine
|
||||
table = VirtualMachineTable
|
||||
filterset = VirtualMachineFilterSet
|
||||
filterset_form = VirtualMachineFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('Virtual Machines'),
|
||||
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
|
||||
@ -2944,6 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
|
||||
child_model = InventoryItem
|
||||
table = tables.InventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('Children'),
|
||||
badge=lambda obj: obj.child_items.count(),
|
||||
|
@ -39,7 +39,7 @@ class ScriptSerializer(ValidatedModelSerializer):
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_description(self, obj):
|
||||
if obj.python_class:
|
||||
return obj.python_class().description
|
||||
|
@ -251,6 +251,10 @@ class ObjectListWidget(DashboardWidget):
|
||||
def render(self, request):
|
||||
app_label, model_name = self.config['model'].split('.')
|
||||
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')
|
||||
|
||||
# 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:
|
||||
bookmarks = list()
|
||||
else:
|
||||
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'])
|
||||
bookmarks = Bookmark.objects.filter(user=request.user)
|
||||
if object_types := self.config.get('object_types'):
|
||||
models = get_models_from_content_types(object_types)
|
||||
content_types = ObjectType.objects.get_for_models(*models).values()
|
||||
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'):
|
||||
bookmarks = bookmarks[:max_items]
|
||||
|
||||
|
@ -63,6 +63,9 @@ def enqueue_object(queue, instance, user, request_id, action):
|
||||
if key in queue:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
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:
|
||||
queue[key] = {
|
||||
'content_type': ContentType.objects.get_for_model(instance),
|
||||
|
@ -228,9 +228,6 @@ class TagImportForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
|
@ -66,11 +66,16 @@ class Command(BaseCommand):
|
||||
raise CommandError(_("No indexers found!"))
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
# Clear cached values for the specified models (if not being 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.flush()
|
||||
deleted_count = search_backend.clear()
|
||||
deleted_count = search_backend.clear(object_types=content_types)
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
|
@ -490,7 +490,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# 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
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
|
@ -47,7 +47,8 @@ class CustomFieldTable(NetBoxTable):
|
||||
verbose_name=_('Object Types')
|
||||
)
|
||||
required = columns.BooleanColumn(
|
||||
verbose_name=_('Required')
|
||||
verbose_name=_('Required'),
|
||||
false_mark=None
|
||||
)
|
||||
ui_visible = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Visible')
|
||||
@ -72,6 +73,7 @@ class CustomFieldTable(NetBoxTable):
|
||||
)
|
||||
is_cloneable = columns.BooleanColumn(
|
||||
verbose_name=_('Is Cloneable'),
|
||||
false_mark=None
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@ -105,6 +107,7 @@ class CustomFieldChoiceSetTable(NetBoxTable):
|
||||
)
|
||||
order_alphabetically = columns.BooleanColumn(
|
||||
verbose_name=_('Order Alphabetically'),
|
||||
false_mark=None
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@ -129,6 +132,7 @@ class CustomLinkTable(NetBoxTable):
|
||||
)
|
||||
new_window = columns.BooleanColumn(
|
||||
verbose_name=_('New Window'),
|
||||
false_mark=None
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@ -150,6 +154,7 @@ class ExportTemplateTable(NetBoxTable):
|
||||
)
|
||||
as_attachment = columns.BooleanColumn(
|
||||
verbose_name=_('As Attachment'),
|
||||
false_mark=None
|
||||
)
|
||||
data_source = tables.Column(
|
||||
verbose_name=_('Data Source'),
|
||||
@ -218,6 +223,7 @@ class SavedFilterTable(NetBoxTable):
|
||||
)
|
||||
shared = columns.BooleanColumn(
|
||||
verbose_name=_('Shared'),
|
||||
false_mark=None
|
||||
)
|
||||
|
||||
def value_parameters(self, value):
|
||||
|
@ -390,13 +390,36 @@ class EventRuleTest(APITestCase):
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
|
||||
|
||||
# Test create & update
|
||||
with event_tracking(request):
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
# Save the site a second time
|
||||
site.description = 'foo'
|
||||
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_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()
|
||||
|
@ -86,7 +86,8 @@ class RIRTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_private = columns.BooleanColumn(
|
||||
verbose_name=_('Private')
|
||||
verbose_name=_('Private'),
|
||||
false_mark=None
|
||||
)
|
||||
aggregate_count = columns.LinkedCountColumn(
|
||||
viewname='ipam:aggregate_list',
|
||||
@ -258,10 +259,12 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_pool = columns.BooleanColumn(
|
||||
verbose_name=_('Pool')
|
||||
verbose_name=_('Pool'),
|
||||
false_mark=None
|
||||
)
|
||||
mark_utilized = columns.BooleanColumn(
|
||||
verbose_name=_('Marked Utilized')
|
||||
verbose_name=_('Marked Utilized'),
|
||||
false_mark=None
|
||||
)
|
||||
utilization = PrefixUtilizationColumn(
|
||||
verbose_name=_('Utilization'),
|
||||
@ -314,7 +317,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
mark_utilized = columns.BooleanColumn(
|
||||
verbose_name=_('Marked Utilized')
|
||||
verbose_name=_('Marked Utilized'),
|
||||
false_mark=None
|
||||
)
|
||||
utilization = columns.UtilizationColumn(
|
||||
verbose_name=_('Utilization'),
|
||||
@ -386,7 +390,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
||||
assigned = columns.BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
linkify=lambda record: record.assigned_object.get_absolute_url(),
|
||||
verbose_name=_('Assigned')
|
||||
verbose_name=_('Assigned'),
|
||||
false_mark=None
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
|
@ -211,6 +211,7 @@ class InterfaceVLANTable(NetBoxTable):
|
||||
)
|
||||
tagged = columns.BooleanColumn(
|
||||
verbose_name=_('Tagged'),
|
||||
false_mark=None
|
||||
)
|
||||
site = tables.Column(
|
||||
verbose_name=_('Site'),
|
||||
|
@ -30,7 +30,8 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name=_('RD')
|
||||
)
|
||||
enforce_unique = columns.BooleanColumn(
|
||||
verbose_name=_('Unique')
|
||||
verbose_name=_('Unique'),
|
||||
false_mark=None
|
||||
)
|
||||
import_targets = columns.TemplateColumn(
|
||||
verbose_name=_('Import Targets'),
|
||||
|
@ -7,6 +7,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.forms import InterfaceFilterForm
|
||||
from dcim.models import Interface, Site
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
@ -14,6 +15,7 @@ from utilities.query import count_related
|
||||
from utilities.tables import get_table_ordering
|
||||
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.forms import VMInterfaceFilterForm
|
||||
from virtualization.models import VMInterface
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import PrefixStatusChoices
|
||||
@ -206,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
|
||||
child_model = ASN
|
||||
table = tables.ASNTable
|
||||
filterset = filtersets.ASNFilterSet
|
||||
filterset_form = forms.ASNFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('ASNs'),
|
||||
badge=lambda x: x.get_child_asns().count(),
|
||||
@ -337,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
|
||||
child_model = Prefix
|
||||
table = tables.PrefixTable
|
||||
filterset = filtersets.PrefixFilterSet
|
||||
filterset_form = forms.PrefixFilterForm
|
||||
template_name = 'ipam/aggregate/prefixes.html'
|
||||
tab = ViewTab(
|
||||
label=_('Prefixes'),
|
||||
@ -523,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
|
||||
child_model = Prefix
|
||||
table = tables.PrefixTable
|
||||
filterset = filtersets.PrefixFilterSet
|
||||
filterset_form = forms.PrefixFilterForm
|
||||
template_name = 'ipam/prefix/prefixes.html'
|
||||
tab = ViewTab(
|
||||
label=_('Child Prefixes'),
|
||||
@ -558,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
|
||||
child_model = IPRange
|
||||
table = tables.IPRangeTable
|
||||
filterset = filtersets.IPRangeFilterSet
|
||||
filterset_form = forms.IPRangeFilterForm
|
||||
template_name = 'ipam/prefix/ip_ranges.html'
|
||||
tab = ViewTab(
|
||||
label=_('Child Ranges'),
|
||||
@ -584,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
child_model = IPAddress
|
||||
table = tables.IPAddressTable
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
template_name = 'ipam/prefix/ip_addresses.html'
|
||||
tab = ViewTab(
|
||||
label=_('IP Addresses'),
|
||||
@ -683,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
child_model = IPAddress
|
||||
table = tables.IPAddressTable
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
filterset_form = forms.IPRangeFilterForm
|
||||
template_name = 'ipam/iprange/ip_addresses.html'
|
||||
tab = ViewTab(
|
||||
label=_('IP Addresses'),
|
||||
@ -885,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
|
||||
child_model = IPAddress
|
||||
table = tables.IPAddressTable
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('Related IPs'),
|
||||
badge=lambda x: x.get_related_ips().count(),
|
||||
@ -957,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
|
||||
child_model = VLAN
|
||||
table = tables.VLANTable
|
||||
filterset = filtersets.VLANFilterSet
|
||||
filterset_form = forms.VLANFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('VLANs'),
|
||||
badge=lambda x: x.get_child_vlans().count(),
|
||||
@ -1112,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
|
||||
child_model = Interface
|
||||
table = tables.VLANDevicesTable
|
||||
filterset = InterfaceFilterSet
|
||||
filterset_form = InterfaceFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('Device Interfaces'),
|
||||
badge=lambda x: x.get_interfaces().count(),
|
||||
@ -1129,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
|
||||
child_model = VMInterface
|
||||
table = tables.VLANVirtualMachinesTable
|
||||
filterset = VMInterfaceFilterSet
|
||||
filterset_form = VMInterfaceFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('VM Interfaces'),
|
||||
badge=lambda x: x.get_vminterfaces().count(),
|
||||
|
@ -49,12 +49,15 @@ AUTH_BACKEND_ATTRS = {
|
||||
'okta-openidconnect': ('Okta (OIDC)', None),
|
||||
'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):
|
||||
"""
|
||||
Return the user-friendly name and icon name for a remote authentication backend, if known. Defaults to the
|
||||
raw backend name and no icon.
|
||||
Return the user-friendly name and icon name for a remote authentication backend, if
|
||||
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))
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import re
|
||||
|
||||
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.backends import search_backend
|
||||
@ -36,7 +36,8 @@ class SearchForm(forms.Form):
|
||||
lookup = forms.ChoiceField(
|
||||
choices=LOOKUP_CHOICES,
|
||||
initial=LookupTypes.PARTIAL,
|
||||
required=False
|
||||
required=False,
|
||||
label=_('Lookup')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -47,6 +47,11 @@ class CoreMiddleware:
|
||||
with event_tracking(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.
|
||||
response['X-Request-ID'] = request.id
|
||||
|
||||
|
@ -462,16 +462,13 @@ MENUS = [
|
||||
PROVISIONING_MENU,
|
||||
CUSTOMIZATION_MENU,
|
||||
OPERATIONS_MENU,
|
||||
ADMIN_MENU,
|
||||
]
|
||||
|
||||
#
|
||||
# Add plugin menus
|
||||
#
|
||||
|
||||
# Add top-level plugin menus
|
||||
for menu in registry['plugins']['menus']:
|
||||
MENUS.append(menu)
|
||||
|
||||
# Add the default "plugins" menu
|
||||
if registry['plugins']['menu_items']:
|
||||
|
||||
# Build the default plugins menu
|
||||
@ -485,3 +482,6 @@ if registry['plugins']['menu_items']:
|
||||
groups=groups
|
||||
)
|
||||
MENUS.append(plugins_menu)
|
||||
|
||||
# Add the admin menu last
|
||||
MENUS.append(ADMIN_MENU)
|
||||
|
@ -8,6 +8,7 @@ from django.db.models.fields.related import ForeignKey
|
||||
from django.db.models.functions import window
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import netaddr
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
@ -39,7 +40,7 @@ class SearchBackend:
|
||||
# Organize choices by category
|
||||
categories = defaultdict(dict)
|
||||
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
|
||||
results = (
|
||||
|
@ -25,7 +25,7 @@ from utilities.string import trailing_slash
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '4.0.7-dev'
|
||||
VERSION = '4.0.9-dev'
|
||||
HOSTNAME = platform.node()
|
||||
# Set the base directory two levels up
|
||||
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_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||
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_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
|
||||
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
|
||||
@ -225,6 +226,23 @@ if STORAGE_BACKEND is not None:
|
||||
return globals().get(name, default)
|
||||
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:
|
||||
warnings.warn(
|
||||
"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,
|
||||
sample_rate=SENTRY_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,
|
||||
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
|
||||
)
|
||||
@ -721,11 +739,16 @@ RQ_QUEUES.update({
|
||||
|
||||
# Supported translation languages
|
||||
LANGUAGES = (
|
||||
('cs', _('Czech')),
|
||||
('da', _('Danish')),
|
||||
('de', _('German')),
|
||||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('fr', _('French')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('nl', _('Dutch')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('tr', _('Turkish')),
|
||||
|
@ -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
|
||||
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">—</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):
|
||||
if value:
|
||||
rendered = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
|
||||
elif value is None:
|
||||
rendered = '<span class="text-muted">—</span>'
|
||||
else:
|
||||
rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
|
||||
return mark_safe(rendered)
|
||||
if value is None:
|
||||
return self.EMPTY_MARK
|
||||
if value and self.true_mark:
|
||||
return self.true_mark
|
||||
if not value and self.false_mark:
|
||||
return self.false_mark
|
||||
return self.EMPTY_MARK
|
||||
|
||||
def value(self, value):
|
||||
return str(value)
|
||||
@ -249,7 +258,7 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
def render(self, record, table, **kwargs):
|
||||
# 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 ''
|
||||
|
||||
model = table.Meta.model
|
||||
|
@ -176,7 +176,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
'model': model,
|
||||
'table': table,
|
||||
'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),
|
||||
**self.get_extra_context(request),
|
||||
}
|
||||
|
@ -87,12 +87,14 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
child_model: The model class which represents the child objects
|
||||
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_form: The form class used to render filter options
|
||||
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
|
||||
action names must be prefixed with `bulk_`. (See ActionsMixin.)
|
||||
"""
|
||||
child_model = None
|
||||
table = None
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
template_name = 'generic/object_children.html'
|
||||
|
||||
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',
|
||||
'table': table,
|
||||
'table_config': f'{table.name}_config',
|
||||
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
|
||||
'actions': actions,
|
||||
'tab': self.tab,
|
||||
'return_url': request.get_full_path(),
|
||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -27,10 +27,10 @@
|
||||
"bootstrap": "5.3.3",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "10.2.1",
|
||||
"gridstack": "10.3.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.0.0",
|
||||
"sass": "1.77.6",
|
||||
"query-string": "9.1.0",
|
||||
"sass": "1.77.8",
|
||||
"tom-select": "2.3.1",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
@ -74,20 +74,25 @@ export class DynamicTomSelect extends TomSelect {
|
||||
|
||||
load(value: string) {
|
||||
const self = this;
|
||||
const url = self.getRequestUrl(value);
|
||||
|
||||
// Automatically clear any cached options. (Only options included
|
||||
// in the API response should be present.)
|
||||
self.clearOptions();
|
||||
|
||||
addClasses(self.wrapper, self.settings.loadingClass);
|
||||
self.loading++;
|
||||
|
||||
// Populate the null option (if any) if not searching
|
||||
if (self.nullOption && !value) {
|
||||
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
|
||||
fetch(url)
|
||||
.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'))) {
|
||||
if (value) {
|
||||
url = replaceAll(url, result[1], value.toString());
|
||||
} else {
|
||||
// No value is available to replace the token; abort.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1754,10 +1754,10 @@ graphql@16.8.1:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
||||
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
||||
|
||||
gridstack@10.2.1:
|
||||
version "10.2.1"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca"
|
||||
integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw==
|
||||
gridstack@10.3.1:
|
||||
version "10.3.1"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.1.tgz#4ed704279c40094fc1b9e3318f20b573f2fe9f40"
|
||||
integrity sha512-Ra82k/88gdeiu3ZP40COS4bI4sGhNQlZAaAQ6szfPfr68zVpsXxiyLKr5zYcTpKX4jjcwyNsNNdcV1tDJc71fA==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^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"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
query-string@9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.0.0.tgz#1fe177cd95545600f0deab93f5fb02fd4e3e7273"
|
||||
integrity sha512-4EWwcRGsO2H+yzq6ddHcVqkCQ2EFUSfDMEjF8ryp8ReymyZhIuaFRGLomeOQLkrzacMHoyky2HW0Qe30UbzkKw==
|
||||
query-string@9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.0.tgz#5f12a4653a4ba56021e113b5cf58e56581823e7a"
|
||||
integrity sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==
|
||||
dependencies:
|
||||
decode-uri-component "^0.4.1"
|
||||
filter-obj "^5.1.0"
|
||||
@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.77.6:
|
||||
version "1.77.6"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4"
|
||||
integrity sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==
|
||||
sass@1.77.8:
|
||||
version "1.77.8"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.8.tgz#9f18b449ea401759ef7ec1752a16373e296b52bd"
|
||||
integrity sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==
|
||||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
immutable "^4.0.0"
|
||||
|
@ -7,7 +7,11 @@
|
||||
<html
|
||||
lang="en"
|
||||
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>
|
||||
<meta charset="UTF-8" />
|
||||
|
@ -4,6 +4,18 @@
|
||||
{% load perms %}
|
||||
{% 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 %}
|
||||
{% if request.user|can_delete:object %}
|
||||
{% delete_button object %}
|
||||
|
@ -88,7 +88,7 @@
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<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>
|
||||
|
@ -31,7 +31,7 @@
|
||||
<td class="d-flex justify-content-between align-items-start">
|
||||
{% if object.rack %}
|
||||
{{ 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>
|
||||
</a>
|
||||
{% else %}
|
||||
@ -125,28 +125,30 @@
|
||||
</div>
|
||||
</h5>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th>{% trans "Device" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
<th>{% trans "Master" %}</th>
|
||||
<th>{% trans "Priority" %}</th>
|
||||
<thead>
|
||||
<tr class="border-bottom">
|
||||
<th>{% trans "Device" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
<th>{% trans "Master" %}</th>
|
||||
<th>{% trans "Priority" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for vc_member in vc_members %}
|
||||
<tr{% if vc_member == object %} class="info"{% endif %}>
|
||||
<td>
|
||||
{{ vc_member|linkify }}
|
||||
</td>
|
||||
<td>
|
||||
{% badge vc_member.vc_position show_empty=True %}
|
||||
</td>
|
||||
<td>
|
||||
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ vc_member.vc_priority|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr{% if vc_member == object %} class="table-primary"{% endif %}>
|
||||
<td>{{ vc_member|linkify }}</td>
|
||||
<td>{% badge vc_member.vc_position show_empty=True %}</td>
|
||||
<td>
|
||||
{% if object.virtual_chassis.master == vc_member %}
|
||||
{% checkmark True %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ vc_member.vc_priority|placeholder }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -221,6 +223,11 @@
|
||||
<td>
|
||||
{% if object.oob_ip %}
|
||||
<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" %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
|
@ -24,7 +24,7 @@
|
||||
<table class="table table-hover">
|
||||
{% for test, data in tests.items %}
|
||||
<tr>
|
||||
<td class="font-monospace"><a href="#{{ test }}">{{ test }}</a></td>
|
||||
<td class="font-monospace">{{ test }}</td>
|
||||
<td class="text-end report-stats">
|
||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
||||
|
@ -48,7 +48,7 @@ Context:
|
||||
<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">
|
||||
{% 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>
|
||||
</li>
|
||||
{% if filter_form %}
|
||||
|
@ -13,14 +13,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-print-none">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
|
||||
{% if filter_form %}
|
||||
<div class="col-auto d-print-none">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
|
||||
</div>
|
||||
{{ filter_form.filter_id }}
|
||||
</div>
|
||||
{{ filter_form.filter_id }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
{% if request.user.is_authenticated and table_modal %}
|
||||
|
@ -53,10 +53,6 @@
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<div class="field-group my-5">
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
@ -65,4 +61,8 @@
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -78,7 +78,8 @@
|
||||
{% for backend in auth_backends %}
|
||||
<div class="col">
|
||||
<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 }}
|
||||
</a>
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
|
||||
child_model = ContactAssignment
|
||||
table = tables.ContactAssignmentTable
|
||||
filterset = filtersets.ContactAssignmentFilterSet
|
||||
filterset_form = forms.ContactAssignmentFilterForm
|
||||
template_name = 'tenancy/object_contacts.html'
|
||||
tab = ViewTab(
|
||||
label=_('Contacts'),
|
||||
@ -364,7 +365,7 @@ class ContactAssignmentEditView(generic.ObjectEditView):
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
return {
|
||||
'content_type': request.GET.get('content_type'),
|
||||
'object_type': request.GET.get('object_type'),
|
||||
'object_id': request.GET.get('object_id'),
|
||||
}
|
||||
|
||||
|
15295
netbox/translations/cs/LC_MESSAGES/django.po
Normal file
15295
netbox/translations/cs/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
15310
netbox/translations/da/LC_MESSAGES/django.po
Normal file
15310
netbox/translations/da/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
15483
netbox/translations/it/LC_MESSAGES/django.po
Normal file
15483
netbox/translations/it/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
15480
netbox/translations/nl/LC_MESSAGES/django.po
Normal file
15480
netbox/translations/nl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
15393
netbox/translations/pl/LC_MESSAGES/django.po
Normal file
15393
netbox/translations/pl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
@ -61,6 +61,14 @@ class UserSerializer(ValidatedModelSerializer):
|
||||
'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):
|
||||
"""
|
||||
Extract the password from validated data and set it separately to ensure proper hash generation.
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
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.core.exceptions import FieldError
|
||||
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']:
|
||||
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):
|
||||
users = DynamicModelMultipleChoiceField(
|
||||
|
@ -1,4 +1,5 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import ObjectType
|
||||
@ -93,6 +94,31 @@ class UserTest(APIViewTestCases.APIViewTestCase):
|
||||
user.refresh_from_db()
|
||||
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):
|
||||
model = Group
|
||||
|
@ -1,6 +1,8 @@
|
||||
from django.test import override_settings
|
||||
|
||||
from core.models import ObjectType
|
||||
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(
|
||||
@ -58,6 +60,34 @@ class UserTestCase(
|
||||
'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(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
|
@ -2,6 +2,7 @@ from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from utilities.ordering import naturalize
|
||||
@ -26,6 +27,7 @@ class ColorField(models.CharField):
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
kwargs['widget'] = ColorSelect
|
||||
kwargs['help_text'] = mark_safe(_('RGB color in hexadecimal. Example: ') + '<code>00ff00</code>')
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
|
@ -55,7 +55,7 @@ def prepare_cloned_fields(instance):
|
||||
for key, value in attrs.items():
|
||||
if type(value) in (list, tuple):
|
||||
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))
|
||||
else:
|
||||
params.append((key, ''))
|
||||
|
@ -19,10 +19,9 @@ from .base import ModelTestCase
|
||||
from .utils import disable_warnings
|
||||
|
||||
from ipam.graphql.types import IPAddressFamilyType
|
||||
from strawberry.field import StrawberryField
|
||||
from strawberry.lazy_type import LazyType
|
||||
from strawberry.type import StrawberryList, StrawberryOptional
|
||||
from strawberry.union import StrawberryUnion
|
||||
from strawberry.types.lazy_type import LazyType
|
||||
from strawberry.types.base import StrawberryList, StrawberryOptional
|
||||
from strawberry.types.union import StrawberryUnion
|
||||
|
||||
__all__ = (
|
||||
'APITestCase',
|
||||
|
@ -179,8 +179,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
||||
'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
|
||||
})
|
||||
|
||||
# Validate site for cluster & device
|
||||
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||
# Validate site for cluster & VM
|
||||
if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
|
||||
raise ValidationError({
|
||||
'cluster': _(
|
||||
'The selected cluster ({cluster}) is not assigned to this site ({site}).'
|
||||
|
@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from dcim.filtersets import DeviceFilterSet
|
||||
from dcim.forms import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
from dcim.tables import DeviceTable
|
||||
from extras.views import ObjectConfigContextView
|
||||
@ -173,6 +174,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
|
||||
child_model = VirtualMachine
|
||||
table = tables.VirtualMachineTable
|
||||
filterset = filtersets.VirtualMachineFilterSet
|
||||
filterset_form = forms.VirtualMachineFilterForm
|
||||
tab = ViewTab(
|
||||
label=_('Virtual Machines'),
|
||||
badge=lambda obj: obj.virtual_machines.count(),
|
||||
@ -190,6 +192,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
|
||||
child_model = Device
|
||||
table = DeviceTable
|
||||
filterset = DeviceFilterSet
|
||||
filterset_form = DeviceFilterForm
|
||||
template_name = 'virtualization/cluster/devices.html'
|
||||
actions = {
|
||||
'add': {'add'},
|
||||
@ -350,6 +353,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
|
||||
child_model = VMInterface
|
||||
table = tables.VirtualMachineVMInterfaceTable
|
||||
filterset = filtersets.VMInterfaceFilterSet
|
||||
filterset_form = forms.VMInterfaceFilterForm
|
||||
template_name = 'virtualization/virtualmachine/interfaces.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
@ -375,6 +379,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
|
||||
child_model = VirtualDisk
|
||||
table = tables.VirtualMachineVirtualDiskTable
|
||||
filterset = filtersets.VirtualDiskFilterSet
|
||||
filterset_form = forms.VirtualDiskFilterForm
|
||||
template_name = 'virtualization/virtualmachine/virtual_disks.html'
|
||||
tab = ViewTab(
|
||||
label=_('Virtual Disks'),
|
||||
|
@ -25,7 +25,8 @@ class IKEProposalSerializer(NetBoxModelSerializer):
|
||||
choices=EncryptionAlgorithmChoices
|
||||
)
|
||||
authentication_algorithm = ChoiceField(
|
||||
choices=AuthenticationAlgorithmChoices
|
||||
choices=AuthenticationAlgorithmChoices,
|
||||
required=False
|
||||
)
|
||||
group = ChoiceField(
|
||||
choices=DHGroupChoices
|
||||
@ -49,7 +50,8 @@ class IKEPolicySerializer(NetBoxModelSerializer):
|
||||
choices=IKEVersionChoices
|
||||
)
|
||||
mode = ChoiceField(
|
||||
choices=IKEModeChoices
|
||||
choices=IKEModeChoices,
|
||||
required=False
|
||||
)
|
||||
proposals = SerializedPKRelatedField(
|
||||
queryset=IKEProposal.objects.all(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
Django==5.0.6
|
||||
Django==5.0.7
|
||||
django-cors-headers==4.4.0
|
||||
django-debug-toolbar==4.3.0
|
||||
django-filter==24.2
|
||||
@ -12,26 +12,26 @@ django-rich==1.9.0
|
||||
django-rq==2.10.2
|
||||
django-taggit==5.0.1
|
||||
django-tables2==2.7.0
|
||||
django-timezone-field==6.1.0
|
||||
django-timezone-field==7.0
|
||||
djangorestframework==3.15.2
|
||||
drf-spectacular==0.27.2
|
||||
drf-spectacular-sidecar==2024.6.1
|
||||
drf-spectacular-sidecar==2024.7.1
|
||||
feedparser==6.0.11
|
||||
gunicorn==22.0.0
|
||||
Jinja2==3.1.4
|
||||
Markdown==3.6
|
||||
mkdocs-material==9.5.27
|
||||
mkdocstrings[python-legacy]==0.25.1
|
||||
mkdocs-material==9.5.30
|
||||
mkdocstrings[python-legacy]==0.25.2
|
||||
netaddr==1.3.0
|
||||
nh3==0.2.17
|
||||
Pillow==10.3.0
|
||||
psycopg[c,pool]==3.1.19
|
||||
nh3==0.2.18
|
||||
Pillow==10.4.0
|
||||
psycopg[c,pool]==3.2.1
|
||||
PyYAML==6.0.1
|
||||
requests==2.32.3
|
||||
social-auth-app-django==5.4.1
|
||||
social-auth-app-django==5.4.2
|
||||
social-auth-core==4.5.4
|
||||
strawberry-graphql==0.235.0
|
||||
strawberry-graphql-django==0.44.2
|
||||
strawberry-graphql==0.237.2
|
||||
strawberry-graphql-django==0.47.1
|
||||
svgwrite==1.4.3
|
||||
tablib==3.6.1
|
||||
tzdata==2024.1
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user