mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-17 09:45:57 +00:00
Merge branch 'feature' of https://github.com/netbox-community/netbox into feature-ip-prefix-link
This commit is contained in:
commit
90d277610c
@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.5
|
||||
placeholder: v4.4.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.5
|
||||
placeholder: v4.4.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
3
.github/codeql/codeql-config.yml
vendored
Normal file
3
.github/codeql/codeql-config.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
paths-ignore:
|
||||
# Ignore compiled JS
|
||||
- netbox/project-static/dist
|
||||
42
.github/workflows/codeql.yml
vendored
Normal file
42
.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "feature" ]
|
||||
pull_request:
|
||||
branches: [ "main", "feature" ]
|
||||
schedule:
|
||||
- cron: '38 16 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@ -106,7 +106,11 @@ mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||
mkdocstrings[python]
|
||||
mkdocstrings
|
||||
|
||||
# Python handler for mkdocstrings
|
||||
# https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md
|
||||
mkdocstrings-python
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
@ -135,7 +139,8 @@ requests
|
||||
|
||||
# rq
|
||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||
rq
|
||||
# RQ v2.5 drops support for Redis < 5.0
|
||||
rq==2.4.1
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||
|
||||
@ -95,6 +95,7 @@
|
||||
"iec-60320-c8",
|
||||
"iec-60320-c14",
|
||||
"iec-60320-c16",
|
||||
"iec-60320-c18",
|
||||
"iec-60320-c20",
|
||||
"iec-60320-c22",
|
||||
"iec-60309-p-n-e-4h",
|
||||
@ -209,6 +210,7 @@
|
||||
"iec-60320-c7",
|
||||
"iec-60320-c13",
|
||||
"iec-60320-c15",
|
||||
"iec-60320-c17",
|
||||
"iec-60320-c19",
|
||||
"iec-60320-c21",
|
||||
"iec-60309-p-n-e-4h",
|
||||
@ -474,6 +476,13 @@
|
||||
"passive-48v-2pair",
|
||||
"passive-48v-4pair"
|
||||
]
|
||||
},
|
||||
"rf_role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ap",
|
||||
"station"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
### Enabling Error Reporting
|
||||
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
|
||||
@ -22,24 +22,9 @@ Stores registration made using `netbox.denormalized.register()`. For each model,
|
||||
|
||||
### `model_features`
|
||||
|
||||
A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example:
|
||||
A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.
|
||||
|
||||
```python
|
||||
{
|
||||
'custom_fields': {
|
||||
'circuits': ['provider', 'circuit'],
|
||||
'dcim': ['site', 'rack', 'devicetype', ...],
|
||||
...
|
||||
},
|
||||
'event_rules': {
|
||||
'extras': ['configcontext', 'tag', ...],
|
||||
'dcim': ['site', 'rack', 'devicetype', ...],
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Supported model features are listed in the [features matrix](./models.md#features-matrix).
|
||||
Core model features are listed in the [features matrix](./models.md#features-matrix).
|
||||
|
||||
### `models`
|
||||
|
||||
|
||||
@ -10,19 +10,26 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
||||
|
||||
Depending on its classification, each NetBox model may support various features which enhance its operation. Each feature is enabled by inheriting from its designated mixin class, and some features also make use of the [application registry](./application-registry.md#model_features).
|
||||
|
||||
| Feature | Feature Mixin | Registry Key | Description |
|
||||
|------------------------------------------------------------|-------------------------|--------------------|-----------------------------------------------------------------------------------------|
|
||||
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | - | Changes to these objects are automatically recorded in the change log |
|
||||
| Cloning | `CloningMixin` | - | Provides the `clone()` method to prepare a copy |
|
||||
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
|
||||
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
|
||||
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
|
||||
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
|
||||
| [Job results](../features/background-jobs.md) | `JobsMixin` | `jobs` | Background jobs can be scheduled for these models |
|
||||
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
|
||||
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
|
||||
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
|
||||
| [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events |
|
||||
| Feature | Feature Mixin | Registry Key | Description |
|
||||
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
|
||||
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
||||
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
|
||||
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
|
||||
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
|
||||
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
|
||||
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
|
||||
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
|
||||
| [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events |
|
||||
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
|
||||
| [Image attachments](../models/extras/imageattachment.md) | `ImageAttachmentsMixin` | `image_attachments` | Image uploads can be attached to these models |
|
||||
| [Jobs](../features/background-jobs.md) | `JobsMixin` | `jobs` | Background jobs can be scheduled for these models |
|
||||
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
|
||||
| [Notifications](../features/notifications.md) | `NotificationsMixin` | `notifications` | These models support user notifications |
|
||||
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
|
||||
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
|
||||
|
||||
!!! note
|
||||
The above listed features are supported natively by NetBox. Beginning with NetBox v4.4.0, plugins can register their own model features as well.
|
||||
|
||||
## Models Index
|
||||
|
||||
|
||||
@ -31,28 +31,14 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto
|
||||
|
||||
Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
|
||||
|
||||
### Update the Dependency Requirements Matrix
|
||||
|
||||
For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
|
||||
|
||||
1. Add a new row with the supported dependency versions.
|
||||
2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
|
||||
3. Bold any version changes for clarity.
|
||||
|
||||
**Example Update:**
|
||||
|
||||
```markdown
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
||||
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
```
|
||||
|
||||
### Update System Requirements
|
||||
|
||||
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
|
||||
|
||||
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version.
|
||||
* Update the minimum versions for each dependency.
|
||||
* Add a new row to the release history table. Bold any version changes for clarity.
|
||||
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
|
||||
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
|
||||
|
||||
|
||||
@ -25,42 +25,21 @@ NetBox requires the following dependencies:
|
||||
|
||||
### Version History
|
||||
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
||||
| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
|
||||
| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
|
||||
| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
|
||||
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
|
||||
| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
|
||||
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
|
||||
| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
|
||||
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
|
||||
| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
|
||||
| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
|
||||
| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
|
||||
| 2.11 | 3.6 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
|
||||
| 2.10 | 3.6 | 3.8 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
|
||||
| 2.9 | 3.6 | 3.8 | 9.5 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
|
||||
| 2.8 | 3.6 | 3.8 | 9.5 | 3.4 | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
|
||||
| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
|
||||
| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
|
||||
| 2.5 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
|
||||
| 2.4 | 3.4 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
|
||||
| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
|
||||
| 2.2 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
|
||||
| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
|
||||
| 2.0 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
|
||||
| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
|
||||
| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
|
||||
| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
|
||||
| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
|
||||
| 1.5 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
|
||||
| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
|
||||
| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
|
||||
| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
|
||||
| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
|
||||
| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
|
||||
| 4.4 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
|
||||
| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
|
||||
| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
|
||||
| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
|
||||
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
|
||||
| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
|
||||
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
|
||||
| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
|
||||
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
|
||||
| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
|
||||
| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
|
||||
| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
|
||||
|
||||
## 3. Install the Latest Release
|
||||
|
||||
|
||||
@ -2,12 +2,20 @@
|
||||
|
||||
A platform defines the type of software running on a [device](./device.md) or [virtual machine](../virtualization/virtualmachine.md). This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15.
|
||||
|
||||
Platforms may be nested under parents to form a hierarchy. For example, platforms named "Debian" and "RHEL" might both be created under a generic "Linux" parent.
|
||||
|
||||
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
||||
|
||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||
The assignment of platforms to devices and virtual machines is optional.
|
||||
|
||||
## Fields
|
||||
|
||||
## Parent
|
||||
|
||||
!!! "This field was introduced in NetBox v4.4."
|
||||
|
||||
The parent platform class to which this platform belongs (optional).
|
||||
|
||||
### Name
|
||||
|
||||
A human-friendly name for the platform. Must be unique per manufacturer.
|
||||
|
||||
@ -12,6 +12,13 @@ The [rack](./rack.md) being reserved.
|
||||
|
||||
The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7.
|
||||
|
||||
### Status
|
||||
|
||||
The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.)
|
||||
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `RackReservation.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### User
|
||||
|
||||
The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.
|
||||
|
||||
@ -14,6 +14,10 @@ A unique human-friendly name.
|
||||
|
||||
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
|
||||
|
||||
### Profile
|
||||
|
||||
The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data.
|
||||
|
||||
### Data
|
||||
|
||||
The context data expressed in JSON format.
|
||||
|
||||
33
docs/models/extras/configcontextprofile.md
Normal file
33
docs/models/extras/configcontextprofile.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Config Context Profiles
|
||||
|
||||
Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The later is achieved by defining a [JSON schema](https://json-schema.org/) to which all config context with this profile assigned must comply.
|
||||
|
||||
For example, the following schema defines two keys, `size` and `priority`, of which the former is required:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"default": "medium"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"size"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name.
|
||||
|
||||
### Schema
|
||||
|
||||
The JSON schema to be enforced for all assigned config contexts (optional).
|
||||
@ -24,20 +24,7 @@ Every model includes by default a numeric primary key. This value is generated a
|
||||
|
||||
## Enabling NetBox Features
|
||||
|
||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
||||
|
||||
* Bookmarks
|
||||
* Change logging
|
||||
* Cloning
|
||||
* Custom fields
|
||||
* Custom links
|
||||
* Custom validation
|
||||
* Export templates
|
||||
* Journaling
|
||||
* Tags
|
||||
* Webhooks
|
||||
|
||||
This class performs two crucial functions:
|
||||
Plugin models can leverage certain [model features](../../development/models.md#features-matrix) (such as tags, custom fields, event rules, etc.) by inheriting from NetBox's `NetBoxModel` class. This class performs two crucial functions:
|
||||
|
||||
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
|
||||
2. Register the model with NetBox as utilizing these features
|
||||
@ -135,6 +122,27 @@ For more information about database migrations, see the [Django documentation](h
|
||||
|
||||
::: netbox.models.features.TagsMixin
|
||||
|
||||
## Custom Model Features
|
||||
|
||||
In addition to utilizing the model features provided natively by NetBox (listed above), plugins can register their own model features. This is done using the `register_model_feature()` function from `netbox.utils`. This function takes two arguments: a feature name, and a callable which accepts a model class. The callable must return a boolean value indicting whether the given model supports the named feature.
|
||||
|
||||
This function can be used as a decorator:
|
||||
|
||||
```python
|
||||
@register_model_feature('foo')
|
||||
def supports_foo(model):
|
||||
# Your logic here
|
||||
```
|
||||
|
||||
Or it can be called directly:
|
||||
|
||||
```python
|
||||
register_model_feature('foo', supports_foo)
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Consider performing feature registration inside your PluginConfig's `ready()` method.
|
||||
|
||||
## Choice Sets
|
||||
|
||||
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)
|
||||
|
||||
@ -51,6 +51,10 @@ This will automatically apply any user-specific preferences for the table. (If u
|
||||
|
||||
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
|
||||
|
||||
::: netbox.tables.ArrayColumn
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.BooleanColumn
|
||||
options:
|
||||
members: false
|
||||
|
||||
@ -89,7 +89,7 @@ The following condition will evaluate as true:
|
||||
```
|
||||
|
||||
!!! note "Evaluating static choice fields"
|
||||
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). be sure to specify on which of these you want to match.
|
||||
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). Be sure to specify on which of these you want to match.
|
||||
|
||||
## Condition Sets
|
||||
|
||||
|
||||
@ -13,8 +13,9 @@ This page contains a history of all major and minor releases since NetBox v2.0.
|
||||
#### [Version 4.4](./version-4.4.md) (September 2025)
|
||||
|
||||
* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
|
||||
* Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
|
||||
* Logging Mechanism for Background Jobs ([#19816](https://github.com/netbox-community/netbox/issues/19816))
|
||||
* Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713))
|
||||
* Config Context Data Validation ([#19377](https://github.com/netbox-community/netbox/issues/19377))
|
||||
|
||||
#### [Version 4.3](./version-4.3.md) (May 2025)
|
||||
|
||||
|
||||
@ -1,5 +1,60 @@
|
||||
# NetBox v4.3
|
||||
|
||||
## v4.3.7 (2025-08-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#18147](https://github.com/netbox-community/netbox/issues/18147) - Add device & VM interface counts under related objects for VRFs
|
||||
* [#19990](https://github.com/netbox-community/netbox/issues/19990) - Button to add a missing prerequisite now includes a return URL
|
||||
* [#20122](https://github.com/netbox-community/netbox/issues/20122) - Improve color contrast of highlighted data under changelog diff view
|
||||
* [#20131](https://github.com/netbox-community/netbox/issues/20131) - Add object selector for interface to the MAC address edit form
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18916](https://github.com/netbox-community/netbox/issues/18916) - Fix dynamic dropdown selection styling for required fields when no selection is made
|
||||
* [#19645](https://github.com/netbox-community/netbox/issues/19645) - Fix interface selection when adding a cable for a virtual chassis master
|
||||
* [#19669](https://github.com/netbox-community/netbox/issues/19669) - Restore token authentication support for fetching media assets
|
||||
* [#19970](https://github.com/netbox-community/netbox/issues/19970) - Device role child device counts should be cumulative
|
||||
* [#20012](https://github.com/netbox-community/netbox/issues/20012) - Fix support for `empty` filter lookup on custom fields
|
||||
* [#20043](https://github.com/netbox-community/netbox/issues/20043) - Fix page styling when rack elevations are embedded
|
||||
* [#20098](https://github.com/netbox-community/netbox/issues/20098) - Fix `AttributeError` exception when assigning tags during bulk import
|
||||
* [#20120](https://github.com/netbox-community/netbox/issues/20120) - Fix REST API serialization of jobs under `/api/core/background-tasks/`
|
||||
* [#20157](https://github.com/netbox-community/netbox/issues/20157) - Fix `IntegrityError` exception when a duplicate notification is triggered
|
||||
* [#20164](https://github.com/netbox-community/netbox/issues/20164) - Fix `ValueError` exception when attempting to add power outlets to devices in bulk
|
||||
|
||||
---
|
||||
|
||||
## v4.3.6 (2025-08-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning
|
||||
* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables
|
||||
* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget
|
||||
* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links
|
||||
* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices
|
||||
* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions
|
||||
* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices
|
||||
* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group
|
||||
* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges
|
||||
* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses
|
||||
* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form
|
||||
* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags
|
||||
* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget
|
||||
* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type
|
||||
* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views
|
||||
* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data
|
||||
* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time
|
||||
* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others
|
||||
* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks
|
||||
* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation
|
||||
|
||||
---
|
||||
|
||||
## v4.3.5 (2025-07-29)
|
||||
|
||||
### Enhancements
|
||||
@ -16,6 +71,11 @@
|
||||
* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
|
||||
* [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
|
||||
|
||||
!!! note "Plugin Developer Advisory"
|
||||
The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility.
|
||||
|
||||
---
|
||||
|
||||
## v4.3.4 (2025-07-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# NetBox v4.4
|
||||
|
||||
## v4.4.0 (FUTURE)
|
||||
## v4.4.0 (2025-09-02)
|
||||
|
||||
### New Features
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
Most bulk operations, such as the import, modification, or deletion of objects can now be executed as a background job. This frees the user to continue working in NetBox while the bulk operation is processed. Once completed, the user will be notified of the job's result.
|
||||
|
||||
#### Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
|
||||
#### Logging Mechanism for Background Jobs ([#19816](https://github.com/netbox-community/netbox/issues/19816))
|
||||
|
||||
A dedicated logging mechanism has been implemented for background jobs. Jobs can now easily record log messages by calling e.g. `self.logger.info("Log message")` under the `run()` method. These messages are displayed along with the job's resulting data. Supported log levels include `DEBUG`, `INFO`, `WARNING`, and `ERROR`.
|
||||
|
||||
@ -16,25 +16,41 @@ A dedicated logging mechanism has been implemented for background jobs. Jobs can
|
||||
|
||||
When creating, editing, or deleting objects in NetBox, users now have the option of providing a short message explaining the change. This message will be recorded on the resulting changelog records for all affected objects.
|
||||
|
||||
#### Config Context Data Validation ([#19377](https://github.com/netbox-community/netbox/issues/19377))
|
||||
|
||||
A new ConfigContextProfile model has been introduced to support JSON schema validation for config context data. If a validation schema has been defined for a profile, all config contexts assigned to it will have their data validated against the schema whenever a change is made. (The assignment of a config context to a profile is optional.)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names
|
||||
* [#18204](https://github.com/netbox-community/netbox/issues/18204) - Improved layout of the image attachments view & tables
|
||||
* [#18528](https://github.com/netbox-community/netbox/issues/18528) - Introduced the `HOSTNAME` configuration parameter to override the system hostname reported by NetBox
|
||||
* [#18984](https://github.com/netbox-community/netbox/issues/18984) - Added a `status` field for rack reservations
|
||||
* [#18990](https://github.com/netbox-community/netbox/issues/18990) - Image attachments now include an optional description field
|
||||
* [#19134](https://github.com/netbox-community/netbox/issues/19134) - Interface transmit power now accepts negative values
|
||||
* [#19231](https://github.com/netbox-community/netbox/issues/19231) - Bulk renaming support has been implemented in the UI for most object types
|
||||
* [#19591](https://github.com/netbox-community/netbox/issues/19591) - Thumbnails for all images attached to an object are now displayed under a dedicated tab
|
||||
* [#19722](https://github.com/netbox-community/netbox/issues/19722) - The REST API endpoint for object types has been extended to include additional details
|
||||
* [#19739](https://github.com/netbox-community/netbox/issues/19739) - Introduced a user preference for CSV delimiter
|
||||
* [#19740](https://github.com/netbox-community/netbox/issues/19740) - Enable nesting of platforms within a hierarchy for improved organization
|
||||
* [#19773](https://github.com/netbox-community/netbox/issues/19773) - Extend the system UI view with additional information
|
||||
* [#19893](https://github.com/netbox-community/netbox/issues/19893) - The `/api/status/` REST API endpoint now includes the system hostname
|
||||
* [#19920](https://github.com/netbox-community/netbox/issues/19920) - Contacts can now be assigned to ASNs
|
||||
* [#19945](https://github.com/netbox-community/netbox/issues/19945) - Introduce a new custom script variable to represent decimal values
|
||||
* [#19965](https://github.com/netbox-community/netbox/issues/19965) - Add REST & GraphQL API request counters to the Prometheus metrics exporter
|
||||
* [#20029](https://github.com/netbox-community/netbox/issues/20029) - Include complete representation of object type in webhook payload data
|
||||
|
||||
### Plugins
|
||||
|
||||
* [#18006](https://github.com/netbox-community/netbox/issues/18006) - A Javascript is now triggered when UI is toggled between light and dark mode
|
||||
* [#19735](https://github.com/netbox-community/netbox/issues/19735) - Custom individual and bulk operations can now be registered under individual views using `ObjectAction`
|
||||
* [#20003](https://github.com/netbox-community/netbox/issues/20003) - Enable registration of callbacks to provide supplementary webhook payload data
|
||||
* [#20115](https://github.com/netbox-community/netbox/issues/20115) - Support the use of ArrayColumn for plugin tables
|
||||
* [#20129](https://github.com/netbox-community/netbox/issues/20129) - Enable plugins to register custom model features
|
||||
|
||||
### Deprecations
|
||||
|
||||
* [#19738](https://github.com/netbox-community/netbox/issues/19738) - The direct assignment of VLANs to sites is now discouraged in favor of VLAN groups
|
||||
|
||||
### Other Changes
|
||||
|
||||
@ -42,10 +58,11 @@ When creating, editing, or deleting objects in NetBox, users now have the option
|
||||
* [#18588](https://github.com/netbox-community/netbox/issues/18588) - The "Service" model has been renamed to "Application Service" for clarity (UI change only)
|
||||
* [#19829](https://github.com/netbox-community/netbox/issues/19829) - The REST API endpoint for object types is now available under `/api/core/`
|
||||
* [#19924](https://github.com/netbox-community/netbox/issues/19924) - ObjectTypes are now tracked as concrete objects in the database (alongside ContentTypes)
|
||||
* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbhshell` management command
|
||||
* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbshell` management command
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* All object types which support change logging now support the inclusion of a `changelog_message` for write operations. If provided, this message will be attached to the changelog record resulting from the change (if successful).
|
||||
* The `/api/status/` endpoint now includes the system hostname.
|
||||
* The `/api/extras/object-types/` endpoint is now available at `/api/core/object-types/`. (The original endpoint will be removed in NetBox v4.5.)
|
||||
* The `/api/core/object-types/` endpoint has been expanded to include the following read-only fields:
|
||||
@ -55,7 +72,16 @@ When creating, editing, or deleting objects in NetBox, users now have the option
|
||||
* `is_plugin_model`
|
||||
* `rest_api_endpoint`
|
||||
* `description`
|
||||
* Introduced the `/api/extras/config-context-profiles/` endpoint
|
||||
* core.Job
|
||||
* Added the read-only `log_entries` array field
|
||||
* dcim.Interface
|
||||
* The `tx_power` field now accepts negative values
|
||||
* dcim.RackReservation
|
||||
* Added the `status` choice field
|
||||
* dcim.Platform
|
||||
* Add an optional `parent` foreign key field to support nesting
|
||||
* extras.ConfigContext
|
||||
* Added the optional `profile` foreign key field
|
||||
* extras.ImageAttachment
|
||||
* Added an optional `description` field
|
||||
|
||||
@ -30,6 +30,8 @@ plugins:
|
||||
python:
|
||||
paths: ["netbox"]
|
||||
options:
|
||||
docstring_options:
|
||||
warn_missing_types: false
|
||||
heading_level: 3
|
||||
members_order: source
|
||||
show_root_heading: true
|
||||
@ -226,6 +228,7 @@ nav:
|
||||
- Extras:
|
||||
- Bookmark: 'models/extras/bookmark.md'
|
||||
- ConfigContext: 'models/extras/configcontext.md'
|
||||
- ConfigContextProfile: 'models/extras/configcontextprofile.md'
|
||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||
- CustomField: 'models/extras/customfield.md'
|
||||
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
|
||||
|
||||
@ -35,11 +35,7 @@ urlpatterns = [
|
||||
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
|
||||
|
||||
# Virtual circuits
|
||||
path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
|
||||
path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
|
||||
path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'),
|
||||
path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
|
||||
path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
|
||||
path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))),
|
||||
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
|
||||
|
||||
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
|
||||
|
||||
@ -687,6 +687,7 @@ class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
# Virtual circuits
|
||||
#
|
||||
|
||||
@register_model_view(VirtualCircuit, 'list', path='', detail=False)
|
||||
class VirtualCircuitListView(generic.ObjectListView):
|
||||
queryset = VirtualCircuit.objects.annotate(
|
||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||
@ -701,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'add', detail=False)
|
||||
@register_model_view(VirtualCircuit, 'edit')
|
||||
class VirtualCircuitEditView(generic.ObjectEditView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
@ -712,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False)
|
||||
class VirtualCircuitBulkImportView(generic.BulkImportView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
model_form = forms.VirtualCircuitImportForm
|
||||
@ -727,6 +730,7 @@ class VirtualCircuitBulkImportView(generic.BulkImportView):
|
||||
return data
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False)
|
||||
class VirtualCircuitBulkEditView(generic.BulkEditView):
|
||||
queryset = VirtualCircuit.objects.annotate(
|
||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||
@ -737,11 +741,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
|
||||
class VirtualCircuitulkRenameView(generic.BulkRenameView):
|
||||
class VirtualCircuitBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
field_name = 'cid'
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)
|
||||
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualCircuit.objects.annotate(
|
||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import inspect
|
||||
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.urls import NoReverseMatch
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.models import ObjectType
|
||||
from netbox.api.serializers import BaseModelSerializer
|
||||
from utilities.views import get_viewname
|
||||
from utilities.views import get_action_url
|
||||
|
||||
__all__ = (
|
||||
'ObjectTypeSerializer',
|
||||
@ -15,7 +15,7 @@ __all__ = (
|
||||
|
||||
|
||||
class ObjectTypeSerializer(BaseModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
|
||||
url = serializers.HyperlinkedIdentityField(view_name='core-api:objecttype-detail')
|
||||
app_name = serializers.CharField(source='app_verbose_name', read_only=True)
|
||||
model_name = serializers.CharField(source='model_verbose_name', read_only=True)
|
||||
model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
|
||||
@ -26,19 +26,19 @@ class ObjectTypeSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = ObjectType
|
||||
fields = [
|
||||
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
|
||||
'is_plugin_model', 'rest_api_endpoint', 'description',
|
||||
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', 'public',
|
||||
'features', 'is_plugin_model', 'rest_api_endpoint', 'description',
|
||||
]
|
||||
read_only_fields = ['public', 'features']
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_rest_api_endpoint(self, obj):
|
||||
if not (model := obj.model_class()):
|
||||
return
|
||||
if viewname := get_viewname(model, action='list', rest_api=True):
|
||||
try:
|
||||
return reverse(viewname)
|
||||
except NoReverseMatch:
|
||||
return
|
||||
try:
|
||||
return get_action_url(model, action='list', rest_api=True)
|
||||
except NoReverseMatch:
|
||||
return
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_description(self, obj):
|
||||
|
||||
@ -18,8 +18,8 @@ class BackgroundTaskSerializer(serializers.Serializer):
|
||||
description = serializers.CharField()
|
||||
origin = serializers.CharField()
|
||||
func_name = serializers.CharField()
|
||||
args = serializers.ListField(child=serializers.CharField())
|
||||
kwargs = serializers.DictField()
|
||||
args = serializers.SerializerMethodField()
|
||||
kwargs = serializers.SerializerMethodField()
|
||||
result = serializers.CharField()
|
||||
timeout = serializers.IntegerField()
|
||||
result_ttl = serializers.IntegerField()
|
||||
@ -42,6 +42,16 @@ class BackgroundTaskSerializer(serializers.Serializer):
|
||||
is_scheduled = serializers.BooleanField()
|
||||
is_stopped = serializers.BooleanField()
|
||||
|
||||
def get_args(self, obj) -> list:
|
||||
return [
|
||||
str(arg) for arg in obj.args
|
||||
]
|
||||
|
||||
def get_kwargs(self, obj) -> dict:
|
||||
return {
|
||||
key: str(value) for key, value in obj.kwargs.items()
|
||||
}
|
||||
|
||||
def get_position(self, obj) -> int:
|
||||
return obj.get_position()
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ router.APIRootView = views.CoreRootView
|
||||
router.register('data-sources', views.DataSourceViewSet)
|
||||
router.register('data-files', views.DataFileViewSet)
|
||||
router.register('jobs', views.JobViewSet)
|
||||
router.register('object-changes', views.ObjectChangeViewSet)
|
||||
router.register('object-changes', views.ObjectChangeViewSet, basename='objectchange')
|
||||
router.register('object-types', views.ObjectTypeViewSet)
|
||||
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
|
||||
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
|
||||
|
||||
@ -78,10 +78,12 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
Retrieve a list of recent changes.
|
||||
"""
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ObjectChange.objects.valid_models()
|
||||
serializer_class = serializers.ObjectChangeSerializer
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
def get_queryset(self):
|
||||
return ObjectChange.objects.valid_models()
|
||||
|
||||
|
||||
class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
|
||||
@ -134,15 +134,18 @@ class JobFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ObjectTypeFilterSet(django_filters.FilterSet):
|
||||
class ObjectTypeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
features = django_filters.CharFilter(
|
||||
method='filter_features'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectType
|
||||
fields = ('id', 'app_label', 'model')
|
||||
fields = ('id', 'app_label', 'model', 'public')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@ -152,6 +155,9 @@ class ObjectTypeFilterSet(django_filters.FilterSet):
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
def filter_features(self, queryset, name, value):
|
||||
return queryset.filter(features__icontains=value)
|
||||
|
||||
|
||||
class ObjectChangeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from core.models import ObjectChange
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.types import DataFileType, DataSourceType
|
||||
from netbox.core.graphql.types import ObjectChangeType
|
||||
|
||||
__all__ = (
|
||||
'ChangelogMixin',
|
||||
'SyncedDataMixin',
|
||||
)
|
||||
|
||||
|
||||
@ -25,3 +27,9 @@ class ChangelogMixin:
|
||||
changed_object_id=self.pk
|
||||
)
|
||||
return object_changes.restrict(info.context.request.user, 'view')
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class SyncedDataMixin:
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from importlib import import_module
|
||||
@ -17,8 +16,6 @@ from utilities.proxy import resolve_proxies
|
||||
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
||||
from .models import DataSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncDataSourceJob(JobRunner):
|
||||
"""
|
||||
@ -69,7 +66,11 @@ class SystemHousekeepingJob(JobRunner):
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
# Skip if running in development or test mode
|
||||
if settings.DEBUG or 'test' in sys.argv:
|
||||
if settings.DEBUG:
|
||||
self.logger.warning("Aborting execution: Debug is enabled")
|
||||
return
|
||||
if 'test' in sys.argv:
|
||||
self.logger.warning("Aborting execution: Tests are running")
|
||||
return
|
||||
|
||||
self.send_census_report()
|
||||
@ -78,17 +79,16 @@ class SystemHousekeepingJob(JobRunner):
|
||||
self.delete_expired_jobs()
|
||||
self.check_for_new_releases()
|
||||
|
||||
@staticmethod
|
||||
def send_census_report():
|
||||
def send_census_report(self):
|
||||
"""
|
||||
Send a census report (if enabled).
|
||||
"""
|
||||
logging.info("Reporting census data...")
|
||||
self.logger.info("Reporting census data...")
|
||||
if settings.ISOLATED_DEPLOYMENT:
|
||||
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||
self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||
return
|
||||
if not settings.CENSUS_REPORTING_ENABLED:
|
||||
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
|
||||
self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
|
||||
return
|
||||
|
||||
census_data = {
|
||||
@ -106,73 +106,71 @@ class SystemHousekeepingJob(JobRunner):
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def clear_expired_sessions():
|
||||
def clear_expired_sessions(self):
|
||||
"""
|
||||
Clear any expired sessions from the database.
|
||||
"""
|
||||
logging.info("Clearing expired sessions...")
|
||||
self.logger.info("Clearing expired sessions...")
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
try:
|
||||
engine.SessionStore.clear_expired()
|
||||
logging.info("Sessions cleared.")
|
||||
self.logger.info("Sessions cleared.")
|
||||
except NotImplementedError:
|
||||
logging.warning(
|
||||
self.logger.warning(
|
||||
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prune_changelog():
|
||||
def prune_changelog(self):
|
||||
"""
|
||||
Delete any ObjectChange records older than the configured changelog retention time (if any).
|
||||
"""
|
||||
logging.info("Pruning old changelog entries...")
|
||||
self.logger.info("Pruning old changelog entries...")
|
||||
config = Config()
|
||||
if not config.CHANGELOG_RETENTION:
|
||||
logging.info("No retention period specified; skipping.")
|
||||
self.logger.info("No retention period specified; skipping.")
|
||||
return
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
|
||||
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
|
||||
logging.debug(f"Cut-off time: {cutoff}")
|
||||
self.logger.debug(
|
||||
f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
|
||||
)
|
||||
|
||||
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
|
||||
logging.info(f"Deleted {count} expired records")
|
||||
self.logger.info(f"Deleted {count} expired changelog records")
|
||||
|
||||
@staticmethod
|
||||
def delete_expired_jobs():
|
||||
def delete_expired_jobs(self):
|
||||
"""
|
||||
Delete any jobs older than the configured retention period (if any).
|
||||
"""
|
||||
logging.info("Deleting expired jobs...")
|
||||
self.logger.info("Deleting expired jobs...")
|
||||
config = Config()
|
||||
if not config.JOB_RETENTION:
|
||||
logging.info("No retention period specified; skipping.")
|
||||
self.logger.info("No retention period specified; skipping.")
|
||||
return
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
|
||||
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
|
||||
logging.debug(f"Cut-off time: {cutoff}")
|
||||
self.logger.debug(
|
||||
f"Job retention period: {config.JOB_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
|
||||
)
|
||||
|
||||
count = Job.objects.filter(created__lt=cutoff).delete()[0]
|
||||
logging.info(f"Deleted {count} expired records")
|
||||
self.logger.info(f"Deleted {count} expired jobs")
|
||||
|
||||
@staticmethod
|
||||
def check_for_new_releases():
|
||||
def check_for_new_releases(self):
|
||||
"""
|
||||
Check for new releases and cache the latest release.
|
||||
"""
|
||||
logging.info("Checking for new releases...")
|
||||
self.logger.info("Checking for new releases...")
|
||||
if settings.ISOLATED_DEPLOYMENT:
|
||||
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||
self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||
return
|
||||
if not settings.RELEASE_CHECK_URL:
|
||||
logging.info("RELEASE_CHECK_URL is not set; skipping")
|
||||
self.logger.info("RELEASE_CHECK_URL is not set; skipping")
|
||||
return
|
||||
|
||||
# Fetch the latest releases
|
||||
logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
|
||||
self.logger.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
|
||||
try:
|
||||
response = requests.get(
|
||||
url=settings.RELEASE_CHECK_URL,
|
||||
@ -181,7 +179,7 @@ class SystemHousekeepingJob(JobRunner):
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logging.error(f"Error fetching release: {exc}")
|
||||
self.logger.error(f"Error fetching release: {exc}")
|
||||
return
|
||||
|
||||
# Determine the most recent stable release
|
||||
@ -191,8 +189,8 @@ class SystemHousekeepingJob(JobRunner):
|
||||
continue
|
||||
releases.append((version.parse(release['tag_name']), release.get('html_url')))
|
||||
latest_release = max(releases)
|
||||
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
|
||||
logging.info(f"Latest release: {latest_release[0]}")
|
||||
self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
|
||||
self.logger.info(f"Latest release: {latest_release[0]}")
|
||||
|
||||
# Cache the most recent release
|
||||
cache.set('latest_release', latest_release, None)
|
||||
|
||||
@ -78,8 +78,8 @@ class Command(BaseCommand):
|
||||
for app_label in app_labels:
|
||||
app_name = apps.get_app_config(app_label).verbose_name
|
||||
print(f'{app_name}:')
|
||||
for m in self.django_models[app_label]:
|
||||
print(f' {m}')
|
||||
for model in self.django_models[app_label]:
|
||||
print(f' {app_label}.{model}')
|
||||
|
||||
def get_namespace(self):
|
||||
namespace = defaultdict(SimpleNamespace)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@ -64,6 +65,9 @@ class ObjectTypeManager(models.Manager):
|
||||
Retrieve or create and return the ObjectType for a model.
|
||||
"""
|
||||
from netbox.models.features import get_model_features, model_is_public
|
||||
|
||||
if not inspect.isclass(model):
|
||||
model = model.__class__
|
||||
opts = self._get_opts(model, for_concrete_model)
|
||||
|
||||
try:
|
||||
@ -75,7 +79,7 @@ class ObjectTypeManager(models.Manager):
|
||||
app_label=opts.app_label,
|
||||
model=opts.model_name,
|
||||
public=model_is_public(model),
|
||||
features=get_model_features(model.__class__),
|
||||
features=get_model_features(model),
|
||||
)[0]
|
||||
|
||||
return ot
|
||||
@ -93,6 +97,8 @@ class ObjectTypeManager(models.Manager):
|
||||
needed_models = defaultdict(set)
|
||||
needed_opts = defaultdict(list)
|
||||
for model in models:
|
||||
if not inspect.isclass(model):
|
||||
model = model.__class__
|
||||
opts = self._get_opts(model, for_concrete_models)
|
||||
needed_models[opts.app_label].add(opts.model_name)
|
||||
needed_opts[(opts.app_label, opts.model_name)].append(model)
|
||||
@ -117,7 +123,7 @@ class ObjectTypeManager(models.Manager):
|
||||
app_label=app_label,
|
||||
model=model_name,
|
||||
public=model_is_public(model),
|
||||
features=get_model_features(model.__class__),
|
||||
features=get_model_features(model),
|
||||
)
|
||||
|
||||
return results
|
||||
@ -135,9 +141,9 @@ class ObjectTypeManager(models.Manager):
|
||||
"""
|
||||
Return ObjectTypes only for models which support the given feature.
|
||||
|
||||
Only ObjectTypes which list the specified feature will be included. Supported features are declared in
|
||||
netbox.models.features.FEATURES_MAP. For example, we can find all ObjectTypes for models which support event
|
||||
rules with:
|
||||
Only ObjectTypes which list the specified feature will be included. Supported features are declared in the
|
||||
application registry under `registry["model_features"]`. For example, we can find all ObjectTypes for models
|
||||
which support event rules with:
|
||||
|
||||
ObjectType.objects.with_feature('event_rules')
|
||||
"""
|
||||
|
||||
@ -14,6 +14,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||
from core.events import *
|
||||
from core.models import ObjectType
|
||||
from extras.events import enqueue_event
|
||||
from extras.models import Tag
|
||||
from extras.utils import run_validators
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
@ -104,6 +105,17 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
# m2m_changed with objects added or removed
|
||||
m2m_changed = True
|
||||
event_type = OBJECT_UPDATED
|
||||
elif kwargs.get('action') == 'post_clear':
|
||||
# Handle clearing of an M2M field
|
||||
if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
|
||||
# Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
|
||||
# prechange snapshot is empty)
|
||||
m2m_changed = True
|
||||
event_type = OBJECT_UPDATED
|
||||
else:
|
||||
# Other endpoints are unimpacted as they send post_add and post_remove
|
||||
# This will impact changes that utilize clear() however so we may want to give consideration for this branch
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
@ -241,3 +241,48 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = ObjectType.objects.all()
|
||||
filterset = ObjectTypeFilterSet
|
||||
ignore_fields = (
|
||||
'custom_fields',
|
||||
'custom_links',
|
||||
'event_rules',
|
||||
'export_templates',
|
||||
'object_permissions',
|
||||
'saved_filters',
|
||||
)
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'vrf'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_app_label(self):
|
||||
self.assertEqual(
|
||||
self.filterset({'app_label': ['dcim']}, self.queryset).qs.count(),
|
||||
ObjectType.objects.filter(app_label='dcim').count(),
|
||||
)
|
||||
|
||||
def test_model(self):
|
||||
self.assertEqual(
|
||||
self.filterset({'model': ['site']}, self.queryset).qs.count(),
|
||||
ObjectType.objects.filter(model='site').count(),
|
||||
)
|
||||
|
||||
def test_public(self):
|
||||
self.assertEqual(
|
||||
self.filterset({'public': True}, self.queryset).qs.count(),
|
||||
ObjectType.objects.filter(public=True).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.filterset({'public': False}, self.queryset).qs.count(),
|
||||
ObjectType.objects.filter(public=False).count(),
|
||||
)
|
||||
|
||||
def test_feature(self):
|
||||
self.assertEqual(
|
||||
self.filterset({'features': 'tags'}, self.queryset).qs.count(),
|
||||
ObjectType.objects.filter(features__contains=['tags']).count(),
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@ -366,6 +367,11 @@ class SystemTestCase(TestCase):
|
||||
# Test export
|
||||
response = self.client.get(f"{reverse('core:system')}?export=true")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
self.assertIn('netbox_release', data)
|
||||
self.assertIn('plugins', data)
|
||||
self.assertIn('config', data)
|
||||
self.assertIn('objects', data)
|
||||
|
||||
def test_system_view_with_config_revision(self):
|
||||
ConfigRevision.objects.create()
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import json
|
||||
import platform
|
||||
|
||||
from django import __version__ as DJANGO_VERSION
|
||||
from django import __version__ as django_version
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
@ -23,10 +23,11 @@ from rq.worker_registration import clean_worker_registry
|
||||
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
||||
from netbox.registry import registry
|
||||
from netbox.plugins.utils import get_installed_plugins
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.apps import get_installed_apps
|
||||
from utilities.data import shallow_compare_dict
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import htmx_partial
|
||||
@ -216,17 +217,23 @@ class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(ObjectChange, 'list', path='', detail=False)
|
||||
class ObjectChangeListView(generic.ObjectListView):
|
||||
queryset = ObjectChange.objects.valid_models()
|
||||
queryset = None
|
||||
filterset = filtersets.ObjectChangeFilterSet
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = tables.ObjectChangeTable
|
||||
template_name = 'core/objectchange_list.html'
|
||||
actions = (BulkExport,)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return ObjectChange.objects.valid_models()
|
||||
|
||||
|
||||
@register_model_view(ObjectChange)
|
||||
class ObjectChangeView(generic.ObjectView):
|
||||
queryset = ObjectChange.objects.valid_models()
|
||||
queryset = None
|
||||
|
||||
def get_queryset(self, request):
|
||||
return ObjectChange.objects.valid_models()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||
@ -546,7 +553,7 @@ class SystemView(UserPassesTestMixin, View):
|
||||
|
||||
def get(self, request):
|
||||
|
||||
# System stats
|
||||
# System status
|
||||
psql_version = db_name = db_size = None
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
@ -561,7 +568,7 @@ class SystemView(UserPassesTestMixin, View):
|
||||
pass
|
||||
stats = {
|
||||
'netbox_release': settings.RELEASE,
|
||||
'django_version': DJANGO_VERSION,
|
||||
'django_version': django_version,
|
||||
'python_version': platform.python_version(),
|
||||
'postgresql_version': psql_version,
|
||||
'database_name': db_name,
|
||||
@ -569,19 +576,35 @@ class SystemView(UserPassesTestMixin, View):
|
||||
'rq_worker_count': Worker.count(get_connection('default')),
|
||||
}
|
||||
|
||||
# Django apps
|
||||
django_apps = get_installed_apps()
|
||||
|
||||
# Configuration
|
||||
config = get_config()
|
||||
|
||||
# Plugins
|
||||
plugins = get_installed_plugins()
|
||||
|
||||
# Object counts
|
||||
objects = {}
|
||||
for ot in ObjectType.objects.public().order_by('app_label', 'model'):
|
||||
if model := ot.model_class():
|
||||
objects[ot] = model.objects.count()
|
||||
|
||||
# Raw data export
|
||||
if 'export' in request.GET:
|
||||
stats['netbox_release'] = stats['netbox_release'].asdict()
|
||||
params = [param.name for param in PARAMS]
|
||||
data = {
|
||||
**stats,
|
||||
'plugins': registry['plugins']['installed'],
|
||||
'django_apps': django_apps,
|
||||
'plugins': plugins,
|
||||
'config': {
|
||||
k: getattr(config, k) for k in sorted(params)
|
||||
},
|
||||
'objects': {
|
||||
f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
|
||||
},
|
||||
}
|
||||
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
|
||||
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
|
||||
@ -594,7 +617,10 @@ class SystemView(UserPassesTestMixin, View):
|
||||
|
||||
return render(request, 'core/system.html', {
|
||||
'stats': stats,
|
||||
'django_apps': django_apps,
|
||||
'config': config,
|
||||
'plugins': plugins,
|
||||
'objects': objects,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -6,11 +6,13 @@ from dcim import models
|
||||
|
||||
__all__ = (
|
||||
'NestedDeviceBaySerializer',
|
||||
'NestedDeviceRoleSerializer',
|
||||
'NestedDeviceSerializer',
|
||||
'NestedInterfaceSerializer',
|
||||
'NestedInterfaceTemplateSerializer',
|
||||
'NestedLocationSerializer',
|
||||
'NestedModuleBaySerializer',
|
||||
'NestedPlatformSerializer',
|
||||
'NestedRegionSerializer',
|
||||
'NestedSiteGroupSerializer',
|
||||
)
|
||||
@ -102,3 +104,10 @@ class NestedModuleBaySerializer(WritableNestedSerializer):
|
||||
class Meta:
|
||||
model = models.ModuleBay
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedPlatformSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Platform
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.models import Platform
|
||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from netbox.api.serializers import NestedGroupModelSerializer
|
||||
from .manufacturers import ManufacturerSerializer
|
||||
from .nested import NestedPlatformSerializer
|
||||
|
||||
__all__ = (
|
||||
'PlatformSerializer',
|
||||
)
|
||||
|
||||
|
||||
class PlatformSerializer(NetBoxModelSerializer):
|
||||
class PlatformSerializer(NestedGroupModelSerializer):
|
||||
parent = NestedPlatformSerializer(required=False, allow_null=True, default=None)
|
||||
manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
|
||||
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
|
||||
# Related object counts
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
device_count = serializers.IntegerField(read_only=True, default=0)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||
'virtualmachine_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
|
||||
brief_fields = (
|
||||
'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth',
|
||||
)
|
||||
|
||||
@ -137,17 +137,29 @@ class RackSerializer(RackBaseSerializer):
|
||||
|
||||
|
||||
class RackReservationSerializer(NetBoxModelSerializer):
|
||||
rack = RackSerializer(nested=True)
|
||||
user = UserSerializer(nested=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
rack = RackSerializer(
|
||||
nested=True,
|
||||
)
|
||||
status = ChoiceField(
|
||||
choices=RackReservationStatusChoices,
|
||||
required=False,
|
||||
)
|
||||
user = UserSerializer(
|
||||
nested=True,
|
||||
)
|
||||
tenant = TenantSerializer(
|
||||
nested=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant',
|
||||
'description', 'comments', 'tags', 'custom_fields',
|
||||
'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
|
||||
'tenant', 'description', 'comments', 'tags', 'custom_fields',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
|
||||
brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
|
||||
|
||||
|
||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.models import DeviceRole, InventoryItemRole
|
||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
@ -13,10 +15,8 @@ __all__ = (
|
||||
class DeviceRoleSerializer(NestedGroupModelSerializer):
|
||||
parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
|
||||
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
|
||||
# Related object counts
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
device_count = serializers.IntegerField(read_only=True, default=0)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
|
||||
@ -20,6 +20,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
|
||||
@ -351,7 +352,19 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
#
|
||||
|
||||
class DeviceRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceRole.objects.all()
|
||||
queryset = DeviceRole.objects.add_related_count(
|
||||
DeviceRole.objects.add_related_count(
|
||||
DeviceRole.objects.all(),
|
||||
VirtualMachine,
|
||||
'role',
|
||||
'virtualmachine_count',
|
||||
cumulative=True
|
||||
),
|
||||
Device,
|
||||
'role',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filtersets.DeviceRoleFilterSet
|
||||
|
||||
@ -360,8 +373,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformViewSet(NetBoxModelViewSet):
|
||||
queryset = Platform.objects.all()
|
||||
class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Platform.objects.add_related_count(
|
||||
Platform.objects.add_related_count(
|
||||
Platform.objects.all(),
|
||||
VirtualMachine,
|
||||
'platform',
|
||||
'virtualmachine_count',
|
||||
cumulative=True
|
||||
),
|
||||
Device,
|
||||
'platform',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
)
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
@ -139,6 +139,24 @@ class RackAirflowChoices(ChoiceSet):
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationStatusChoices(ChoiceSet):
|
||||
key = 'RackReservation.status'
|
||||
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_STALE = 'stale'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_PENDING, _('Pending'), 'cyan'),
|
||||
(STATUS_ACTIVE, _('Active'), 'green'),
|
||||
(STATUS_STALE, _('Stale'), 'orange'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# DeviceTypes
|
||||
#
|
||||
@ -344,6 +362,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C8 = 'iec-60320-c8'
|
||||
TYPE_IEC_C14 = 'iec-60320-c14'
|
||||
TYPE_IEC_C16 = 'iec-60320-c16'
|
||||
TYPE_IEC_C18 = 'iec-60320-c18'
|
||||
TYPE_IEC_C20 = 'iec-60320-c20'
|
||||
TYPE_IEC_C22 = 'iec-60320-c22'
|
||||
# IEC 60309
|
||||
@ -462,6 +481,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C8, 'C8'),
|
||||
(TYPE_IEC_C14, 'C14'),
|
||||
(TYPE_IEC_C16, 'C16'),
|
||||
(TYPE_IEC_C18, 'C18'),
|
||||
(TYPE_IEC_C20, 'C20'),
|
||||
(TYPE_IEC_C22, 'C22'),
|
||||
)),
|
||||
@ -599,6 +619,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C7 = 'iec-60320-c7'
|
||||
TYPE_IEC_C13 = 'iec-60320-c13'
|
||||
TYPE_IEC_C15 = 'iec-60320-c15'
|
||||
TYPE_IEC_C17 = 'iec-60320-c17'
|
||||
TYPE_IEC_C19 = 'iec-60320-c19'
|
||||
TYPE_IEC_C21 = 'iec-60320-c21'
|
||||
# IEC 60309
|
||||
@ -711,6 +732,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C7, 'C7'),
|
||||
(TYPE_IEC_C13, 'C13'),
|
||||
(TYPE_IEC_C15, 'C15'),
|
||||
(TYPE_IEC_C17, 'C17'),
|
||||
(TYPE_IEC_C19, 'C19'),
|
||||
(TYPE_IEC_C21, 'C21'),
|
||||
)),
|
||||
|
||||
@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=RackReservationStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
@ -547,14 +551,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Manufacturer (slug)'),
|
||||
)
|
||||
default_platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||
default_platform_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
field_name='default_platform',
|
||||
lookup_expr='in',
|
||||
label=_('Default platform (ID)'),
|
||||
)
|
||||
default_platform = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='default_platform__slug',
|
||||
default_platform = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
field_name='default_platform',
|
||||
to_field_name='slug',
|
||||
lookup_expr='in',
|
||||
label=_('Default platform (slug)'),
|
||||
)
|
||||
has_front_image = django_filters.BooleanFilter(
|
||||
@ -979,6 +986,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
|
||||
class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
label=_('Immediate parent platform (ID)'),
|
||||
)
|
||||
parent = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='parent__slug',
|
||||
queryset=Platform.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Immediate parent platform (slug)'),
|
||||
)
|
||||
ancestor_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
field_name='parent',
|
||||
lookup_expr='in',
|
||||
label=_('Parent platform (ID)'),
|
||||
)
|
||||
ancestor = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
field_name='parent',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Parent platform (slug)'),
|
||||
)
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -1058,14 +1088,17 @@ class DeviceFilterSet(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Parent Device (ID)'),
|
||||
)
|
||||
platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||
platform_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
field_name='platform',
|
||||
lookup_expr='in',
|
||||
label=_('Platform (ID)'),
|
||||
)
|
||||
platform = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='platform__slug',
|
||||
platform = TreeNodeMultipleChoiceFilter(
|
||||
field_name='platform',
|
||||
queryset=Platform.objects.all(),
|
||||
to_field_name='slug',
|
||||
lookup_expr='in',
|
||||
label=_('Platform (slug)'),
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
@ -1515,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
label=_('Site group (slug)'),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site',
|
||||
field_name='_site',
|
||||
queryset=Site.objects.all(),
|
||||
label=_('Site (ID)'),
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site__slug',
|
||||
field_name='_site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Site name (slug)'),
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location',
|
||||
field_name='_location',
|
||||
queryset=Location.objects.all(),
|
||||
label=_('Location (ID)'),
|
||||
)
|
||||
location = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location__slug',
|
||||
field_name='_location__slug',
|
||||
queryset=Location.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__rack',
|
||||
field_name='_rack',
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack (ID)'),
|
||||
)
|
||||
rack = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__rack__name',
|
||||
field_name='_rack__name',
|
||||
queryset=Rack.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Rack (name)'),
|
||||
@ -1885,6 +1918,16 @@ class InterfaceFilterSet(
|
||||
PathEndpointFilterSet,
|
||||
CommonInterfaceFilterSet
|
||||
):
|
||||
virtual_chassis_member_or_master = MultiValueCharFilter(
|
||||
method='filter_virtual_chassis_member_or_master',
|
||||
field_name='name',
|
||||
label=_('Virtual Chassis Interfaces for Device when device is master')
|
||||
)
|
||||
virtual_chassis_member_or_master_id = MultiValueNumberFilter(
|
||||
method='filter_virtual_chassis_member_or_master',
|
||||
field_name='pk',
|
||||
label=_('Virtual Chassis Interfaces for Device when device is master (ID)')
|
||||
)
|
||||
virtual_chassis_member = MultiValueCharFilter(
|
||||
method='filter_virtual_chassis_member',
|
||||
field_name='name',
|
||||
@ -1995,11 +2038,14 @@ class InterfaceFilterSet(
|
||||
'cable_id', 'cable_end',
|
||||
)
|
||||
|
||||
def filter_virtual_chassis_member(self, queryset, name, value):
|
||||
def filter_virtual_chassis_member_or_master(self, queryset, name, value):
|
||||
return self.filter_virtual_chassis_member(queryset, name, value, if_master=True)
|
||||
|
||||
def filter_virtual_chassis_member(self, queryset, name, value, if_master=False):
|
||||
try:
|
||||
vc_interface_ids = []
|
||||
for device in Device.objects.filter(**{f'{name}__in': value}):
|
||||
vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
|
||||
vc_interface_ids.extend(device.vc_interfaces(if_master=if_master).values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
@ -69,11 +69,14 @@ class PowerPortBulkCreateForm(
|
||||
|
||||
|
||||
class PowerOutletBulkCreateForm(
|
||||
form_from_model(PowerOutlet, ['type', 'color', 'feed_leg', 'mark_connected']),
|
||||
form_from_model(PowerOutlet, ['type', 'status', 'color', 'feed_leg', 'mark_connected']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = PowerOutlet
|
||||
field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
|
||||
field_order = (
|
||||
'name', 'label', 'type', 'status', 'color', 'feed_leg', 'mark_connected',
|
||||
'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
class InterfaceBulkCreateForm(
|
||||
|
||||
@ -476,6 +476,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=add_blank_choice(RackReservationStatusChoices),
|
||||
required=False,
|
||||
initial=''
|
||||
)
|
||||
user = forms.ModelChoiceField(
|
||||
label=_('User'),
|
||||
queryset=User.objects.order_by('username'),
|
||||
@ -495,7 +501,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = RackReservation
|
||||
fieldsets = (
|
||||
FieldSet('user', 'tenant', 'description'),
|
||||
FieldSet('status', 'user', 'tenant', 'description'),
|
||||
)
|
||||
nullable_fields = ('comments',)
|
||||
|
||||
@ -682,6 +688,11 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
label=_('Parent'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
label=_('Manufacturer'),
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -697,12 +708,13 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = Platform
|
||||
fieldsets = (
|
||||
FieldSet('manufacturer', 'config_template', 'description'),
|
||||
FieldSet('parent', 'manufacturer', 'config_template', 'description'),
|
||||
)
|
||||
nullable_fields = ('manufacturer', 'config_template', 'description')
|
||||
nullable_fields = ('parent', 'manufacturer', 'config_template', 'description', 'comments')
|
||||
|
||||
|
||||
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
|
||||
required=True,
|
||||
help_text=_('Comma-separated list of individual unit numbers')
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
label=_('Status'),
|
||||
choices=RackReservationStatusChoices,
|
||||
help_text=_('Operational status')
|
||||
)
|
||||
tenant = CSVModelChoiceField(
|
||||
label=_('Tenant'),
|
||||
queryset=Tenant.objects.all(),
|
||||
@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags')
|
||||
fields = ('site', 'location', 'rack', 'units', 'status', 'tenant', 'description', 'comments', 'tags')
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
@ -504,6 +509,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
|
||||
class PlatformImportForm(NetBoxModelImportForm):
|
||||
slug = SlugField()
|
||||
parent = CSVModelChoiceField(
|
||||
label=_('Parent'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Parent platform'),
|
||||
error_messages={
|
||||
'invalid_choice': _('Platform not found.'),
|
||||
}
|
||||
)
|
||||
manufacturer = CSVModelChoiceField(
|
||||
label=_('Manufacturer'),
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -522,7 +537,7 @@ class PlatformImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = (
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@ -676,6 +691,12 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
# Limit platform queryset by manufacturer
|
||||
params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
|
||||
self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
|
||||
Q(**params) | Q(manufacturer=None)
|
||||
)
|
||||
|
||||
# Limit device bay queryset by parent device
|
||||
if parent := data.get('parent'):
|
||||
params = {f"device__{self.fields['parent'].to_field_name}": parent}
|
||||
|
||||
@ -19,6 +19,11 @@ def get_cable_form(a_type, b_type):
|
||||
# Device component
|
||||
if hasattr(term_cls, 'device'):
|
||||
|
||||
# Dynamically change the param field for interfaces to use virtual_chassis filter
|
||||
query_param_device_field = 'device_id'
|
||||
if term_cls == Interface:
|
||||
query_param_device_field = 'virtual_chassis_member_or_master_id'
|
||||
|
||||
attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device'),
|
||||
@ -36,7 +41,7 @@ def get_cable_form(a_type, b_type):
|
||||
'parent': 'device',
|
||||
},
|
||||
query_params={
|
||||
'device_id': f'$termination_{cable_end}_device',
|
||||
query_param_device_field: f'$termination_{cable_end}_device',
|
||||
'kind': 'physical', # Exclude virtual interfaces
|
||||
}
|
||||
)
|
||||
|
||||
@ -417,7 +417,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = RackReservation
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('user_id', name=_('User')),
|
||||
FieldSet('status', 'user_id', name=_('Reservation')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
@ -458,6 +458,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
},
|
||||
label=_('Rack')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
choices=RackReservationStatusChoices,
|
||||
required=False
|
||||
)
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
@ -714,6 +719,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
|
||||
class PlatformFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Platform
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
label=_('Parent')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
|
||||
@ -336,14 +336,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')),
|
||||
FieldSet('rack', 'units', 'status', 'user', 'description', 'tags', name=_('Reservation')),
|
||||
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
'rack', 'units', 'status', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@ -536,6 +536,11 @@ class DeviceRoleForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class PlatformForm(NetBoxModelForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
label=_('Parent'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
label=_('Manufacturer'),
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -551,15 +556,18 @@ class PlatformForm(NetBoxModelForm):
|
||||
label=_('Slug'),
|
||||
max_length=64
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')),
|
||||
FieldSet(
|
||||
'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform'),
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@ -1891,6 +1899,7 @@ class MACAddressForm(NetBoxModelForm):
|
||||
label=_('Interface'),
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
context={
|
||||
'parent': 'device',
|
||||
},
|
||||
@ -1899,6 +1908,7 @@ class MACAddressForm(NetBoxModelForm):
|
||||
label=_('VM Interface'),
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
context={
|
||||
'parent': 'virtual_machine',
|
||||
},
|
||||
|
||||
@ -633,6 +633,8 @@ class ModuleTypeType(NetBoxObjectType):
|
||||
pagination=True
|
||||
)
|
||||
class PlatformType(OrganizationalObjectType):
|
||||
parent: Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')] | None
|
||||
children: List[Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')]]
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
from dcim.choices import *
|
||||
from netbox.choices import WeightUnitChoices
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
|
||||
TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
|
||||
OUTPUT_FILENAME = 'contrib/generated_schema.json'
|
||||
@ -23,6 +24,7 @@ CHOICES_MAP = {
|
||||
'interface_type_choices': InterfaceTypeChoices,
|
||||
'interface_poe_mode_choices': InterfacePoEModeChoices,
|
||||
'interface_poe_type_choices': InterfacePoETypeChoices,
|
||||
'interface_rf_role_choices': WirelessRoleChoices,
|
||||
'front_port_type_choices': PortTypeChoices,
|
||||
'rear_port_type_choices': PortTypeChoices,
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import taggit.managers
|
||||
from django.db import migrations, models
|
||||
|
||||
import utilities.json
|
||||
import utilities.jsonschema
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -25,7 +26,7 @@ class Migration(migrations.Migration):
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('schema', models.JSONField(blank=True, null=True)),
|
||||
('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
|
||||
@ -0,0 +1,287 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
|
||||
def populate_denormalized_data(apps, schema_editor):
|
||||
Device = apps.get_model('dcim', 'Device')
|
||||
component_models = (
|
||||
apps.get_model('dcim', 'ConsolePort'),
|
||||
apps.get_model('dcim', 'ConsoleServerPort'),
|
||||
apps.get_model('dcim', 'PowerPort'),
|
||||
apps.get_model('dcim', 'PowerOutlet'),
|
||||
apps.get_model('dcim', 'Interface'),
|
||||
apps.get_model('dcim', 'FrontPort'),
|
||||
apps.get_model('dcim', 'RearPort'),
|
||||
apps.get_model('dcim', 'DeviceBay'),
|
||||
apps.get_model('dcim', 'ModuleBay'),
|
||||
apps.get_model('dcim', 'InventoryItem'),
|
||||
)
|
||||
|
||||
for model in component_models:
|
||||
subquery = Device.objects.filter(pk=OuterRef('device_id'))
|
||||
model.objects.update(
|
||||
_site=Subquery(subquery.values('site_id')[:1]),
|
||||
_location=Subquery(subquery.values('location_id')[:1]),
|
||||
_rack=Subquery(subquery.values('rack_id')[:1]),
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0208_devicerole_uniqueness'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.RunPython(populate_denormalized_data),
|
||||
]
|
||||
19
netbox/dcim/migrations/0210_macaddress_ordering.py
Normal file
19
netbox/dcim/migrations/0210_macaddress_ordering.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0209_device_component_denorm_site_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='macaddress',
|
||||
options={
|
||||
'ordering': ('mac_address', 'pk'),
|
||||
'verbose_name': 'MAC address',
|
||||
'verbose_name_plural': 'MAC addresses'
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0208_devicerole_uniqueness'),
|
||||
('dcim', '0210_macaddress_ordering'),
|
||||
('extras', '0129_fix_script_paths'),
|
||||
]
|
||||
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0209_platform_manufacturer_uniqueness'),
|
||||
('dcim', '0211_platform_manufacturer_uniqueness'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
55
netbox/dcim/migrations/0213_platform_parent.py
Normal file
55
netbox/dcim/migrations/0213_platform_parent.py
Normal file
@ -0,0 +1,55 @@
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0212_interface_tx_power_negative'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add parent & MPTT fields
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='parent',
|
||||
field=mptt.fields.TreeForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='children',
|
||||
to='dcim.platform'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='level',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='lft',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='rght',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='tree_id',
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
# Add comments field
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
29
netbox/dcim/migrations/0214_platform_rebuild.py
Normal file
29
netbox/dcim/migrations/0214_platform_rebuild.py
Normal file
@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
import mptt
|
||||
import mptt.managers
|
||||
|
||||
|
||||
def rebuild_mptt(apps, schema_editor):
|
||||
"""
|
||||
Construct the MPTT hierarchy.
|
||||
"""
|
||||
Platform = apps.get_model('dcim', 'Platform')
|
||||
manager = mptt.managers.TreeManager()
|
||||
manager.model = Platform
|
||||
mptt.register(Platform)
|
||||
manager.contribute_to_class(Platform, 'objects')
|
||||
manager.rebuild()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0213_platform_parent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=rebuild_mptt,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
16
netbox/dcim/migrations/0215_rackreservation_status.py
Normal file
16
netbox/dcim/migrations/0215_rackreservation_status.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0214_platform_rebuild'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rackreservation',
|
||||
name='status',
|
||||
field=models.CharField(default='active', max_length=50),
|
||||
),
|
||||
]
|
||||
@ -12,6 +12,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.choices import ColorChoices
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from utilities.conversion import to_meters
|
||||
from utilities.exceptions import AbortRequest
|
||||
@ -156,6 +157,15 @@ class Cable(PrimaryModel):
|
||||
self._terminations_modified = True
|
||||
self._b_terminations = value
|
||||
|
||||
@property
|
||||
def color_name(self):
|
||||
color_name = ""
|
||||
for hex_code, label in ColorChoices.CHOICES:
|
||||
if hex_code.lower() == self.color.lower():
|
||||
color_name = str(label)
|
||||
|
||||
return color_name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Denormalized references replicated from the parent Device
|
||||
_site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
_location = models.ForeignKey(
|
||||
to='dcim.Location',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
_rack = models.ForeignKey(
|
||||
to='dcim.Rack',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ('device', 'name')
|
||||
@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
|
||||
"device": _("Components cannot be moved to a different device.")
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Save denormalized references
|
||||
self._site = self.device.site
|
||||
self._location = self.device.location
|
||||
self._rack = self.device.rack
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.device
|
||||
|
||||
@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
from django.db.models import F, ProtectedError, prefetch_related_objects
|
||||
from django.db.models.functions import Lower
|
||||
from django.db.models.signals import post_save
|
||||
from django.urls import reverse
|
||||
@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.mixins import WeightMixin
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.fields import ColorField, CounterCacheField
|
||||
from utilities.prefetch import get_prefetchable_fields
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from .device_components import *
|
||||
from .mixins import RenderConfigMixin
|
||||
@ -424,7 +425,7 @@ class DeviceRole(NestedGroupModel):
|
||||
verbose_name_plural = _('device roles')
|
||||
|
||||
|
||||
class Platform(OrganizationalModel):
|
||||
class Platform(NestedGroupModel):
|
||||
"""
|
||||
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
|
||||
Platform may optionally be associated with a particular Manufacturer.
|
||||
@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
|
||||
null=True,
|
||||
help_text=_('Optionally limit this platform to devices of a certain manufacturer')
|
||||
)
|
||||
# Override name & slug from OrganizationalModel to not enforce uniqueness
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField(
|
||||
verbose_name=_('slug'),
|
||||
max_length=100
|
||||
)
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
@ -454,6 +446,8 @@ class Platform(OrganizationalModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
clone_fields = ('parent', 'description')
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('platform')
|
||||
@ -955,7 +949,10 @@ class Device(
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
model.objects.bulk_create(components)
|
||||
components = model.objects.bulk_create(components)
|
||||
# Prefetch related objects to minimize queries needed during post_save
|
||||
prefetch_fields = get_prefetchable_fields(model)
|
||||
prefetch_related_objects(components, *prefetch_fields)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
for component in components:
|
||||
post_save.send(
|
||||
@ -1303,7 +1300,7 @@ class MACAddress(PrimaryModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('mac_address',)
|
||||
ordering = ('mac_address', 'pk',)
|
||||
verbose_name = _('MAC address')
|
||||
verbose_name_plural = _('MAC addresses')
|
||||
|
||||
|
||||
@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
|
||||
schema = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('schema')
|
||||
validators=[validate_schema],
|
||||
verbose_name=_('schema'),
|
||||
)
|
||||
|
||||
clone_fields = ('schema',)
|
||||
@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the schema definition
|
||||
if self.schema is not None:
|
||||
try:
|
||||
validate_schema(self.schema)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({
|
||||
'schema': e.message,
|
||||
})
|
||||
|
||||
|
||||
class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
|
||||
@ -673,6 +673,12 @@ class RackReservation(PrimaryModel):
|
||||
verbose_name=_('units'),
|
||||
base_field=models.PositiveSmallIntegerField()
|
||||
)
|
||||
status = models.CharField(
|
||||
verbose_name=_('status'),
|
||||
max_length=50,
|
||||
choices=RackReservationStatusChoices,
|
||||
default=RackReservationStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
@ -733,6 +739,9 @@ class RackReservation(PrimaryModel):
|
||||
def unit_list(self):
|
||||
return array_to_string(self.units)
|
||||
|
||||
def get_status_color(self):
|
||||
return RackReservationStatusChoices.colors.get(self.status)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.rack
|
||||
|
||||
@ -20,10 +20,7 @@ class BulkAddComponents(ObjectAction):
|
||||
@classmethod
|
||||
def get_context(cls, context, obj):
|
||||
return {
|
||||
'perms': context.get('perms'),
|
||||
'request': context.get('request'),
|
||||
'formaction': context.get('formaction'),
|
||||
'label': cls.label,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -3,13 +3,28 @@ import logging
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableEndChoices, LinkStatusChoices
|
||||
from dcim.choices import CableEndChoices, LinkStatusChoices
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
|
||||
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
|
||||
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
|
||||
VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
COMPONENT_MODELS = (
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
DeviceBay,
|
||||
FrontPort,
|
||||
Interface,
|
||||
InventoryItem,
|
||||
ModuleBay,
|
||||
PowerOutlet,
|
||||
PowerPort,
|
||||
RearPort,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Location/rack/device assignment
|
||||
@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs):
|
||||
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Device)
|
||||
def handle_device_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child components to update the parent Site, Location, and Rack when a Device is saved.
|
||||
"""
|
||||
if not created:
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device=instance).update(
|
||||
_site=instance.site,
|
||||
_location=instance.location,
|
||||
_rack=instance.rack,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
|
||||
order_by=('_abs_length')
|
||||
)
|
||||
color = columns.ColorColumn()
|
||||
color_name = tables.Column(
|
||||
verbose_name=_('Color Name'),
|
||||
orderable=False
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:cable_list'
|
||||
@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
|
||||
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
|
||||
'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
|
||||
|
||||
@ -103,10 +103,14 @@ class DeviceRoleTable(NetBoxTable):
|
||||
#
|
||||
|
||||
class PlatformTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
name = columns.MPTTColumn(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
manufacturer = tables.Column(
|
||||
verbose_name=_('Manufacturer'),
|
||||
linkify=True
|
||||
@ -132,8 +136,8 @@ class PlatformTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Platform
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'parent', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template',
|
||||
'description', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
|
||||
|
||||
@ -229,6 +229,9 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Units')
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant',
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
|
||||
|
||||
@ -465,7 +465,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackReservation
|
||||
brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
|
||||
brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
@ -483,9 +483,24 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
rack_reservations = (
|
||||
RackReservation(rack=racks[0], units=[1, 2, 3], user=user, description='Reservation #1'),
|
||||
RackReservation(rack=racks[0], units=[4, 5, 6], user=user, description='Reservation #2'),
|
||||
RackReservation(rack=racks[0], units=[7, 8, 9], user=user, description='Reservation #3'),
|
||||
RackReservation(
|
||||
rack=racks[0],
|
||||
units=[1, 2, 3],
|
||||
user=user,
|
||||
description='Reservation #1',
|
||||
),
|
||||
RackReservation(
|
||||
rack=racks[0],
|
||||
units=[4, 5, 6],
|
||||
user=user,
|
||||
description='Reservation #2'
|
||||
),
|
||||
RackReservation(
|
||||
rack=racks[0],
|
||||
units=[7, 8, 9],
|
||||
user=user,
|
||||
description='Reservation #3',
|
||||
),
|
||||
)
|
||||
RackReservation.objects.bulk_create(rack_reservations)
|
||||
|
||||
@ -493,18 +508,21 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'rack': racks[1].pk,
|
||||
'units': [10, 11, 12],
|
||||
'status': RackReservationStatusChoices.STATUS_ACTIVE,
|
||||
'user': user.pk,
|
||||
'description': 'Reservation #4',
|
||||
},
|
||||
{
|
||||
'rack': racks[1].pk,
|
||||
'units': [13, 14, 15],
|
||||
'status': RackReservationStatusChoices.STATUS_PENDING,
|
||||
'user': user.pk,
|
||||
'description': 'Reservation #5',
|
||||
},
|
||||
{
|
||||
'rack': racks[1].pk,
|
||||
'units': [16, 17, 18],
|
||||
'status': RackReservationStatusChoices.STATUS_STALE,
|
||||
'user': user.pk,
|
||||
'description': 'Reservation #6',
|
||||
},
|
||||
@ -1247,7 +1265,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Platform
|
||||
brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
brief_fields = [
|
||||
'_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count',
|
||||
]
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Platform 4',
|
||||
@ -1274,7 +1294,8 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
Platform(name='Platform 3', slug='platform-3'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
|
||||
class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@ -1141,9 +1141,30 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
reservations = (
|
||||
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
|
||||
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
|
||||
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2], description='foobar3'),
|
||||
RackReservation(
|
||||
rack=racks[0],
|
||||
units=[1, 2, 3],
|
||||
status=RackReservationStatusChoices.STATUS_ACTIVE,
|
||||
user=users[0],
|
||||
tenant=tenants[0],
|
||||
description='foobar1',
|
||||
),
|
||||
RackReservation(
|
||||
rack=racks[1],
|
||||
units=[4, 5, 6],
|
||||
status=RackReservationStatusChoices.STATUS_PENDING,
|
||||
user=users[1],
|
||||
tenant=tenants[1],
|
||||
description='foobar2',
|
||||
),
|
||||
RackReservation(
|
||||
rack=racks[2],
|
||||
units=[7, 8, 9],
|
||||
status=RackReservationStatusChoices.STATUS_STALE,
|
||||
user=users[2],
|
||||
tenant=tenants[2],
|
||||
description='foobar3',
|
||||
),
|
||||
)
|
||||
RackReservation.objects.bulk_create(reservations)
|
||||
|
||||
@ -1179,6 +1200,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_user(self):
|
||||
users = User.objects.all()[:2]
|
||||
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||
@ -1256,7 +1281,8 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1]),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
device_types = (
|
||||
DeviceType(
|
||||
@ -2435,7 +2461,37 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
|
||||
Platform(name='Platform 4', slug='platform-4'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
child_platforms = (
|
||||
Platform(parent=platforms[0], name='Platform 1A', slug='platform-1a', manufacturer=manufacturers[0]),
|
||||
Platform(parent=platforms[1], name='Platform 2A', slug='platform-2a', manufacturer=manufacturers[1]),
|
||||
Platform(parent=platforms[2], name='Platform 3A', slug='platform-3a', manufacturer=manufacturers[2]),
|
||||
)
|
||||
for platform in child_platforms:
|
||||
platform.save()
|
||||
grandchild_platforms = (
|
||||
Platform(
|
||||
parent=child_platforms[0],
|
||||
name='Platform 1A1',
|
||||
slug='platform-1a1',
|
||||
manufacturer=manufacturers[0],
|
||||
),
|
||||
Platform(
|
||||
parent=child_platforms[1],
|
||||
name='Platform 2A1',
|
||||
slug='platform-2a1',
|
||||
manufacturer=manufacturers[1],
|
||||
),
|
||||
Platform(
|
||||
parent=child_platforms[2],
|
||||
name='Platform 3A1',
|
||||
slug='platform-3a1',
|
||||
manufacturer=manufacturers[2],
|
||||
),
|
||||
)
|
||||
for platform in grandchild_platforms:
|
||||
platform.save()
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foobar1'}
|
||||
@ -2453,12 +2509,26 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_parent(self):
|
||||
platforms = Platform.objects.filter(parent__isnull=True)[:2]
|
||||
params = {'parent_id': [platforms[0].pk, platforms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'parent': [platforms[0].slug, platforms[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_ancestor(self):
|
||||
platforms = Platform.objects.filter(parent__isnull=True)[:2]
|
||||
params = {'ancestor_id': [platforms[0].pk, platforms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'ancestor': [platforms[0].slug, platforms[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_manufacturer(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_available_for_device_type(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
@ -2469,7 +2539,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
u_height=1
|
||||
)
|
||||
params = {'available_for_device_type': device_type.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@ -2507,7 +2577,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
Platform(name='Platform 3', slug='platform-3'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
@ -2763,7 +2834,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device_type': [device_types[0].slug, device_types[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_devicerole(self):
|
||||
def test_role(self):
|
||||
roles = DeviceRole.objects.all()[:2]
|
||||
params = {'role_id': [roles[0].pk, roles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@ -3367,9 +3438,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
console_ports = (
|
||||
ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
|
||||
ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
|
||||
ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
|
||||
ConsolePort(
|
||||
device=devices[0],
|
||||
module=modules[0],
|
||||
name='Console Port 1',
|
||||
label='A',
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
ConsolePort(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='Console Port 2',
|
||||
label='B',
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
ConsolePort(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Console Port 3',
|
||||
label='C',
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
@ -3581,13 +3679,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
|
||||
console_server_ports = (
|
||||
ConsoleServerPort(
|
||||
device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'
|
||||
device=devices[0],
|
||||
module=modules[0],
|
||||
name='Console Server Port 1',
|
||||
label='A',
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
ConsoleServerPort(
|
||||
device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='Console Server Port 2',
|
||||
label='B',
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
ConsoleServerPort(
|
||||
device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Console Server Port 3',
|
||||
label='C',
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
@ -3807,6 +3926,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
maximum_draw=100,
|
||||
allocated_draw=50,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
PowerPort(
|
||||
device=devices[1],
|
||||
@ -3816,6 +3938,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
maximum_draw=200,
|
||||
allocated_draw=100,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
PowerPort(
|
||||
device=devices[2],
|
||||
@ -3825,6 +3950,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
maximum_draw=300,
|
||||
allocated_draw=150,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
@ -4053,6 +4181,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
description='First',
|
||||
color='ff0000',
|
||||
status=PowerOutletStatusChoices.STATUS_ENABLED,
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
PowerOutlet(
|
||||
device=devices[1],
|
||||
@ -4063,6 +4194,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
description='Second',
|
||||
color='00ff00',
|
||||
status=PowerOutletStatusChoices.STATUS_DISABLED,
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
PowerOutlet(
|
||||
device=devices[2],
|
||||
@ -4073,6 +4207,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
description='Third',
|
||||
color='0000ff',
|
||||
status=PowerOutletStatusChoices.STATUS_FAULTY,
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
@ -4307,6 +4444,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
virtual_chassis.master = devices[0]
|
||||
virtual_chassis.save()
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
@ -4381,13 +4521,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='VC Chassis Interface',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_SFP,
|
||||
enabled=True
|
||||
enabled=True,
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
@ -4406,6 +4552,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PD,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
@ -4424,6 +4573,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
|
||||
vlan_translation_policy=vlan_translation_policies[1],
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@ -4440,6 +4592,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
qinq_svlan=vlans[0],
|
||||
vlan_translation_policy=vlan_translation_policies[1],
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@ -4450,7 +4605,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mgmt_only=True,
|
||||
tx_power=40,
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
qinq_svlan=vlans[1]
|
||||
qinq_svlan=vlans[1],
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@ -4461,7 +4619,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mgmt_only=False,
|
||||
tx_power=40,
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
qinq_svlan=vlans[2]
|
||||
qinq_svlan=vlans[2],
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@ -4470,7 +4631,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rf_role=WirelessRoleChoices.ROLE_AP,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
|
||||
rf_channel_frequency=2412,
|
||||
rf_channel_width=22
|
||||
rf_channel_width=22,
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@ -4479,7 +4643,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rf_role=WirelessRoleChoices.ROLE_STATION,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
|
||||
rf_channel_frequency=5160,
|
||||
rf_channel_width=20
|
||||
rf_channel_width=20,
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
@ -4666,6 +4833,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_virtual_chassis_member_or_master(self):
|
||||
vc = VirtualChassis.objects.first()
|
||||
master = vc.master
|
||||
member = vc.members.exclude(pk=master.pk).first()
|
||||
params = {'virtual_chassis_member_or_master_id': [master.pk,]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'virtual_chassis_member_or_master_id': [member.pk,]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'virtual_chassis_member_or_master': [master.name,]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'virtual_chassis_member_or_master': [member.name,]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_virtual_chassis_member(self):
|
||||
# Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
|
||||
devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
|
||||
@ -4906,6 +5086,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rear_port=rear_ports[0],
|
||||
rear_port_position=1,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[1],
|
||||
@ -4917,6 +5100,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rear_port=rear_ports[1],
|
||||
rear_port_position=2,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[2],
|
||||
@ -4928,6 +5114,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rear_port=rear_ports[2],
|
||||
rear_port_position=3,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[3],
|
||||
@ -4936,6 +5125,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
rear_port=rear_ports[3],
|
||||
rear_port_position=1,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[3],
|
||||
@ -4944,6 +5136,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
rear_port=rear_ports[4],
|
||||
rear_port_position=1,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[3],
|
||||
@ -4952,6 +5147,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
rear_port=rear_ports[5],
|
||||
rear_port_position=1,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
)
|
||||
FrontPort.objects.bulk_create(front_ports)
|
||||
@ -5168,6 +5366,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
color=ColorChoices.COLOR_RED,
|
||||
positions=1,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[1],
|
||||
@ -5178,6 +5379,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
color=ColorChoices.COLOR_GREEN,
|
||||
positions=2,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[2],
|
||||
@ -5188,10 +5392,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
color=ColorChoices.COLOR_BLUE,
|
||||
positions=3,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[3],
|
||||
name='Rear Port 4',
|
||||
label='D',
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
positions=4,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[3],
|
||||
name='Rear Port 5',
|
||||
label='E',
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
positions=5,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[3],
|
||||
name='Rear Port 6',
|
||||
label='F',
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
positions=6,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
|
||||
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
|
||||
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
|
||||
)
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
|
||||
@ -5550,9 +5784,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
device_bays = (
|
||||
DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'),
|
||||
DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'),
|
||||
DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'),
|
||||
DeviceBay(
|
||||
device=devices[0],
|
||||
name='Device Bay 1',
|
||||
label='A',
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
DeviceBay(
|
||||
device=devices[1],
|
||||
name='Device Bay 2',
|
||||
label='B',
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
DeviceBay(
|
||||
device=devices[2],
|
||||
name='Device Bay 3',
|
||||
label='C',
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
DeviceBay.objects.bulk_create(device_bays)
|
||||
|
||||
|
||||
@ -337,6 +337,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
cls.form_data = {
|
||||
'rack': rack.pk,
|
||||
'units': "10,11,12",
|
||||
'status': RackReservationStatusChoices.STATUS_PENDING,
|
||||
'user': user3.pk,
|
||||
'tenant': None,
|
||||
'description': 'Rack reservation',
|
||||
@ -344,10 +345,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'site,location,rack,units,description',
|
||||
'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1',
|
||||
'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2',
|
||||
'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3',
|
||||
'site,location,rack,units,status,description',
|
||||
'Site 1,Location 1,Rack 1,"10,11,12",active,Reservation 1',
|
||||
'Site 1,Location 1,Rack 1,"13,14,15",pending,Reservation 2',
|
||||
'Site 1,Location 1,Rack 1,"16,17,18",stale,Reservation 3',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@ -358,6 +359,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'status': RackReservationStatusChoices.STATUS_STALE,
|
||||
'user': user3.pk,
|
||||
'tenant': None,
|
||||
'description': 'New description',
|
||||
@ -619,7 +621,8 @@ class DeviceTypeTestCase(
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
|
||||
Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
DeviceType.objects.bulk_create([
|
||||
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
|
||||
@ -1891,7 +1894,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
@ -1912,9 +1916,9 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
f"{platforms[0].pk},Platform 7,Fourth platform7",
|
||||
f"{platforms[1].pk},Platform 8,Fifth platform8",
|
||||
f"{platforms[2].pk},Platform 9,Sixth platform9",
|
||||
f"{platforms[0].pk},Foo,New description",
|
||||
f"{platforms[1].pk},Bar,New description",
|
||||
f"{platforms[2].pk},Baz,New description",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@ -1962,7 +1966,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Platform(name='Platform 1', slug='platform-1'),
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
|
||||
@ -2040,9 +2040,18 @@ class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(DeviceRole, 'list', path='', detail=False)
|
||||
class DeviceRoleListView(generic.ObjectListView):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=count_related(Device, 'role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
queryset = DeviceRole.objects.add_related_count(
|
||||
DeviceRole.objects.add_related_count(
|
||||
DeviceRole.objects.all(),
|
||||
VirtualMachine,
|
||||
'role',
|
||||
'vm_count',
|
||||
cumulative=True
|
||||
),
|
||||
Device,
|
||||
'role',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
)
|
||||
filterset = filtersets.DeviceRoleFilterSet
|
||||
filterset_form = forms.DeviceRoleFilterForm
|
||||
@ -2109,9 +2118,18 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Platform, 'list', path='', detail=False)
|
||||
class PlatformListView(generic.ObjectListView):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=count_related(Device, 'platform'),
|
||||
vm_count=count_related(VirtualMachine, 'platform')
|
||||
queryset = Platform.objects.add_related_count(
|
||||
Platform.objects.add_related_count(
|
||||
Platform.objects.all(),
|
||||
VirtualMachine,
|
||||
'platform',
|
||||
'vm_count',
|
||||
cumulative=True
|
||||
),
|
||||
Device,
|
||||
'platform',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
)
|
||||
table = tables.PlatformTable
|
||||
filterset = filtersets.PlatformFilterSet
|
||||
|
||||
@ -6,7 +6,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
|
||||
from dcim.api.serializers_.roles import DeviceRoleSerializer
|
||||
from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, Tag
|
||||
from extras.models import ConfigContext, ConfigContextProfile, Tag
|
||||
from netbox.api.fields import SerializedPKRelatedField
|
||||
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
|
||||
from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
|
||||
@ -15,11 +15,43 @@ from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterG
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextProfileSerializer',
|
||||
'ConfigContextSerializer',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
|
||||
tags = serializers.SlugRelatedField(
|
||||
queryset=Tag.objects.all(),
|
||||
slug_field='slug',
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
data_source = DataSourceSerializer(
|
||||
nested=True,
|
||||
required=False
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'tags', 'comments', 'data_source',
|
||||
'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
|
||||
profile = ConfigContextProfileSerializer(
|
||||
nested=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None,
|
||||
)
|
||||
regions = SerializedPKRelatedField(
|
||||
queryset=Region.objects.all(),
|
||||
serializer=RegionSerializer,
|
||||
@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'weight', 'profile', 'description', 'is_active', 'regions',
|
||||
'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path',
|
||||
'data_file', 'data_synced', 'data', 'created', 'last_updated',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
|
||||
'data_synced', 'data', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
@ -25,6 +25,7 @@ router.register('tagged-objects', views.TaggedItemViewSet)
|
||||
router.register('image-attachments', views.ImageAttachmentViewSet)
|
||||
router.register('journal-entries', views.JournalEntryViewSet)
|
||||
router.register('config-contexts', views.ConfigContextViewSet)
|
||||
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
|
||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
|
||||
|
||||
@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
serializer_class = serializers.ConfigContextProfileSerializer
|
||||
filterset_class = filtersets.ConfigContextProfileFilterSet
|
||||
|
||||
|
||||
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
|
||||
queryset = ConfigContext.objects.all()
|
||||
serializer_class = serializers.ConfigContextSerializer
|
||||
|
||||
@ -11,7 +11,7 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, resolve, reverse
|
||||
from django.urls import NoReverseMatch, resolve
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import ObjectType
|
||||
@ -21,7 +21,7 @@ from utilities.permissions import get_permission_for_model
|
||||
from utilities.proxy import resolve_proxies
|
||||
from utilities.querydict import dict_to_querydict
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.views import get_viewname
|
||||
from utilities.views import get_action_url
|
||||
from .utils import register_widget
|
||||
|
||||
__all__ = (
|
||||
@ -53,9 +53,9 @@ def object_list_widget_supports_model(model: Model) -> bool:
|
||||
"""
|
||||
def can_resolve_model_list_view(model: Model) -> bool:
|
||||
try:
|
||||
reverse(get_viewname(model, action='list'))
|
||||
get_action_url(model, action='list')
|
||||
return True
|
||||
except Exception:
|
||||
except NoReverseMatch:
|
||||
return False
|
||||
|
||||
tests = [
|
||||
@ -206,7 +206,7 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
permission = get_permission_for_model(model, 'view')
|
||||
if request.user.has_perm(permission):
|
||||
try:
|
||||
url = reverse(get_viewname(model, 'list'))
|
||||
url = get_action_url(model, action='list')
|
||||
except NoReverseMatch:
|
||||
url = None
|
||||
qs = model.objects.restrict(request.user, 'view')
|
||||
@ -275,15 +275,13 @@ class ObjectListWidget(DashboardWidget):
|
||||
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
|
||||
# embedded on the page: The view itself will also evaluate permissions separately.
|
||||
permission = get_permission_for_model(model, 'view')
|
||||
has_permission = request.user.has_perm(permission)
|
||||
|
||||
try:
|
||||
htmx_url = reverse(viewname)
|
||||
htmx_url = get_action_url(model, action='list')
|
||||
except NoReverseMatch:
|
||||
htmx_url = None
|
||||
parameters = self.config.get('url_params') or {}
|
||||
@ -297,7 +295,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
except ValueError:
|
||||
pass
|
||||
return render_to_string(self.template_name, {
|
||||
'viewname': viewname,
|
||||
'model_name': model_name,
|
||||
'has_permission': has_permission,
|
||||
'htmx_url': htmx_url,
|
||||
})
|
||||
@ -309,6 +307,7 @@ class RSSFeedWidget(DashboardWidget):
|
||||
default_config = {
|
||||
'max_entries': 10,
|
||||
'cache_timeout': 3600, # seconds
|
||||
'request_timeout': 3, # seconds
|
||||
'requires_internet': True,
|
||||
}
|
||||
description = _('Embed an RSS feed from an external website.')
|
||||
@ -335,6 +334,12 @@ class RSSFeedWidget(DashboardWidget):
|
||||
max_value=86400, # 24 hours
|
||||
help_text=_('How long to stored the cached content (in seconds)')
|
||||
)
|
||||
request_timeout = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=60,
|
||||
required=False,
|
||||
help_text=_('Timeout value for fetching the feed (in seconds)')
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
return render_to_string(self.template_name, {
|
||||
@ -366,7 +371,7 @@ class RSSFeedWidget(DashboardWidget):
|
||||
url=self.config['feed_url'],
|
||||
headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
|
||||
proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
|
||||
timeout=3
|
||||
timeout=self.config.get('request_timeout', 3),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
||||
@ -19,6 +19,7 @@ from .models import *
|
||||
__all__ = (
|
||||
'BookmarkFilterSet',
|
||||
'ConfigContextFilterSet',
|
||||
'ConfigContextProfileFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
'CustomFieldChoiceSetFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
@ -588,11 +589,51 @@ class TaggedItemFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
data_source_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DataSource.objects.all(),
|
||||
label=_('Data source (ID)'),
|
||||
)
|
||||
data_file_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DataSource.objects.all(),
|
||||
label=_('Data file (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = (
|
||||
'id', 'name', 'description', 'auto_sync_enabled', 'data_synced',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
profile_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
label=_('Profile (ID)'),
|
||||
)
|
||||
profile = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='profile__name',
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Profile (name)'),
|
||||
)
|
||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='regions',
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@ -13,12 +13,14 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextBulkEditForm',
|
||||
'ConfigContextProfileBulkEditForm',
|
||||
'ConfigTemplateBulkEditForm',
|
||||
'CustomFieldBulkEditForm',
|
||||
'CustomFieldChoiceSetBulkEditForm',
|
||||
'CustomLinkBulkEditForm',
|
||||
'EventRuleBulkEditForm',
|
||||
'ExportTemplateBulkEditForm',
|
||||
'ImageAttachmentBulkEditForm',
|
||||
'JournalEntryBulkEditForm',
|
||||
'NotificationGroupBulkEditForm',
|
||||
'SavedFilterBulkEditForm',
|
||||
@ -317,6 +319,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class ConfigContextProfileBulkEditForm(NetBoxModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
required=False,
|
||||
max_length=100
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = ConfigContextProfile
|
||||
fieldsets = (
|
||||
FieldSet('description',),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConfigContext.objects.all(),
|
||||
@ -327,6 +348,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
required=False,
|
||||
min_value=0
|
||||
)
|
||||
profile = DynamicModelChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
required=False
|
||||
)
|
||||
is_active = forms.NullBooleanField(
|
||||
label=_('Is active'),
|
||||
required=False,
|
||||
@ -338,7 +363,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
max_length=100
|
||||
)
|
||||
|
||||
nullable_fields = ('description',)
|
||||
fieldsets = (
|
||||
FieldSet('weight', 'profile', 'is_active', 'description'),
|
||||
)
|
||||
nullable_fields = ('profile', 'description')
|
||||
|
||||
|
||||
class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
@ -374,6 +402,18 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
|
||||
|
||||
|
||||
class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ImageAttachment.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=JournalEntry.objects.all(),
|
||||
|
||||
@ -18,6 +18,7 @@ from utilities.forms.fields import (
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextProfileImportForm',
|
||||
'ConfigTemplateImportForm',
|
||||
'CustomFieldChoiceSetImportForm',
|
||||
'CustomFieldImportForm',
|
||||
@ -149,6 +150,15 @@ class ExportTemplateImportForm(CSVModelForm):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = [
|
||||
'name', 'description', 'schema', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class ConfigTemplateImportForm(CSVModelForm):
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'ConfigContextProfileFilterForm',
|
||||
'ConfigTemplateFilterForm',
|
||||
'CustomFieldChoiceSetFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
@ -354,16 +355,43 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContextProfile
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
label=_('Data source')
|
||||
)
|
||||
data_file_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataFile.objects.all(),
|
||||
required=False,
|
||||
label=_('Data file'),
|
||||
query_params={
|
||||
'source_id': '$data_source_id'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag_id'),
|
||||
FieldSet('profile', name=_('Config Context')),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
|
||||
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
|
||||
)
|
||||
profile_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
required=False,
|
||||
label=_('Profile')
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
|
||||
@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
__all__ = (
|
||||
'BookmarkForm',
|
||||
'ConfigContextForm',
|
||||
'ConfigContextProfileForm',
|
||||
'ConfigTemplateForm',
|
||||
'CustomFieldChoiceSetForm',
|
||||
'CustomFieldForm',
|
||||
@ -585,7 +586,36 @@ class TagForm(ChangelogMessageMixin, forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
class ConfigContextProfileForm(SyncedDataMixin, NetBoxModelForm):
|
||||
schema = JSONField(
|
||||
label=_('Schema'),
|
||||
required=False,
|
||||
help_text=_("Enter a valid JSON schema to define supported attributes.")
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
label=_('Tags'),
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')),
|
||||
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigContextProfile
|
||||
fields = (
|
||||
'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'comments', 'tags',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
|
||||
profile = DynamicModelChoiceField(
|
||||
label=_('Profile'),
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
required=False
|
||||
)
|
||||
regions = DynamicModelMultipleChoiceField(
|
||||
label=_('Regions'),
|
||||
queryset=Region.objects.all(),
|
||||
@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'weight', 'description', 'data', 'is_active', name=_('Config Context')),
|
||||
FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')),
|
||||
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
|
||||
FieldSet(
|
||||
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||
@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
|
||||
'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
|
||||
'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
|
||||
)
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
@ -8,7 +8,7 @@ from strawberry_django import FilterLookup
|
||||
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
|
||||
from extras import models
|
||||
from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
|
||||
from netbox.graphql.filter_mixins import SyncedDataFilterMixin
|
||||
from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.filters import ContentTypeFilter
|
||||
@ -24,6 +24,7 @@ if TYPE_CHECKING:
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilter',
|
||||
'ConfigContextProfileFilter',
|
||||
'ConfigTemplateFilter',
|
||||
'CustomFieldFilter',
|
||||
'CustomFieldChoiceSetFilter',
|
||||
@ -97,6 +98,13 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
|
||||
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin):
|
||||
name: FilterLookup[str] = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] = strawberry_django.filter_field()
|
||||
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
|
||||
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@ -11,6 +11,9 @@ class ExtrasQuery:
|
||||
config_context: ConfigContextType = strawberry_django.field()
|
||||
config_context_list: List[ConfigContextType] = strawberry_django.field()
|
||||
|
||||
config_context_profile: ConfigContextProfileType = strawberry_django.field()
|
||||
config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field()
|
||||
|
||||
config_template: ConfigTemplateType = strawberry_django.field()
|
||||
config_template_list: List[ConfigTemplateType] = strawberry_django.field()
|
||||
|
||||
|
||||
@ -3,13 +3,13 @@ from typing import Annotated, List, TYPE_CHECKING
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
|
||||
from core.graphql.mixins import SyncedDataMixin
|
||||
from extras import models
|
||||
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
||||
from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType
|
||||
from netbox.graphql.types import BaseObjectType, ContentTypeType, NetBoxObjectType, ObjectType, OrganizationalObjectType
|
||||
from .filters import *
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.graphql.types import DataFileType, DataSourceType
|
||||
from dcim.graphql.types import (
|
||||
DeviceRoleType,
|
||||
DeviceType,
|
||||
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
||||
from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextProfileType',
|
||||
'ConfigContextType',
|
||||
'ConfigTemplateType',
|
||||
'CustomFieldChoiceSetType',
|
||||
@ -44,15 +45,24 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.ConfigContextProfile,
|
||||
fields='__all__',
|
||||
filters=ConfigContextProfileFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ConfigContextProfileType(SyncedDataMixin, NetBoxObjectType):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.ConfigContext,
|
||||
fields='__all__',
|
||||
filters=ConfigContextFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ConfigContextType(ObjectType):
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
class ConfigContextType(SyncedDataMixin, ObjectType):
|
||||
profile: ConfigContextProfileType | None
|
||||
roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
|
||||
device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
|
||||
tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
|
||||
@ -74,10 +84,7 @@ class ConfigContextType(ObjectType):
|
||||
filters=ConfigTemplateFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ConfigTemplateType(TagsMixin, ObjectType):
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
|
||||
class ConfigTemplateType(SyncedDataMixin, TagsMixin, ObjectType):
|
||||
virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
|
||||
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
|
||||
platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
|
||||
@ -123,9 +130,8 @@ class CustomLinkType(ObjectType):
|
||||
filters=ExportTemplateFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ExportTemplateType(ObjectType):
|
||||
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||
class ExportTemplateType(SyncedDataMixin, ObjectType):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
||||
@ -59,6 +59,7 @@ class ScriptJob(JobRunner):
|
||||
else:
|
||||
script.log_failure(msg)
|
||||
logger.error(f"Script aborted with error: {e}")
|
||||
self.logger.error(f"Script aborted with error: {e}")
|
||||
|
||||
else:
|
||||
stacktrace = traceback.format_exc()
|
||||
@ -66,9 +67,11 @@ class ScriptJob(JobRunner):
|
||||
message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
|
||||
)
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
self.logger.error(f"Exception raised during script execution: {e}")
|
||||
|
||||
if type(e) is not AbortTransaction:
|
||||
script.log_info(message=_("Database changes have been reverted due to error."))
|
||||
self.logger.info("Database changes have been reverted due to error.")
|
||||
|
||||
# Clear all pending events. Job termination (including setting the status) is handled by the job framework.
|
||||
if request:
|
||||
@ -108,9 +111,11 @@ class ScriptJob(JobRunner):
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
self.logger.info("Executing script (commit enabled)")
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
else:
|
||||
self.logger.warning("Executing script (commit disabled)")
|
||||
self.run_script(script, request, data, commit)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.db.models import CharField, Lookup
|
||||
from django.db.models import CharField, JSONField, Lookup
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
|
||||
from .fields import CachedValueField
|
||||
|
||||
@ -18,6 +19,30 @@ class Empty(Lookup):
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||
|
||||
|
||||
class JSONEmpty(Lookup):
|
||||
"""
|
||||
Support "empty" lookups for JSONField keys.
|
||||
|
||||
A key is considered empty if it is "", null, or does not exist.
|
||||
"""
|
||||
lookup_name = "empty"
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
# self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
|
||||
# Rebuild the expression using KeyTextTransform to guarantee ->> (text)
|
||||
text_expr = KeyTextTransform(self.lhs.key_name, self.lhs.lhs)
|
||||
lhs_sql, lhs_params = compiler.compile(text_expr)
|
||||
|
||||
value = self.rhs
|
||||
if value not in (True, False):
|
||||
raise ValueError("The 'empty' lookup only accepts True or False.")
|
||||
|
||||
condition = '' if value else 'NOT '
|
||||
sql = f"(NULLIF({lhs_sql}, '') IS {condition}NULL)"
|
||||
|
||||
return sql, lhs_params
|
||||
|
||||
|
||||
class NetHost(Lookup):
|
||||
"""
|
||||
Similar to ipam.lookups.NetHost, but casts the field to INET.
|
||||
@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup):
|
||||
|
||||
|
||||
CharField.register_lookup(Empty)
|
||||
JSONField.register_lookup(JSONEmpty)
|
||||
CachedValueField.register_lookup(NetHost)
|
||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||
|
||||
75
netbox/extras/migrations/0132_configcontextprofile.py
Normal file
75
netbox/extras/migrations/0132_configcontextprofile.py
Normal file
@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-08 16:40
|
||||
|
||||
import django.db.models.deletion
|
||||
import netbox.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
import utilities.jsonschema
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0018_concrete_objecttype'),
|
||||
('extras', '0131_concrete_objecttype'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ConfigContextProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
(
|
||||
'custom_field_data',
|
||||
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
||||
),
|
||||
('data_path', models.CharField(blank=True, editable=False, max_length=1000)),
|
||||
('auto_sync_enabled', models.BooleanField(default=False)),
|
||||
('data_synced', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])),
|
||||
(
|
||||
'data_file',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='core.datafile',
|
||||
),
|
||||
),
|
||||
(
|
||||
'data_source',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='+',
|
||||
to='core.datasource',
|
||||
),
|
||||
),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'config context profile',
|
||||
'verbose_name_plural': 'config context profiles',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='profile',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='config_contexts',
|
||||
to='extras.configcontextprofile',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,6 @@
|
||||
import jsonschema
|
||||
from collections import defaultdict
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import ValidationError
|
||||
@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.models import ObjectType
|
||||
from extras.models.mixins import RenderTemplateMixin
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from utilities.data import deepmerge
|
||||
from utilities.jsonschema import validate_schema
|
||||
|
||||
__all__ = (
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'ConfigContextProfile',
|
||||
'ConfigTemplate',
|
||||
)
|
||||
|
||||
@ -24,6 +28,46 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
|
||||
"""
|
||||
A profile which can be used to enforce parameters on a ConfigContext.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
schema = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[validate_schema],
|
||||
verbose_name=_('schema'),
|
||||
help_text=_('A JSON schema specifying the structure of the context data for this profile')
|
||||
)
|
||||
|
||||
clone_fields = ('schema',)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('config context profile')
|
||||
verbose_name_plural = _('config context profiles')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def sync_data(self):
|
||||
"""
|
||||
Synchronize schema from the designated DataFile (if any).
|
||||
"""
|
||||
self.schema = self.data_file.get_data()
|
||||
sync_data.alters_data = True
|
||||
|
||||
|
||||
class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
@ -35,6 +79,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
profile = models.ForeignKey(
|
||||
to='extras.ConfigContextProfile',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='config_contexts',
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('weight'),
|
||||
default=1000
|
||||
@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
|
||||
objects = ConfigContextQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data',
|
||||
'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
|
||||
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
# Validate config data against the assigned profile's schema (if any)
|
||||
if self.profile and self.profile.schema:
|
||||
try:
|
||||
jsonschema.validate(self.data, schema=self.profile.schema)
|
||||
except JSONValidationError as e:
|
||||
raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e))
|
||||
|
||||
def sync_data(self):
|
||||
"""
|
||||
Synchronize context data from the designated DataFile (if any).
|
||||
|
||||
@ -600,11 +600,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
kwargs = {
|
||||
'field_name': f'custom_field_data__{self.name}'
|
||||
}
|
||||
# Native numeric filters will use `isnull` by default for empty lookups, but
|
||||
# JSON fields require `empty` (see bug #20012).
|
||||
if lookup_expr == 'isnull':
|
||||
lookup_expr = 'empty'
|
||||
if lookup_expr is not None:
|
||||
kwargs['lookup_expr'] = lookup_expr
|
||||
|
||||
# 'Empty' lookup is always a boolean
|
||||
if lookup_expr == 'empty':
|
||||
filter_class = django_filters.BooleanFilter
|
||||
|
||||
# Text/URL
|
||||
if self.type in (
|
||||
elif self.type in (
|
||||
CustomFieldTypeChoices.TYPE_TEXT,
|
||||
CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
CustomFieldTypeChoices.TYPE_URL,
|
||||
|
||||
@ -872,6 +872,9 @@ class Bookmark(models.Model):
|
||||
return str(self.object)
|
||||
return super().__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('account:bookmarks')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@ -173,14 +173,17 @@ class NotificationGroup(ChangeLoggedModel):
|
||||
User.objects.filter(groups__in=self.groups.all())
|
||||
).order_by('username')
|
||||
|
||||
def notify(self, **kwargs):
|
||||
def notify(self, object_type, object_id, **kwargs):
|
||||
"""
|
||||
Bulk-create Notifications for all members of this group.
|
||||
"""
|
||||
Notification.objects.bulk_create([
|
||||
Notification(user=member, **kwargs)
|
||||
for member in self.members
|
||||
])
|
||||
for user in self.members:
|
||||
Notification.objects.update_or_create(
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
user=user,
|
||||
defaults=kwargs
|
||||
)
|
||||
notify.alters_data = True
|
||||
|
||||
|
||||
|
||||
@ -588,9 +588,9 @@ class BaseScript:
|
||||
"""
|
||||
Return data from a YAML file
|
||||
"""
|
||||
# TODO: DEPRECATED: Remove this method in v4.4
|
||||
# TODO: DEPRECATED: Remove this method in v4.5
|
||||
self._log(
|
||||
_("load_yaml is deprecated and will be removed in v4.4"),
|
||||
_("load_yaml is deprecated and will be removed in v4.5"),
|
||||
level=LogLevelChoices.LOG_WARNING
|
||||
)
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
@ -603,9 +603,9 @@ class BaseScript:
|
||||
"""
|
||||
Return data from a JSON file
|
||||
"""
|
||||
# TODO: DEPRECATED: Remove this method in v4.4
|
||||
# TODO: DEPRECATED: Remove this method in v4.5
|
||||
self._log(
|
||||
_("load_json is deprecated and will be removed in v4.4"),
|
||||
_("load_json is deprecated and will be removed in v4.5"),
|
||||
level=LogLevelChoices.LOG_WARNING
|
||||
)
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
|
||||
@ -2,6 +2,17 @@ from netbox.search import SearchIndex, register_search
|
||||
from . import models
|
||||
|
||||
|
||||
@register_search
|
||||
class ConfigContextProfileIndex(SearchIndex):
|
||||
model = models.ConfigContextProfile
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class CustomFieldIndex(SearchIndex):
|
||||
model = models.CustomField
|
||||
|
||||
@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
|
||||
|
||||
__all__ = (
|
||||
'BookmarkTable',
|
||||
'ConfigContextProfileTable',
|
||||
'ConfigContextTable',
|
||||
'ConfigTemplateTable',
|
||||
'CustomFieldChoiceSetTable',
|
||||
@ -39,9 +40,8 @@ __all__ = (
|
||||
|
||||
IMAGEATTACHMENT_IMAGE = """
|
||||
{% if record.image %}
|
||||
<a class="image-preview" href="{{ record.image.url }}" target="_blank">
|
||||
<i class="mdi mdi-image"></i>
|
||||
</a>
|
||||
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
|
||||
<i class="mdi mdi-image"></i></a>
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
|
||||
"""
|
||||
@ -235,6 +235,7 @@ class ImageAttachmentTable(NetBoxTable):
|
||||
image = columns.TemplateColumn(
|
||||
verbose_name=_('Image'),
|
||||
template_code=IMAGEATTACHMENT_IMAGE,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
@ -254,7 +255,7 @@ class ImageAttachmentTable(NetBoxTable):
|
||||
verbose_name=_('Object Type'),
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
verbose_name=_('Object'),
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
)
|
||||
@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
|
||||
fields = ('id', 'content_type', 'content_object')
|
||||
|
||||
|
||||
class ConfigContextProfileTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
data_source = tables.Column(
|
||||
verbose_name=_('Data Source'),
|
||||
linkify=True
|
||||
)
|
||||
data_file = tables.Column(
|
||||
verbose_name=_('Data File'),
|
||||
linkify=True
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name=_('Synced')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:configcontextprofile_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContextProfile
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'description', 'comments', 'data_source', 'data_file', 'is_synced', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'is_synced', 'description')
|
||||
|
||||
|
||||
class ConfigContextTable(NetBoxTable):
|
||||
profile = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Profile'),
|
||||
)
|
||||
data_source = tables.Column(
|
||||
verbose_name=_('Data Source'),
|
||||
linkify=True
|
||||
@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description', 'regions', 'sites',
|
||||
'locations', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||
default_columns = ('pk', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description')
|
||||
|
||||
|
||||
class ConfigTemplateTable(NetBoxTable):
|
||||
|
||||
@ -666,6 +666,70 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
|
||||
|
||||
class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContextProfile
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Config Context Profile 4',
|
||||
},
|
||||
{
|
||||
'name': 'Config Context Profile 5',
|
||||
},
|
||||
{
|
||||
'name': 'Config Context Profile 6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 1',
|
||||
schema={
|
||||
"properties": {
|
||||
"foo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"foo"
|
||||
]
|
||||
}
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 2',
|
||||
schema={
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"bar"
|
||||
]
|
||||
}
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 3',
|
||||
schema={
|
||||
"properties": {
|
||||
"baz": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baz"
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
|
||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContext
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
|
||||
@ -1615,6 +1615,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf11': manufacturers[2].pk,
|
||||
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
|
||||
}),
|
||||
Site(name='Site 4', slug='site-4'),
|
||||
])
|
||||
|
||||
def test_filter_integer(self):
|
||||
@ -1624,6 +1625,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_decimal(self):
|
||||
self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
|
||||
@ -1632,6 +1634,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_boolean(self):
|
||||
self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
|
||||
@ -1648,6 +1651,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_text_loose(self):
|
||||
self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
|
||||
@ -1659,6 +1663,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_url_strict(self):
|
||||
self.assertEqual(
|
||||
@ -1674,17 +1679,20 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
|
||||
self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_url_loose(self):
|
||||
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_filter_select(self):
|
||||
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) # Contains a literal null
|
||||
self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_object(self):
|
||||
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
|
||||
@ -1692,6 +1700,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
|
||||
2
|
||||
)
|
||||
self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_multiobject(self):
|
||||
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
|
||||
@ -1703,3 +1712,4 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
|
||||
3
|
||||
)
|
||||
self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)
|
||||
|
||||
@ -45,4 +45,4 @@ class ObjectListWidgetTests(TestCase):
|
||||
mock_request = Request()
|
||||
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
|
||||
rendered = widget.render(mock_request)
|
||||
self.assertTrue('Unable to load content. Invalid view name:' in rendered)
|
||||
self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)
|
||||
|
||||
@ -871,6 +871,39 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
filterset = ConfigContextProfileFilterSet
|
||||
ignore_fields = ('schema', 'data_path')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 1',
|
||||
description='foo',
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 2',
|
||||
description='bar',
|
||||
),
|
||||
ConfigContextProfile(
|
||||
name='Config Context Profile 3',
|
||||
description='baz',
|
||||
),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foo'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_name(self):
|
||||
profiles = self.queryset.all()[:2]
|
||||
params = {'name': [profiles[0].name, profiles[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = ConfigContextFilterSet
|
||||
@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
profiles = (
|
||||
ConfigContextProfile(name='Config Context Profile 1'),
|
||||
ConfigContextProfile(name='Config Context Profile 2'),
|
||||
ConfigContextProfile(name='Config Context Profile 3'),
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
@ -931,7 +970,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
Platform(name='Platform 3', slug='platform-3'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
for platform in platforms:
|
||||
platform.save()
|
||||
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
@ -975,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
is_active = bool(i % 2)
|
||||
c = ConfigContext.objects.create(
|
||||
name=f"Config Context {i + 1}",
|
||||
profile=profiles[i],
|
||||
is_active=is_active,
|
||||
data='{"foo": 123}',
|
||||
description=f"foobar{i + 1}"
|
||||
@ -1011,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_profile(self):
|
||||
profiles = ConfigContextProfile.objects.all()[:2]
|
||||
params = {'profile_id': [profiles[0].pk, profiles[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'profile': [profiles[0].name, profiles[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@ -1184,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'cluster',
|
||||
'clustergroup',
|
||||
'clustertype',
|
||||
'configcontextprofile',
|
||||
'configtemplate',
|
||||
'consoleport',
|
||||
'consoleserverport',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user