Merge branch 'feature' of https://github.com/netbox-community/netbox into feature-ip-prefix-link

This commit is contained in:
Daniel Sheppard 2025-09-03 22:15:38 -05:00
commit 90d277610c
244 changed files with 14260 additions and 11062 deletions

View File

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

View File

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

3
.github/codeql/codeql-config.yml vendored Normal file
View File

@ -0,0 +1,3 @@
paths-ignore:
# Ignore compiled JS
- netbox/project-static/dist

42
.github/workflows/codeql.yml vendored Normal file
View 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}}"

View File

@ -106,7 +106,11 @@ mkdocs-material
# Introspection for embedded code # Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md # 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 # Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
@ -135,7 +139,8 @@ requests
# rq # rq
# https://github.com/rq/rq/blob/master/CHANGES.md # 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 # Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md

View File

@ -95,6 +95,7 @@
"iec-60320-c8", "iec-60320-c8",
"iec-60320-c14", "iec-60320-c14",
"iec-60320-c16", "iec-60320-c16",
"iec-60320-c18",
"iec-60320-c20", "iec-60320-c20",
"iec-60320-c22", "iec-60320-c22",
"iec-60309-p-n-e-4h", "iec-60309-p-n-e-4h",
@ -209,6 +210,7 @@
"iec-60320-c7", "iec-60320-c7",
"iec-60320-c13", "iec-60320-c13",
"iec-60320-c15", "iec-60320-c15",
"iec-60320-c17",
"iec-60320-c19", "iec-60320-c19",
"iec-60320-c21", "iec-60320-c21",
"iec-60309-p-n-e-4h", "iec-60309-p-n-e-4h",
@ -474,6 +476,13 @@
"passive-48v-2pair", "passive-48v-2pair",
"passive-48v-4pair" "passive-48v-4pair"
] ]
},
"rf_role": {
"type": "string",
"enum": [
"ap",
"station"
]
} }
} }
}, },

View File

@ -4,7 +4,7 @@
### Enabling Error Reporting ### 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 ```python
SENTRY_ENABLED = True SENTRY_ENABLED = True

View File

@ -22,24 +22,9 @@ Stores registration made using `netbox.denormalized.register()`. For each model,
### `model_features` ### `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 Core model features are listed in the [features matrix](./models.md#features-matrix).
{
'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).
### `models` ### `models`

View File

@ -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). 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 | | Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|--------------------|-----------------------------------------------------------------------------------------| |------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | - | Changes to these objects are automatically recorded in the change log | | [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| Cloning | `CloningMixin` | - | Provides the `clone()` method to prepare a copy | | [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields | | Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links | | [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules | | [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models | | [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
| [Job results](../features/background-jobs.md) | `JobsMixin` | `jobs` | Background jobs can be scheduled for these models | | [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary | | [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events |
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source | | [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags | | [Image attachments](../models/extras/imageattachment.md) | `ImageAttachmentsMixin` | `image_attachments` | Image uploads can be attached to these models |
| [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events | | [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 ## Models Index

View File

@ -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`. 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 ### Update System Requirements
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change: 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 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 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`) * Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)

View File

@ -25,42 +25,21 @@ NetBox requires the following dependencies:
### Version History ### Version History
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation | | 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.4 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.4.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.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.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.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.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) | | 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.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) | | 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.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.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.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.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.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.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.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.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.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.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.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.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.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.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.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) | | 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.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) |
## 3. Install the Latest Release ## 3. Install the Latest Release

View File

@ -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. 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. 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 ## Fields
## Parent
!!! "This field was introduced in NetBox v4.4."
The parent platform class to which this platform belongs (optional).
### Name ### Name
A human-friendly name for the platform. Must be unique per manufacturer. A human-friendly name for the platform. Must be unique per manufacturer.

View File

@ -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. 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 ### User
The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users. The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.

View File

@ -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. 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 ### Data
The context data expressed in JSON format. The context data expressed in JSON format.

View 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).

View File

@ -24,20 +24,7 @@ Every model includes by default a numeric primary key. This value is generated a
## Enabling NetBox Features ## 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: 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:
* Bookmarks
* Change logging
* Cloning
* Custom fields
* Custom links
* Custom validation
* Export templates
* Journaling
* Tags
* Webhooks
This class performs two crucial functions:
1. Apply any fields, methods, and/or attributes necessary to the operation of these features 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 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 ::: 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 ## 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.) 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.)

View File

@ -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`. 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 ::: netbox.tables.BooleanColumn
options: options:
members: false members: false

View File

@ -89,7 +89,7 @@ The following condition will evaluate as true:
``` ```
!!! note "Evaluating static choice fields" !!! 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 ## Condition Sets

View File

@ -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) #### [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)) * 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)) * 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) #### [Version 4.3](./version-4.3.md) (May 2025)

View File

@ -1,5 +1,60 @@
# NetBox v4.3 # 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) ## v4.3.5 (2025-07-29)
### Enhancements ### Enhancements
@ -16,6 +71,11 @@
* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form * [#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 * [#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) ## v4.3.4 (2025-07-15)
### Enhancements ### Enhancements

View File

@ -1,6 +1,6 @@
# NetBox v4.4 # NetBox v4.4
## v4.4.0 (FUTURE) ## v4.4.0 (2025-09-02)
### New Features ### 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. 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`. 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. 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 ### Enhancements
* [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 ### 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` * [#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 ### 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) * [#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/` * [#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) * [#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 ### 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/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/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: * 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` * `is_plugin_model`
* `rest_api_endpoint` * `rest_api_endpoint`
* `description` * `description`
* Introduced the `/api/extras/config-context-profiles/` endpoint
* core.Job
* Added the read-only `log_entries` array field
* dcim.Interface * dcim.Interface
* The `tx_power` field now accepts negative values * 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 * extras.ImageAttachment
* Added an optional `description` field * Added an optional `description` field

View File

@ -30,6 +30,8 @@ plugins:
python: python:
paths: ["netbox"] paths: ["netbox"]
options: options:
docstring_options:
warn_missing_types: false
heading_level: 3 heading_level: 3
members_order: source members_order: source
show_root_heading: true show_root_heading: true
@ -226,6 +228,7 @@ nav:
- Extras: - Extras:
- Bookmark: 'models/extras/bookmark.md' - Bookmark: 'models/extras/bookmark.md'
- ConfigContext: 'models/extras/configcontext.md' - ConfigContext: 'models/extras/configcontext.md'
- ConfigContextProfile: 'models/extras/configcontextprofile.md'
- ConfigTemplate: 'models/extras/configtemplate.md' - ConfigTemplate: 'models/extras/configtemplate.md'
- CustomField: 'models/extras/customfield.md' - CustomField: 'models/extras/customfield.md'
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md' - CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'

View File

@ -35,11 +35,7 @@ urlpatterns = [
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))), path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
# Virtual circuits # Virtual circuits
path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'), path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))),
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/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))), path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))), path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),

View File

@ -687,6 +687,7 @@ class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
# Virtual circuits # Virtual circuits
# #
@register_model_view(VirtualCircuit, 'list', path='', detail=False)
class VirtualCircuitListView(generic.ObjectListView): class VirtualCircuitListView(generic.ObjectListView):
queryset = VirtualCircuit.objects.annotate( queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@ -701,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView):
queryset = VirtualCircuit.objects.all() queryset = VirtualCircuit.objects.all()
@register_model_view(VirtualCircuit, 'add', detail=False)
@register_model_view(VirtualCircuit, 'edit') @register_model_view(VirtualCircuit, 'edit')
class VirtualCircuitEditView(generic.ObjectEditView): class VirtualCircuitEditView(generic.ObjectEditView):
queryset = VirtualCircuit.objects.all() queryset = VirtualCircuit.objects.all()
@ -712,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuit.objects.all() queryset = VirtualCircuit.objects.all()
@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False)
class VirtualCircuitBulkImportView(generic.BulkImportView): class VirtualCircuitBulkImportView(generic.BulkImportView):
queryset = VirtualCircuit.objects.all() queryset = VirtualCircuit.objects.all()
model_form = forms.VirtualCircuitImportForm model_form = forms.VirtualCircuitImportForm
@ -727,6 +730,7 @@ class VirtualCircuitBulkImportView(generic.BulkImportView):
return data return data
@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False)
class VirtualCircuitBulkEditView(generic.BulkEditView): class VirtualCircuitBulkEditView(generic.BulkEditView):
queryset = VirtualCircuit.objects.annotate( queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') 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) @register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitulkRenameView(generic.BulkRenameView): class VirtualCircuitBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all() queryset = VirtualCircuit.objects.all()
field_name = 'cid' field_name = 'cid'
@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView): class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate( queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')

View File

@ -1,13 +1,13 @@
import inspect import inspect
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer from netbox.api.serializers import BaseModelSerializer
from utilities.views import get_viewname from utilities.views import get_action_url
__all__ = ( __all__ = (
'ObjectTypeSerializer', 'ObjectTypeSerializer',
@ -15,7 +15,7 @@ __all__ = (
class ObjectTypeSerializer(BaseModelSerializer): 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) app_name = serializers.CharField(source='app_verbose_name', read_only=True)
model_name = serializers.CharField(source='model_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) model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
@ -26,19 +26,19 @@ class ObjectTypeSerializer(BaseModelSerializer):
class Meta: class Meta:
model = ObjectType model = ObjectType
fields = [ fields = [
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', 'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', 'public',
'is_plugin_model', 'rest_api_endpoint', 'description', 'features', 'is_plugin_model', 'rest_api_endpoint', 'description',
] ]
read_only_fields = ['public', 'features']
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def get_rest_api_endpoint(self, obj): def get_rest_api_endpoint(self, obj):
if not (model := obj.model_class()): if not (model := obj.model_class()):
return return
if viewname := get_viewname(model, action='list', rest_api=True): try:
try: return get_action_url(model, action='list', rest_api=True)
return reverse(viewname) except NoReverseMatch:
except NoReverseMatch: return
return
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def get_description(self, obj): def get_description(self, obj):

View File

@ -18,8 +18,8 @@ class BackgroundTaskSerializer(serializers.Serializer):
description = serializers.CharField() description = serializers.CharField()
origin = serializers.CharField() origin = serializers.CharField()
func_name = serializers.CharField() func_name = serializers.CharField()
args = serializers.ListField(child=serializers.CharField()) args = serializers.SerializerMethodField()
kwargs = serializers.DictField() kwargs = serializers.SerializerMethodField()
result = serializers.CharField() result = serializers.CharField()
timeout = serializers.IntegerField() timeout = serializers.IntegerField()
result_ttl = serializers.IntegerField() result_ttl = serializers.IntegerField()
@ -42,6 +42,16 @@ class BackgroundTaskSerializer(serializers.Serializer):
is_scheduled = serializers.BooleanField() is_scheduled = serializers.BooleanField()
is_stopped = 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: def get_position(self, obj) -> int:
return obj.get_position() return obj.get_position()

View File

@ -9,7 +9,7 @@ router.APIRootView = views.CoreRootView
router.register('data-sources', views.DataSourceViewSet) router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet) router.register('data-files', views.DataFileViewSet)
router.register('jobs', views.JobViewSet) 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('object-types', views.ObjectTypeViewSet)
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue') router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker') router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')

View File

@ -78,10 +78,12 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
Retrieve a list of recent changes. Retrieve a list of recent changes.
""" """
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.valid_models()
serializer_class = serializers.ObjectChangeSerializer serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet
def get_queryset(self):
return ObjectChange.objects.valid_models()
class ObjectTypeViewSet(ReadOnlyModelViewSet): class ObjectTypeViewSet(ReadOnlyModelViewSet):
""" """

View File

@ -134,15 +134,18 @@ class JobFilterSet(BaseFilterSet):
) )
class ObjectTypeFilterSet(django_filters.FilterSet): class ObjectTypeFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label=_('Search'), label=_('Search'),
) )
features = django_filters.CharFilter(
method='filter_features'
)
class Meta: class Meta:
model = ObjectType model = ObjectType
fields = ('id', 'app_label', 'model') fields = ('id', 'app_label', 'model', 'public')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -152,6 +155,9 @@ class ObjectTypeFilterSet(django_filters.FilterSet):
Q(model__icontains=value) Q(model__icontains=value)
) )
def filter_features(self, queryset, name, value):
return queryset.filter(features__icontains=value)
class ObjectChangeFilterSet(BaseFilterSet): class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(

View File

@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
from core.models import ObjectChange from core.models import ObjectChange
if TYPE_CHECKING: if TYPE_CHECKING:
from core.graphql.types import DataFileType, DataSourceType
from netbox.core.graphql.types import ObjectChangeType from netbox.core.graphql.types import ObjectChangeType
__all__ = ( __all__ = (
'ChangelogMixin', 'ChangelogMixin',
'SyncedDataMixin',
) )
@ -25,3 +27,9 @@ class ChangelogMixin:
changed_object_id=self.pk changed_object_id=self.pk
) )
return object_changes.restrict(info.context.request.user, 'view') 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

View File

@ -1,4 +1,3 @@
import logging
import sys import sys
from datetime import timedelta from datetime import timedelta
from importlib import import_module from importlib import import_module
@ -17,8 +16,6 @@ from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices from .choices import DataSourceStatusChoices, JobIntervalChoices
from .models import DataSource from .models import DataSource
logger = logging.getLogger(__name__)
class SyncDataSourceJob(JobRunner): class SyncDataSourceJob(JobRunner):
""" """
@ -69,7 +66,11 @@ class SystemHousekeepingJob(JobRunner):
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
# Skip if running in development or test mode # 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 return
self.send_census_report() self.send_census_report()
@ -78,17 +79,16 @@ class SystemHousekeepingJob(JobRunner):
self.delete_expired_jobs() self.delete_expired_jobs()
self.check_for_new_releases() self.check_for_new_releases()
@staticmethod def send_census_report(self):
def send_census_report():
""" """
Send a census report (if enabled). Send a census report (if enabled).
""" """
logging.info("Reporting census data...") self.logger.info("Reporting census data...")
if settings.ISOLATED_DEPLOYMENT: if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping") self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return return
if not settings.CENSUS_REPORTING_ENABLED: if not settings.CENSUS_REPORTING_ENABLED:
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping") self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
return return
census_data = { census_data = {
@ -106,73 +106,71 @@ class SystemHousekeepingJob(JobRunner):
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
pass pass
@staticmethod def clear_expired_sessions(self):
def clear_expired_sessions():
""" """
Clear any expired sessions from the database. Clear any expired sessions from the database.
""" """
logging.info("Clearing expired sessions...") self.logger.info("Clearing expired sessions...")
engine = import_module(settings.SESSION_ENGINE) engine = import_module(settings.SESSION_ENGINE)
try: try:
engine.SessionStore.clear_expired() engine.SessionStore.clear_expired()
logging.info("Sessions cleared.") self.logger.info("Sessions cleared.")
except NotImplementedError: except NotImplementedError:
logging.warning( self.logger.warning(
f"The configured session engine ({settings.SESSION_ENGINE}) does not support " f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping." f"clearing sessions; skipping."
) )
@staticmethod def prune_changelog(self):
def prune_changelog():
""" """
Delete any ObjectChange records older than the configured changelog retention time (if any). 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() config = Config()
if not config.CHANGELOG_RETENTION: if not config.CHANGELOG_RETENTION:
logging.info("No retention period specified; skipping.") self.logger.info("No retention period specified; skipping.")
return return
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION) cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days") self.logger.debug(
logging.debug(f"Cut-off time: {cutoff}") f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
)
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0] 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(self):
def delete_expired_jobs():
""" """
Delete any jobs older than the configured retention period (if any). Delete any jobs older than the configured retention period (if any).
""" """
logging.info("Deleting expired jobs...") self.logger.info("Deleting expired jobs...")
config = Config() config = Config()
if not config.JOB_RETENTION: if not config.JOB_RETENTION:
logging.info("No retention period specified; skipping.") self.logger.info("No retention period specified; skipping.")
return return
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION) cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days") self.logger.debug(
logging.debug(f"Cut-off time: {cutoff}") f"Job retention period: {config.JOB_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
)
count = Job.objects.filter(created__lt=cutoff).delete()[0] 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(self):
def check_for_new_releases():
""" """
Check for new releases and cache the latest release. 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: if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping") self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return return
if not settings.RELEASE_CHECK_URL: 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 return
# Fetch the latest releases # 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: try:
response = requests.get( response = requests.get(
url=settings.RELEASE_CHECK_URL, url=settings.RELEASE_CHECK_URL,
@ -181,7 +179,7 @@ class SystemHousekeepingJob(JobRunner):
) )
response.raise_for_status() response.raise_for_status()
except requests.exceptions.RequestException as exc: except requests.exceptions.RequestException as exc:
logging.error(f"Error fetching release: {exc}") self.logger.error(f"Error fetching release: {exc}")
return return
# Determine the most recent stable release # Determine the most recent stable release
@ -191,8 +189,8 @@ class SystemHousekeepingJob(JobRunner):
continue continue
releases.append((version.parse(release['tag_name']), release.get('html_url'))) releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases) latest_release = max(releases)
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable") self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
logging.info(f"Latest release: {latest_release[0]}") self.logger.info(f"Latest release: {latest_release[0]}")
# Cache the most recent release # Cache the most recent release
cache.set('latest_release', latest_release, None) cache.set('latest_release', latest_release, None)

View File

@ -78,8 +78,8 @@ class Command(BaseCommand):
for app_label in app_labels: for app_label in app_labels:
app_name = apps.get_app_config(app_label).verbose_name app_name = apps.get_app_config(app_label).verbose_name
print(f'{app_name}:') print(f'{app_name}:')
for m in self.django_models[app_label]: for model in self.django_models[app_label]:
print(f' {m}') print(f' {app_label}.{model}')
def get_namespace(self): def get_namespace(self):
namespace = defaultdict(SimpleNamespace) namespace = defaultdict(SimpleNamespace)

View File

@ -1,3 +1,4 @@
import inspect
from collections import defaultdict from collections import defaultdict
from django.contrib.contenttypes.models import ContentType 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. Retrieve or create and return the ObjectType for a model.
""" """
from netbox.models.features import get_model_features, model_is_public 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) opts = self._get_opts(model, for_concrete_model)
try: try:
@ -75,7 +79,7 @@ class ObjectTypeManager(models.Manager):
app_label=opts.app_label, app_label=opts.app_label,
model=opts.model_name, model=opts.model_name,
public=model_is_public(model), public=model_is_public(model),
features=get_model_features(model.__class__), features=get_model_features(model),
)[0] )[0]
return ot return ot
@ -93,6 +97,8 @@ class ObjectTypeManager(models.Manager):
needed_models = defaultdict(set) needed_models = defaultdict(set)
needed_opts = defaultdict(list) needed_opts = defaultdict(list)
for model in models: for model in models:
if not inspect.isclass(model):
model = model.__class__
opts = self._get_opts(model, for_concrete_models) opts = self._get_opts(model, for_concrete_models)
needed_models[opts.app_label].add(opts.model_name) needed_models[opts.app_label].add(opts.model_name)
needed_opts[(opts.app_label, opts.model_name)].append(model) needed_opts[(opts.app_label, opts.model_name)].append(model)
@ -117,7 +123,7 @@ class ObjectTypeManager(models.Manager):
app_label=app_label, app_label=app_label,
model=model_name, model=model_name,
public=model_is_public(model), public=model_is_public(model),
features=get_model_features(model.__class__), features=get_model_features(model),
) )
return results return results
@ -135,9 +141,9 @@ class ObjectTypeManager(models.Manager):
""" """
Return ObjectTypes only for models which support the given feature. 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 Only ObjectTypes which list the specified feature will be included. Supported features are declared in the
netbox.models.features.FEATURES_MAP. For example, we can find all ObjectTypes for models which support event application registry under `registry["model_features"]`. For example, we can find all ObjectTypes for models
rules with: which support event rules with:
ObjectType.objects.with_feature('event_rules') ObjectType.objects.with_feature('event_rules')
""" """

View File

@ -14,6 +14,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.events import * from core.events import *
from core.models import ObjectType from core.models import ObjectType
from extras.events import enqueue_event from extras.events import enqueue_event
from extras.models import Tag
from extras.utils import run_validators from extras.utils import run_validators
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, events_queue 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 with objects added or removed
m2m_changed = True m2m_changed = True
event_type = OBJECT_UPDATED 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: else:
return return

View File

@ -241,3 +241,48 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]} params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) 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(),
)

View File

@ -1,3 +1,4 @@
import json
import urllib.parse import urllib.parse
import uuid import uuid
from datetime import datetime from datetime import datetime
@ -366,6 +367,11 @@ class SystemTestCase(TestCase):
# Test export # Test export
response = self.client.get(f"{reverse('core:system')}?export=true") response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200) 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): def test_system_view_with_config_revision(self):
ConfigRevision.objects.create() ConfigRevision.objects.create()

View File

@ -1,7 +1,7 @@
import json import json
import platform import platform
from django import __version__ as DJANGO_VERSION from django import __version__ as django_version
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin 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 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.config import get_config, PARAMS
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject 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 import generic
from netbox.views.generic.base import BaseObjectView from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.apps import get_installed_apps
from utilities.data import shallow_compare_dict from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
@ -216,17 +217,23 @@ class JobBulkDeleteView(generic.BulkDeleteView):
@register_model_view(ObjectChange, 'list', path='', detail=False) @register_model_view(ObjectChange, 'list', path='', detail=False)
class ObjectChangeListView(generic.ObjectListView): class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.valid_models() queryset = None
filterset = filtersets.ObjectChangeFilterSet filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html' template_name = 'core/objectchange_list.html'
actions = (BulkExport,) actions = (BulkExport,)
def get_queryset(self, request):
return ObjectChange.objects.valid_models()
@register_model_view(ObjectChange) @register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView): 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): def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
@ -546,7 +553,7 @@ class SystemView(UserPassesTestMixin, View):
def get(self, request): def get(self, request):
# System stats # System status
psql_version = db_name = db_size = None psql_version = db_name = db_size = None
try: try:
with connection.cursor() as cursor: with connection.cursor() as cursor:
@ -561,7 +568,7 @@ class SystemView(UserPassesTestMixin, View):
pass pass
stats = { stats = {
'netbox_release': settings.RELEASE, 'netbox_release': settings.RELEASE,
'django_version': DJANGO_VERSION, 'django_version': django_version,
'python_version': platform.python_version(), 'python_version': platform.python_version(),
'postgresql_version': psql_version, 'postgresql_version': psql_version,
'database_name': db_name, 'database_name': db_name,
@ -569,19 +576,35 @@ class SystemView(UserPassesTestMixin, View):
'rq_worker_count': Worker.count(get_connection('default')), 'rq_worker_count': Worker.count(get_connection('default')),
} }
# Django apps
django_apps = get_installed_apps()
# Configuration # Configuration
config = get_config() 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 # Raw data export
if 'export' in request.GET: if 'export' in request.GET:
stats['netbox_release'] = stats['netbox_release'].asdict() stats['netbox_release'] = stats['netbox_release'].asdict()
params = [param.name for param in PARAMS] params = [param.name for param in PARAMS]
data = { data = {
**stats, **stats,
'plugins': registry['plugins']['installed'], 'django_apps': django_apps,
'plugins': plugins,
'config': { 'config': {
k: getattr(config, k) for k in sorted(params) 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 = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
response['Content-Disposition'] = 'attachment; filename="netbox.json"' response['Content-Disposition'] = 'attachment; filename="netbox.json"'
@ -594,7 +617,10 @@ class SystemView(UserPassesTestMixin, View):
return render(request, 'core/system.html', { return render(request, 'core/system.html', {
'stats': stats, 'stats': stats,
'django_apps': django_apps,
'config': config, 'config': config,
'plugins': plugins,
'objects': objects,
}) })

View File

@ -6,11 +6,13 @@ from dcim import models
__all__ = ( __all__ = (
'NestedDeviceBaySerializer', 'NestedDeviceBaySerializer',
'NestedDeviceRoleSerializer',
'NestedDeviceSerializer', 'NestedDeviceSerializer',
'NestedInterfaceSerializer', 'NestedInterfaceSerializer',
'NestedInterfaceTemplateSerializer', 'NestedInterfaceTemplateSerializer',
'NestedLocationSerializer', 'NestedLocationSerializer',
'NestedModuleBaySerializer', 'NestedModuleBaySerializer',
'NestedPlatformSerializer',
'NestedRegionSerializer', 'NestedRegionSerializer',
'NestedSiteGroupSerializer', 'NestedSiteGroupSerializer',
) )
@ -102,3 +104,10 @@ class NestedModuleBaySerializer(WritableNestedSerializer):
class Meta: class Meta:
model = models.ModuleBay model = models.ModuleBay
fields = ['id', 'url', 'display_url', 'display', 'name'] fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedPlatformSerializer(WritableNestedSerializer):
class Meta:
model = models.Platform
fields = ['id', 'url', 'display_url', 'display', 'name']

View File

@ -1,26 +1,32 @@
from rest_framework import serializers
from dcim.models import Platform from dcim.models import Platform
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField from netbox.api.serializers import NestedGroupModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from .manufacturers import ManufacturerSerializer from .manufacturers import ManufacturerSerializer
from .nested import NestedPlatformSerializer
__all__ = ( __all__ = (
'PlatformSerializer', '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) manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
# Related object counts # Related object counts
device_count = RelatedObjectCountField('devices') device_count = serializers.IntegerField(read_only=True, default=0)
virtualmachine_count = RelatedObjectCountField('virtual_machines') virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', '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',
)

View File

@ -137,17 +137,29 @@ class RackSerializer(RackBaseSerializer):
class RackReservationSerializer(NetBoxModelSerializer): class RackReservationSerializer(NetBoxModelSerializer):
rack = RackSerializer(nested=True) rack = RackSerializer(
user = UserSerializer(nested=True) nested=True,
tenant = TenantSerializer(nested=True, required=False, allow_null=True) )
status = ChoiceField(
choices=RackReservationStatusChoices,
required=False,
)
user = UserSerializer(
nested=True,
)
tenant = TenantSerializer(
nested=True,
required=False,
allow_null=True,
)
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
'description', 'comments', 'tags', 'custom_fields', '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): class RackElevationDetailFilterSerializer(serializers.Serializer):

View File

@ -1,3 +1,5 @@
from rest_framework import serializers
from dcim.models import DeviceRole, InventoryItemRole from dcim.models import DeviceRole, InventoryItemRole
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField
@ -13,10 +15,8 @@ __all__ = (
class DeviceRoleSerializer(NestedGroupModelSerializer): class DeviceRoleSerializer(NestedGroupModelSerializer):
parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None) parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True, default=0)
# Related object counts virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta: class Meta:
model = DeviceRole model = DeviceRole

View File

@ -20,6 +20,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from virtualization.models import VirtualMachine
from . import serializers from . import serializers
from .exceptions import MissingFilterException from .exceptions import MissingFilterException
@ -351,7 +352,19 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# #
class DeviceRoleViewSet(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 serializer_class = serializers.DeviceRoleSerializer
filterset_class = filtersets.DeviceRoleFilterSet filterset_class = filtersets.DeviceRoleFilterSet
@ -360,8 +373,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
# Platforms # Platforms
# #
class PlatformViewSet(NetBoxModelViewSet): class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Platform.objects.all() 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 serializer_class = serializers.PlatformSerializer
filterset_class = filtersets.PlatformFilterSet filterset_class = filtersets.PlatformFilterSet

View File

@ -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 # DeviceTypes
# #
@ -344,6 +362,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_C8 = 'iec-60320-c8' TYPE_IEC_C8 = 'iec-60320-c8'
TYPE_IEC_C14 = 'iec-60320-c14' TYPE_IEC_C14 = 'iec-60320-c14'
TYPE_IEC_C16 = 'iec-60320-c16' TYPE_IEC_C16 = 'iec-60320-c16'
TYPE_IEC_C18 = 'iec-60320-c18'
TYPE_IEC_C20 = 'iec-60320-c20' TYPE_IEC_C20 = 'iec-60320-c20'
TYPE_IEC_C22 = 'iec-60320-c22' TYPE_IEC_C22 = 'iec-60320-c22'
# IEC 60309 # IEC 60309
@ -462,6 +481,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_C8, 'C8'), (TYPE_IEC_C8, 'C8'),
(TYPE_IEC_C14, 'C14'), (TYPE_IEC_C14, 'C14'),
(TYPE_IEC_C16, 'C16'), (TYPE_IEC_C16, 'C16'),
(TYPE_IEC_C18, 'C18'),
(TYPE_IEC_C20, 'C20'), (TYPE_IEC_C20, 'C20'),
(TYPE_IEC_C22, 'C22'), (TYPE_IEC_C22, 'C22'),
)), )),
@ -599,6 +619,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_C7 = 'iec-60320-c7' TYPE_IEC_C7 = 'iec-60320-c7'
TYPE_IEC_C13 = 'iec-60320-c13' TYPE_IEC_C13 = 'iec-60320-c13'
TYPE_IEC_C15 = 'iec-60320-c15' TYPE_IEC_C15 = 'iec-60320-c15'
TYPE_IEC_C17 = 'iec-60320-c17'
TYPE_IEC_C19 = 'iec-60320-c19' TYPE_IEC_C19 = 'iec-60320-c19'
TYPE_IEC_C21 = 'iec-60320-c21' TYPE_IEC_C21 = 'iec-60320-c21'
# IEC 60309 # IEC 60309
@ -711,6 +732,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_C7, 'C7'), (TYPE_IEC_C7, 'C7'),
(TYPE_IEC_C13, 'C13'), (TYPE_IEC_C13, 'C13'),
(TYPE_IEC_C15, 'C15'), (TYPE_IEC_C15, 'C15'),
(TYPE_IEC_C17, 'C17'),
(TYPE_IEC_C19, 'C19'), (TYPE_IEC_C19, 'C19'),
(TYPE_IEC_C21, 'C21'), (TYPE_IEC_C21, 'C21'),
)), )),

View File

@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Location (slug)'), label=_('Location (slug)'),
) )
status = django_filters.MultipleChoiceFilter(
choices=RackReservationStatusChoices,
null_value=None
)
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
@ -547,14 +551,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
to_field_name='slug', to_field_name='slug',
label=_('Manufacturer (slug)'), label=_('Manufacturer (slug)'),
) )
default_platform_id = django_filters.ModelMultipleChoiceFilter( default_platform_id = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
field_name='default_platform',
lookup_expr='in',
label=_('Default platform (ID)'), label=_('Default platform (ID)'),
) )
default_platform = django_filters.ModelMultipleChoiceFilter( default_platform = TreeNodeMultipleChoiceFilter(
field_name='default_platform__slug',
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
field_name='default_platform',
to_field_name='slug', to_field_name='slug',
lookup_expr='in',
label=_('Default platform (slug)'), label=_('Default platform (slug)'),
) )
has_front_image = django_filters.BooleanFilter( has_front_image = django_filters.BooleanFilter(
@ -979,6 +986,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
class PlatformFilterSet(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( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer', field_name='manufacturer',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -1058,14 +1088,17 @@ class DeviceFilterSet(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label=_('Parent Device (ID)'), label=_('Parent Device (ID)'),
) )
platform_id = django_filters.ModelMultipleChoiceFilter( platform_id = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
field_name='platform',
lookup_expr='in',
label=_('Platform (ID)'), label=_('Platform (ID)'),
) )
platform = django_filters.ModelMultipleChoiceFilter( platform = TreeNodeMultipleChoiceFilter(
field_name='platform__slug', field_name='platform',
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
to_field_name='slug', to_field_name='slug',
lookup_expr='in',
label=_('Platform (slug)'), label=_('Platform (slug)'),
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
@ -1515,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
label=_('Site group (slug)'), label=_('Site group (slug)'),
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site', field_name='_site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
label=_('Site (ID)'), label=_('Site (ID)'),
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug', field_name='_site__slug',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Site name (slug)'), label=_('Site name (slug)'),
) )
location_id = django_filters.ModelMultipleChoiceFilter( location_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__location', field_name='_location',
queryset=Location.objects.all(), queryset=Location.objects.all(),
label=_('Location (ID)'), label=_('Location (ID)'),
) )
location = django_filters.ModelMultipleChoiceFilter( location = django_filters.ModelMultipleChoiceFilter(
field_name='device__location__slug', field_name='_location__slug',
queryset=Location.objects.all(), queryset=Location.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Location (slug)'), label=_('Location (slug)'),
) )
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack', field_name='_rack',
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
label=_('Rack (ID)'), label=_('Rack (ID)'),
) )
rack = django_filters.ModelMultipleChoiceFilter( rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name', field_name='_rack__name',
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
to_field_name='name', to_field_name='name',
label=_('Rack (name)'), label=_('Rack (name)'),
@ -1885,6 +1918,16 @@ class InterfaceFilterSet(
PathEndpointFilterSet, PathEndpointFilterSet,
CommonInterfaceFilterSet 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( virtual_chassis_member = MultiValueCharFilter(
method='filter_virtual_chassis_member', method='filter_virtual_chassis_member',
field_name='name', field_name='name',
@ -1995,11 +2038,14 @@ class InterfaceFilterSet(
'cable_id', 'cable_end', '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: try:
vc_interface_ids = [] vc_interface_ids = []
for device in Device.objects.filter(**{f'{name}__in': value}): 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) return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:
return queryset.none() return queryset.none()

View File

@ -69,11 +69,14 @@ class PowerPortBulkCreateForm(
class PowerOutletBulkCreateForm( class PowerOutletBulkCreateForm(
form_from_model(PowerOutlet, ['type', 'color', 'feed_leg', 'mark_connected']), form_from_model(PowerOutlet, ['type', 'status', 'color', 'feed_leg', 'mark_connected']),
DeviceBulkAddComponentForm DeviceBulkAddComponentForm
): ):
model = PowerOutlet 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( class InterfaceBulkCreateForm(

View File

@ -476,6 +476,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
class RackReservationBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(RackReservationStatusChoices),
required=False,
initial=''
)
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_('User'), label=_('User'),
queryset=User.objects.order_by('username'), queryset=User.objects.order_by('username'),
@ -495,7 +501,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
FieldSet('user', 'tenant', 'description'), FieldSet('status', 'user', 'tenant', 'description'),
) )
nullable_fields = ('comments',) nullable_fields = ('comments',)
@ -682,6 +688,11 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
class PlatformBulkEditForm(NetBoxModelBulkEditForm): class PlatformBulkEditForm(NetBoxModelBulkEditForm):
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Platform.objects.all(),
required=False,
)
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -697,12 +708,13 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField()
model = Platform model = Platform
fieldsets = ( 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): class DeviceBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
required=True, required=True,
help_text=_('Comma-separated list of individual unit numbers') help_text=_('Comma-separated list of individual unit numbers')
) )
status = CSVChoiceField(
label=_('Status'),
choices=RackReservationStatusChoices,
help_text=_('Operational status')
)
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'), label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = RackReservation 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): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)
@ -504,6 +509,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class PlatformImportForm(NetBoxModelImportForm): class PlatformImportForm(NetBoxModelImportForm):
slug = SlugField() 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( manufacturer = CSVModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -522,7 +537,7 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ( 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) 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 # Limit device bay queryset by parent device
if parent := data.get('parent'): if parent := data.get('parent'):
params = {f"device__{self.fields['parent'].to_field_name}": parent} params = {f"device__{self.fields['parent'].to_field_name}": parent}

View File

@ -19,6 +19,11 @@ def get_cable_form(a_type, b_type):
# Device component # Device component
if hasattr(term_cls, 'device'): 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( attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label=_('Device'), label=_('Device'),
@ -36,7 +41,7 @@ def get_cable_form(a_type, b_type):
'parent': 'device', 'parent': 'device',
}, },
query_params={ query_params={
'device_id': f'$termination_{cable_end}_device', query_param_device_field: f'$termination_{cable_end}_device',
'kind': 'physical', # Exclude virtual interfaces 'kind': 'physical', # Exclude virtual interfaces
} }
) )

View File

@ -417,7 +417,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), 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('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
) )
@ -458,6 +458,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
}, },
label=_('Rack') label=_('Rack')
) )
status = forms.MultipleChoiceField(
label=_('Status'),
choices=RackReservationStatusChoices,
required=False
)
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
required=False, required=False,
@ -714,6 +719,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
class PlatformFilterForm(NetBoxModelFilterSetForm): class PlatformFilterForm(NetBoxModelFilterSetForm):
model = Platform model = Platform
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
parent_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Parent')
)
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False, required=False,

View File

@ -336,14 +336,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')), FieldSet('rack', 'units', 'status', 'user', 'description', 'tags', name=_('Reservation')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
) )
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ 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): class PlatformForm(NetBoxModelForm):
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Platform.objects.all(),
required=False,
)
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -551,15 +556,18 @@ class PlatformForm(NetBoxModelForm):
label=_('Slug'), label=_('Slug'),
max_length=64 max_length=64
) )
comments = CommentField()
fieldsets = ( fieldsets = (
FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')), FieldSet(
'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform'),
),
) )
class Meta: class Meta:
model = Platform model = Platform
fields = [ 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'), label=_('Interface'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
selector=True,
context={ context={
'parent': 'device', 'parent': 'device',
}, },
@ -1899,6 +1908,7 @@ class MACAddressForm(NetBoxModelForm):
label=_('VM Interface'), label=_('VM Interface'),
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
selector=True,
context={ context={
'parent': 'virtual_machine', 'parent': 'virtual_machine',
}, },

View File

@ -633,6 +633,8 @@ class ModuleTypeType(NetBoxObjectType):
pagination=True pagination=True
) )
class PlatformType(OrganizationalObjectType): 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 manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None

View File

@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment
from dcim.choices import * from dcim.choices import *
from netbox.choices import WeightUnitChoices from netbox.choices import WeightUnitChoices
from wireless.choices import WirelessRoleChoices
TEMPLATE_FILENAME = 'devicetype_schema.jinja2' TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
OUTPUT_FILENAME = 'contrib/generated_schema.json' OUTPUT_FILENAME = 'contrib/generated_schema.json'
@ -23,6 +24,7 @@ CHOICES_MAP = {
'interface_type_choices': InterfaceTypeChoices, 'interface_type_choices': InterfaceTypeChoices,
'interface_poe_mode_choices': InterfacePoEModeChoices, 'interface_poe_mode_choices': InterfacePoEModeChoices,
'interface_poe_type_choices': InterfacePoETypeChoices, 'interface_poe_type_choices': InterfacePoETypeChoices,
'interface_rf_role_choices': WirelessRoleChoices,
'front_port_type_choices': PortTypeChoices, 'front_port_type_choices': PortTypeChoices,
'rear_port_type_choices': PortTypeChoices, 'rear_port_type_choices': PortTypeChoices,
} }

View File

@ -3,6 +3,7 @@ import taggit.managers
from django.db import migrations, models from django.db import migrations, models
import utilities.json import utilities.json
import utilities.jsonschema
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -25,7 +26,7 @@ class Migration(migrations.Migration):
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)), ('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=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')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
], ],
options={ options={

View File

@ -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),
]

View 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'
},
),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0208_devicerole_uniqueness'), ('dcim', '0210_macaddress_ordering'),
('extras', '0129_fix_script_paths'), ('extras', '0129_fix_script_paths'),
] ]

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0209_platform_manufacturer_uniqueness'), ('dcim', '0211_platform_manufacturer_uniqueness'),
] ]
operations = [ operations = [

View 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),
),
]

View 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
),
]

View 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),
),
]

View File

@ -12,6 +12,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import PathField from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node from dcim.utils import decompile_path_node, object_to_path_node
from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters from utilities.conversion import to_meters
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
@ -156,6 +157,15 @@ class Cable(PrimaryModel):
self._terminations_modified = True self._terminations_modified = True
self._b_terminations = value 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): def clean(self):
super().clean() super().clean()

View File

@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
blank=True 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: class Meta:
abstract = True abstract = True
ordering = ('device', 'name') ordering = ('device', 'name')
@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
"device": _("Components cannot be moved to a different device.") "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 @property
def parent_object(self): def parent_object(self):
return self.device return self.device

View File

@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models 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.functions import Lower
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.urls import reverse 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.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import ColorField, CounterCacheField from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin from utilities.tracking import TrackingModelMixin
from .device_components import * from .device_components import *
from .mixins import RenderConfigMixin from .mixins import RenderConfigMixin
@ -424,7 +425,7 @@ class DeviceRole(NestedGroupModel):
verbose_name_plural = _('device roles') 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 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. Platform may optionally be associated with a particular Manufacturer.
@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
null=True, null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer') 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( config_template = models.ForeignKey(
to='extras.ConfigTemplate', to='extras.ConfigTemplate',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -454,6 +446,8 @@ class Platform(OrganizationalModel):
null=True null=True
) )
clone_fields = ('parent', 'description')
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
verbose_name = _('platform') verbose_name = _('platform')
@ -955,7 +949,10 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model): if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components: for component in components:
component.custom_field_data = cf_defaults 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 # Manually send the post_save signal for each of the newly created components
for component in components: for component in components:
post_save.send( post_save.send(
@ -1303,7 +1300,7 @@ class MACAddress(PrimaryModel):
) )
class Meta: class Meta:
ordering = ('mac_address',) ordering = ('mac_address', 'pk',)
verbose_name = _('MAC address') verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses') verbose_name_plural = _('MAC addresses')

View File

@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
schema = models.JSONField( schema = models.JSONField(
blank=True, blank=True,
null=True, null=True,
verbose_name=_('schema') validators=[validate_schema],
verbose_name=_('schema'),
) )
clone_fields = ('schema',) clone_fields = ('schema',)
@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
def __str__(self): def __str__(self):
return self.name 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): class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
""" """

View File

@ -673,6 +673,12 @@ class RackReservation(PrimaryModel):
verbose_name=_('units'), verbose_name=_('units'),
base_field=models.PositiveSmallIntegerField() base_field=models.PositiveSmallIntegerField()
) )
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=RackReservationStatusChoices,
default=RackReservationStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -733,6 +739,9 @@ class RackReservation(PrimaryModel):
def unit_list(self): def unit_list(self):
return array_to_string(self.units) return array_to_string(self.units)
def get_status_color(self):
return RackReservationStatusChoices.colors.get(self.status)
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
objectchange.related_object = self.rack objectchange.related_object = self.rack

View File

@ -20,10 +20,7 @@ class BulkAddComponents(ObjectAction):
@classmethod @classmethod
def get_context(cls, context, obj): def get_context(cls, context, obj):
return { return {
'perms': context.get('perms'),
'request': context.get('request'),
'formaction': context.get('formaction'), 'formaction': context.get('formaction'),
'label': cls.label,
} }

View File

@ -3,13 +3,28 @@ import logging
from django.db.models.signals import post_save, post_delete, pre_delete from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices from dcim.choices import CableEndChoices, LinkStatusChoices
from .models import ( 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 .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths from .utils import create_cablepath, rebuild_paths
COMPONENT_MODELS = (
ConsolePort,
ConsoleServerPort,
DeviceBay,
FrontPort,
Interface,
InventoryItem,
ModuleBay,
PowerOutlet,
PowerPort,
RearPort,
)
# #
# Location/rack/device assignment # 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) 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 # Virtual chassis
# #

View File

@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
order_by=('_abs_length') order_by=('_abs_length')
) )
color = columns.ColorColumn() color = columns.ColorColumn()
color_name = tables.Column(
verbose_name=_('Color Name'),
orderable=False
)
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:cable_list' url_name='dcim:cable_list'
@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
fields = ( fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', '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', '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 = ( default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

View File

@ -103,10 +103,14 @@ class DeviceRoleTable(NetBoxTable):
# #
class PlatformTable(NetBoxTable): class PlatformTable(NetBoxTable):
name = tables.Column( name = columns.MPTTColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True,
)
manufacturer = tables.Column( manufacturer = tables.Column(
verbose_name=_('Manufacturer'), verbose_name=_('Manufacturer'),
linkify=True linkify=True
@ -132,8 +136,8 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.Platform model = models.Platform
fields = ( fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description', 'pk', 'id', 'name', 'parent', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template',
'tags', 'actions', 'created', 'last_updated', 'description', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',

View File

@ -229,6 +229,9 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
orderable=False, orderable=False,
verbose_name=_('Units') verbose_name=_('Units')
) )
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
) )
@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = RackReservation model = RackReservation
fields = ( 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', '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')

View File

@ -465,7 +465,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
class RackReservationTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase):
model = RackReservation model = RackReservation
brief_fields = ['description', 'display', 'id', 'units', 'url', 'user'] brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -483,9 +483,24 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
rack_reservations = ( rack_reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=user, description='Reservation #1'), RackReservation(
RackReservation(rack=racks[0], units=[4, 5, 6], user=user, description='Reservation #2'), rack=racks[0],
RackReservation(rack=racks[0], units=[7, 8, 9], user=user, description='Reservation #3'), 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) RackReservation.objects.bulk_create(rack_reservations)
@ -493,18 +508,21 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
{ {
'rack': racks[1].pk, 'rack': racks[1].pk,
'units': [10, 11, 12], 'units': [10, 11, 12],
'status': RackReservationStatusChoices.STATUS_ACTIVE,
'user': user.pk, 'user': user.pk,
'description': 'Reservation #4', 'description': 'Reservation #4',
}, },
{ {
'rack': racks[1].pk, 'rack': racks[1].pk,
'units': [13, 14, 15], 'units': [13, 14, 15],
'status': RackReservationStatusChoices.STATUS_PENDING,
'user': user.pk, 'user': user.pk,
'description': 'Reservation #5', 'description': 'Reservation #5',
}, },
{ {
'rack': racks[1].pk, 'rack': racks[1].pk,
'units': [16, 17, 18], 'units': [16, 17, 18],
'status': RackReservationStatusChoices.STATUS_STALE,
'user': user.pk, 'user': user.pk,
'description': 'Reservation #6', 'description': 'Reservation #6',
}, },
@ -1247,7 +1265,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
class PlatformTest(APIViewTestCases.APIViewTestCase): class PlatformTest(APIViewTestCases.APIViewTestCase):
model = Platform 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 = [ create_data = [
{ {
'name': 'Platform 4', 'name': 'Platform 4',
@ -1274,7 +1294,8 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'), Platform(name='Platform 3', slug='platform-3'),
) )
Platform.objects.bulk_create(platforms) for platform in platforms:
platform.save()
class DeviceTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase):

View File

@ -1141,9 +1141,30 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
reservations = ( reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'), RackReservation(
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'), rack=racks[0],
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2], description='foobar3'), 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) RackReservation.objects.bulk_create(reservations)
@ -1179,6 +1200,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]} params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_user(self):
users = User.objects.all()[:2] users = User.objects.all()[:2]
params = {'user_id': [users[0].pk, users[1].pk]} 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 2', slug='platform-2', manufacturer=manufacturers[1]),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
) )
Platform.objects.bulk_create(platforms) for platform in platforms:
platform.save()
device_types = ( device_types = (
DeviceType( DeviceType(
@ -2435,7 +2461,37 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
Platform(name='Platform 4', slug='platform-4'), 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): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
@ -2453,12 +2509,26 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_manufacturer(self):
manufacturers = Manufacturer.objects.all()[:2] manufacturers = Manufacturer.objects.all()[:2]
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} 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]} 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): def test_available_for_device_type(self):
manufacturers = Manufacturer.objects.all()[:2] manufacturers = Manufacturer.objects.all()[:2]
@ -2469,7 +2539,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
u_height=1 u_height=1
) )
params = {'available_for_device_type': device_type.pk} 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): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -2507,7 +2577,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'), Platform(name='Platform 3', slug='platform-3'),
) )
Platform.objects.bulk_create(platforms) for platform in platforms:
platform.save()
regions = ( regions = (
Region(name='Region 1', slug='region-1'), 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]} params = {'device_type': [device_types[0].slug, device_types[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_devicerole(self): def test_role(self):
roles = DeviceRole.objects.all()[:2] roles = DeviceRole.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]} params = {'role_id': [roles[0].pk, roles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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) ConsoleServerPort.objects.bulk_create(console_server_ports)
console_ports = ( console_ports = (
ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'), ConsolePort(
ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'), device=devices[0],
ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'), 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) ConsolePort.objects.bulk_create(console_ports)
@ -3581,13 +3679,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
console_server_ports = ( console_server_ports = (
ConsoleServerPort( 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( 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( 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) ConsoleServerPort.objects.bulk_create(console_server_ports)
@ -3807,6 +3926,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
maximum_draw=100, maximum_draw=100,
allocated_draw=50, allocated_draw=50,
description='First', description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
), ),
PowerPort( PowerPort(
device=devices[1], device=devices[1],
@ -3816,6 +3938,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
maximum_draw=200, maximum_draw=200,
allocated_draw=100, allocated_draw=100,
description='Second', description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
), ),
PowerPort( PowerPort(
device=devices[2], device=devices[2],
@ -3825,6 +3950,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
maximum_draw=300, maximum_draw=300,
allocated_draw=150, allocated_draw=150,
description='Third', description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
), ),
) )
PowerPort.objects.bulk_create(power_ports) PowerPort.objects.bulk_create(power_ports)
@ -4053,6 +4181,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
description='First', description='First',
color='ff0000', color='ff0000',
status=PowerOutletStatusChoices.STATUS_ENABLED, status=PowerOutletStatusChoices.STATUS_ENABLED,
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
), ),
PowerOutlet( PowerOutlet(
device=devices[1], device=devices[1],
@ -4063,6 +4194,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
description='Second', description='Second',
color='00ff00', color='00ff00',
status=PowerOutletStatusChoices.STATUS_DISABLED, status=PowerOutletStatusChoices.STATUS_DISABLED,
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
), ),
PowerOutlet( PowerOutlet(
device=devices[2], device=devices[2],
@ -4073,6 +4207,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
description='Third', description='Third',
color='0000ff', color='0000ff',
status=PowerOutletStatusChoices.STATUS_FAULTY, status=PowerOutletStatusChoices.STATUS_FAULTY,
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
), ),
) )
PowerOutlet.objects.bulk_create(power_outlets) PowerOutlet.objects.bulk_create(power_outlets)
@ -4307,6 +4444,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
virtual_chassis.master = devices[0]
virtual_chassis.save()
module_bays = ( module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1'), ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
@ -4381,13 +4521,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
vlan_translation_policy=vlan_translation_policies[0], vlan_translation_policy=vlan_translation_policies[0],
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
), ),
Interface( Interface(
device=devices[1], device=devices[1],
module=modules[1], module=modules[1],
name='VC Chassis Interface', name='VC Chassis Interface',
type=InterfaceTypeChoices.TYPE_1GE_SFP, type=InterfaceTypeChoices.TYPE_1GE_SFP,
enabled=True enabled=True,
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
), ),
Interface( Interface(
device=devices[2], device=devices[2],
@ -4406,6 +4552,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_mode=InterfacePoEModeChoices.MODE_PD, poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
vlan_translation_policy=vlan_translation_policies[0], vlan_translation_policy=vlan_translation_policies[0],
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
), ),
Interface( Interface(
device=devices[3], device=devices[3],
@ -4424,6 +4573,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
vlan_translation_policy=vlan_translation_policies[1], vlan_translation_policy=vlan_translation_policies[1],
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
), ),
Interface( Interface(
device=devices[4], device=devices[4],
@ -4440,6 +4592,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mode=InterfaceModeChoices.MODE_Q_IN_Q, mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[0], qinq_svlan=vlans[0],
vlan_translation_policy=vlan_translation_policies[1], vlan_translation_policy=vlan_translation_policies[1],
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
), ),
Interface( Interface(
device=devices[4], device=devices[4],
@ -4450,7 +4605,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=True, mgmt_only=True,
tx_power=40, tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q, 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( Interface(
device=devices[4], device=devices[4],
@ -4461,7 +4619,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=False, mgmt_only=False,
tx_power=40, tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q, 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( Interface(
device=devices[4], device=devices[4],
@ -4470,7 +4631,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_role=WirelessRoleChoices.ROLE_AP, rf_role=WirelessRoleChoices.ROLE_AP,
rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
rf_channel_frequency=2412, 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( Interface(
device=devices[4], device=devices[4],
@ -4479,7 +4643,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_role=WirelessRoleChoices.ROLE_STATION, rf_role=WirelessRoleChoices.ROLE_STATION,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160, 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) Interface.objects.bulk_create(interfaces)
@ -4666,6 +4833,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'device': [devices[0].name, devices[1].name]} params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_virtual_chassis_member(self):
# Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces # Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
devices = Device.objects.filter(name__in=['Device 1A', 'Device 3']) 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=rear_ports[0],
rear_port_position=1, rear_port_position=1,
description='First', description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
), ),
FrontPort( FrontPort(
device=devices[1], device=devices[1],
@ -4917,6 +5100,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rear_port=rear_ports[1], rear_port=rear_ports[1],
rear_port_position=2, rear_port_position=2,
description='Second', description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
), ),
FrontPort( FrontPort(
device=devices[2], device=devices[2],
@ -4928,6 +5114,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rear_port=rear_ports[2], rear_port=rear_ports[2],
rear_port_position=3, rear_port_position=3,
description='Third', description='Third',
_site=devices[2].site,
_location=devices[2].location,
_rack=devices[2].rack,
), ),
FrontPort( FrontPort(
device=devices[3], device=devices[3],
@ -4936,6 +5125,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=PortTypeChoices.TYPE_FC, type=PortTypeChoices.TYPE_FC,
rear_port=rear_ports[3], rear_port=rear_ports[3],
rear_port_position=1, rear_port_position=1,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
), ),
FrontPort( FrontPort(
device=devices[3], device=devices[3],
@ -4944,6 +5136,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=PortTypeChoices.TYPE_FC, type=PortTypeChoices.TYPE_FC,
rear_port=rear_ports[4], rear_port=rear_ports[4],
rear_port_position=1, rear_port_position=1,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
), ),
FrontPort( FrontPort(
device=devices[3], device=devices[3],
@ -4952,6 +5147,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=PortTypeChoices.TYPE_FC, type=PortTypeChoices.TYPE_FC,
rear_port=rear_ports[5], rear_port=rear_ports[5],
rear_port_position=1, rear_port_position=1,
_site=devices[3].site,
_location=devices[3].location,
_rack=devices[3].rack,
), ),
) )
FrontPort.objects.bulk_create(front_ports) FrontPort.objects.bulk_create(front_ports)
@ -5168,6 +5366,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
color=ColorChoices.COLOR_RED, color=ColorChoices.COLOR_RED,
positions=1, positions=1,
description='First', description='First',
_site=devices[0].site,
_location=devices[0].location,
_rack=devices[0].rack,
), ),
RearPort( RearPort(
device=devices[1], device=devices[1],
@ -5178,6 +5379,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
color=ColorChoices.COLOR_GREEN, color=ColorChoices.COLOR_GREEN,
positions=2, positions=2,
description='Second', description='Second',
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
), ),
RearPort( RearPort(
device=devices[2], device=devices[2],
@ -5188,10 +5392,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
color=ColorChoices.COLOR_BLUE, color=ColorChoices.COLOR_BLUE,
positions=3, positions=3,
description='Third', 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) RearPort.objects.bulk_create(rear_ports)
@ -5550,9 +5784,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
device_bays = ( device_bays = (
DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'), DeviceBay(
DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'), device=devices[0],
DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'), 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) DeviceBay.objects.bulk_create(device_bays)

View File

@ -337,6 +337,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'rack': rack.pk, 'rack': rack.pk,
'units': "10,11,12", 'units': "10,11,12",
'status': RackReservationStatusChoices.STATUS_PENDING,
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'Rack reservation', 'description': 'Rack reservation',
@ -344,10 +345,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
'site,location,rack,units,description', 'site,location,rack,units,status,description',
'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1', 'Site 1,Location 1,Rack 1,"10,11,12",active,Reservation 1',
'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2', 'Site 1,Location 1,Rack 1,"13,14,15",pending,Reservation 2',
'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', 'Site 1,Location 1,Rack 1,"16,17,18",stale,Reservation 3',
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -358,6 +359,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'status': RackReservationStatusChoices.STATUS_STALE,
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'New description', 'description': 'New description',
@ -619,7 +621,8 @@ class DeviceTypeTestCase(
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]), Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]), 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.objects.bulk_create([
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]), 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 2', slug='platform-2', manufacturer=manufacturer),
Platform(name='Platform 3', slug='platform-3', 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') tags = create_tags('Alpha', 'Bravo', 'Charlie')
@ -1912,9 +1916,9 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,description", "id,name,description",
f"{platforms[0].pk},Platform 7,Fourth platform7", f"{platforms[0].pk},Foo,New description",
f"{platforms[1].pk},Platform 8,Fifth platform8", f"{platforms[1].pk},Bar,New description",
f"{platforms[2].pk},Platform 9,Sixth platform9", f"{platforms[2].pk},Baz,New description",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1962,7 +1966,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Platform(name='Platform 1', slug='platform-1'), Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'), Platform(name='Platform 2', slug='platform-2'),
) )
Platform.objects.bulk_create(platforms) for platform in platforms:
platform.save()
devices = ( devices = (
Device( Device(

View File

@ -2040,9 +2040,18 @@ class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView):
@register_model_view(DeviceRole, 'list', path='', detail=False) @register_model_view(DeviceRole, 'list', path='', detail=False)
class DeviceRoleListView(generic.ObjectListView): class DeviceRoleListView(generic.ObjectListView):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.add_related_count(
device_count=count_related(Device, 'role'), DeviceRole.objects.add_related_count(
vm_count=count_related(VirtualMachine, 'role') DeviceRole.objects.all(),
VirtualMachine,
'role',
'vm_count',
cumulative=True
),
Device,
'role',
'device_count',
cumulative=True
) )
filterset = filtersets.DeviceRoleFilterSet filterset = filtersets.DeviceRoleFilterSet
filterset_form = forms.DeviceRoleFilterForm filterset_form = forms.DeviceRoleFilterForm
@ -2109,9 +2118,18 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Platform, 'list', path='', detail=False) @register_model_view(Platform, 'list', path='', detail=False)
class PlatformListView(generic.ObjectListView): class PlatformListView(generic.ObjectListView):
queryset = Platform.objects.annotate( queryset = Platform.objects.add_related_count(
device_count=count_related(Device, 'platform'), Platform.objects.add_related_count(
vm_count=count_related(VirtualMachine, 'platform') Platform.objects.all(),
VirtualMachine,
'platform',
'vm_count',
cumulative=True
),
Device,
'platform',
'device_count',
cumulative=True
) )
table = tables.PlatformTable table = tables.PlatformTable
filterset = filtersets.PlatformFilterSet filterset = filtersets.PlatformFilterSet

View File

@ -6,7 +6,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
from dcim.api.serializers_.roles import DeviceRoleSerializer from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup 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.fields import SerializedPKRelatedField
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer 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 from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
'ConfigContextProfileSerializer',
'ConfigContextSerializer', '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): class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
profile = ConfigContextProfileSerializer(
nested=True,
required=False,
allow_null=True,
default=None,
)
regions = SerializedPKRelatedField( regions = SerializedPKRelatedField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
serializer=RegionSerializer, serializer=RegionSerializer,
@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
class Meta: class Meta:
model = ConfigContext model = ConfigContext
fields = [ 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', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
'data_file', 'data_synced', 'data', 'created', 'last_updated', 'data_synced', 'data', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -25,6 +25,7 @@ router.register('tagged-objects', views.TaggedItemViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet) router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet) router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet) router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
router.register('config-templates', views.ConfigTemplateViewSet) router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts', views.ScriptViewSet, basename='script') router.register('scripts', views.ScriptViewSet, basename='script')

View File

@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet):
# Config contexts # Config contexts
# #
class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContextProfile.objects.all()
serializer_class = serializers.ConfigContextProfileSerializer
filterset_class = filtersets.ConfigContextProfileFilterSet
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
serializer_class = serializers.ConfigContextSerializer serializer_class = serializers.ConfigContextSerializer

View File

@ -11,7 +11,7 @@ from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model from django.db.models import Model
from django.template.loader import render_to_string 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 django.utils.translation import gettext as _
from core.models import ObjectType 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.proxy import resolve_proxies
from utilities.querydict import dict_to_querydict from utilities.querydict import dict_to_querydict
from utilities.templatetags.builtins.filters import render_markdown 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 from .utils import register_widget
__all__ = ( __all__ = (
@ -53,9 +53,9 @@ def object_list_widget_supports_model(model: Model) -> bool:
""" """
def can_resolve_model_list_view(model: Model) -> bool: def can_resolve_model_list_view(model: Model) -> bool:
try: try:
reverse(get_viewname(model, action='list')) get_action_url(model, action='list')
return True return True
except Exception: except NoReverseMatch:
return False return False
tests = [ tests = [
@ -206,7 +206,7 @@ class ObjectCountsWidget(DashboardWidget):
permission = get_permission_for_model(model, 'view') permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission): if request.user.has_perm(permission):
try: try:
url = reverse(get_viewname(model, 'list')) url = get_action_url(model, action='list')
except NoReverseMatch: except NoReverseMatch:
url = None url = None
qs = model.objects.restrict(request.user, 'view') 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}") logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
return return
viewname = get_viewname(model, action='list')
# Evaluate user's permission. Note that this controls only whether the HTMX element is # Evaluate user's permission. Note that this controls only whether the HTMX element is
# embedded on the page: The view itself will also evaluate permissions separately. # embedded on the page: The view itself will also evaluate permissions separately.
permission = get_permission_for_model(model, 'view') permission = get_permission_for_model(model, 'view')
has_permission = request.user.has_perm(permission) has_permission = request.user.has_perm(permission)
try: try:
htmx_url = reverse(viewname) htmx_url = get_action_url(model, action='list')
except NoReverseMatch: except NoReverseMatch:
htmx_url = None htmx_url = None
parameters = self.config.get('url_params') or {} parameters = self.config.get('url_params') or {}
@ -297,7 +295,7 @@ class ObjectListWidget(DashboardWidget):
except ValueError: except ValueError:
pass pass
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
'viewname': viewname, 'model_name': model_name,
'has_permission': has_permission, 'has_permission': has_permission,
'htmx_url': htmx_url, 'htmx_url': htmx_url,
}) })
@ -309,6 +307,7 @@ class RSSFeedWidget(DashboardWidget):
default_config = { default_config = {
'max_entries': 10, 'max_entries': 10,
'cache_timeout': 3600, # seconds 'cache_timeout': 3600, # seconds
'request_timeout': 3, # seconds
'requires_internet': True, 'requires_internet': True,
} }
description = _('Embed an RSS feed from an external website.') description = _('Embed an RSS feed from an external website.')
@ -335,6 +334,12 @@ class RSSFeedWidget(DashboardWidget):
max_value=86400, # 24 hours max_value=86400, # 24 hours
help_text=_('How long to stored the cached content (in seconds)') 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): def render(self, request):
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
@ -366,7 +371,7 @@ class RSSFeedWidget(DashboardWidget):
url=self.config['feed_url'], url=self.config['feed_url'],
headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'}, headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}), proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
timeout=3 timeout=self.config.get('request_timeout', 3),
) )
response.raise_for_status() response.raise_for_status()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:

View File

@ -19,6 +19,7 @@ from .models import *
__all__ = ( __all__ = (
'BookmarkFilterSet', 'BookmarkFilterSet',
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ConfigContextProfileFilterSet',
'ConfigTemplateFilterSet', 'ConfigTemplateFilterSet',
'CustomFieldChoiceSetFilterSet', 'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet', '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): class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label=_('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( region_id = django_filters.ModelMultipleChoiceFilter(
field_name='regions', field_name='regions',
queryset=Region.objects.all(), queryset=Region.objects.all(),

View File

@ -13,12 +13,14 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = ( __all__ = (
'ConfigContextBulkEditForm', 'ConfigContextBulkEditForm',
'ConfigContextProfileBulkEditForm',
'ConfigTemplateBulkEditForm', 'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm', 'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm', 'CustomFieldChoiceSetBulkEditForm',
'CustomLinkBulkEditForm', 'CustomLinkBulkEditForm',
'EventRuleBulkEditForm', 'EventRuleBulkEditForm',
'ExportTemplateBulkEditForm', 'ExportTemplateBulkEditForm',
'ImageAttachmentBulkEditForm',
'JournalEntryBulkEditForm', 'JournalEntryBulkEditForm',
'NotificationGroupBulkEditForm', 'NotificationGroupBulkEditForm',
'SavedFilterBulkEditForm', 'SavedFilterBulkEditForm',
@ -317,6 +319,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm):
nullable_fields = ('description',) 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): class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(), queryset=ConfigContext.objects.all(),
@ -327,6 +348,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
required=False, required=False,
min_value=0 min_value=0
) )
profile = DynamicModelChoiceField(
queryset=ConfigContextProfile.objects.all(),
required=False
)
is_active = forms.NullBooleanField( is_active = forms.NullBooleanField(
label=_('Is active'), label=_('Is active'),
required=False, required=False,
@ -338,7 +363,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
max_length=100 max_length=100
) )
nullable_fields = ('description',) fieldsets = (
FieldSet('weight', 'profile', 'is_active', 'description'),
)
nullable_fields = ('profile', 'description')
class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm): class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
@ -374,6 +402,18 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') 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): class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(), queryset=JournalEntry.objects.all(),

View File

@ -18,6 +18,7 @@ from utilities.forms.fields import (
) )
__all__ = ( __all__ = (
'ConfigContextProfileImportForm',
'ConfigTemplateImportForm', 'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm', 'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm', '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 ConfigTemplateImportForm(CSVModelForm):
class Meta: class Meta:

View File

@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
'ConfigContextFilterForm', 'ConfigContextFilterForm',
'ConfigContextProfileFilterForm',
'ConfigTemplateFilterForm', 'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm', 'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm', '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): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigContext model = ConfigContext
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag_id'), FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('profile', name=_('Config Context')),
FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')), FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
) )
profile_id = DynamicModelMultipleChoiceField(
queryset=ConfigContextProfile.objects.all(),
required=False,
label=_('Profile')
)
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
required=False, required=False,

View File

@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
'BookmarkForm', 'BookmarkForm',
'ConfigContextForm', 'ConfigContextForm',
'ConfigContextProfileForm',
'ConfigTemplateForm', 'ConfigTemplateForm',
'CustomFieldChoiceSetForm', 'CustomFieldChoiceSetForm',
'CustomFieldForm', '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): class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
profile = DynamicModelChoiceField(
label=_('Profile'),
queryset=ConfigContextProfile.objects.all(),
required=False
)
regions = DynamicModelMultipleChoiceField( regions = DynamicModelMultipleChoiceField(
label=_('Regions'), label=_('Regions'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
) )
fieldsets = ( 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('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
FieldSet( FieldSet(
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
class Meta: class Meta:
model = ConfigContext model = ConfigContext
fields = ( fields = (
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
) )
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):

View File

@ -8,7 +8,7 @@ from strawberry_django import FilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from extras import models from extras import models
from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin 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: if TYPE_CHECKING:
from core.graphql.filters import ContentTypeFilter from core.graphql.filters import ContentTypeFilter
@ -24,6 +24,7 @@ if TYPE_CHECKING:
__all__ = ( __all__ = (
'ConfigContextFilter', 'ConfigContextFilter',
'ConfigContextProfileFilter',
'ConfigTemplateFilter', 'ConfigTemplateFilter',
'CustomFieldFilter', 'CustomFieldFilter',
'CustomFieldChoiceSetFilter', '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) @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -11,6 +11,9 @@ class ExtrasQuery:
config_context: ConfigContextType = strawberry_django.field() config_context: ConfigContextType = strawberry_django.field()
config_context_list: List[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: ConfigTemplateType = strawberry_django.field()
config_template_list: List[ConfigTemplateType] = strawberry_django.field() config_template_list: List[ConfigTemplateType] = strawberry_django.field()

View File

@ -3,13 +3,13 @@ from typing import Annotated, List, TYPE_CHECKING
import strawberry import strawberry
import strawberry_django import strawberry_django
from core.graphql.mixins import SyncedDataMixin
from extras import models from extras import models
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin 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 * from .filters import *
if TYPE_CHECKING: if TYPE_CHECKING:
from core.graphql.types import DataFileType, DataSourceType
from dcim.graphql.types import ( from dcim.graphql.types import (
DeviceRoleType, DeviceRoleType,
DeviceType, DeviceType,
@ -25,6 +25,7 @@ if TYPE_CHECKING:
from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
__all__ = ( __all__ = (
'ConfigContextProfileType',
'ConfigContextType', 'ConfigContextType',
'ConfigTemplateType', 'ConfigTemplateType',
'CustomFieldChoiceSetType', '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( @strawberry_django.type(
models.ConfigContext, models.ConfigContext,
fields='__all__', fields='__all__',
filters=ConfigContextFilter, filters=ConfigContextFilter,
pagination=True pagination=True
) )
class ConfigContextType(ObjectType): class ConfigContextType(SyncedDataMixin, ObjectType):
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None profile: ConfigContextProfileType | None
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]] device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]] tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
@ -74,10 +84,7 @@ class ConfigContextType(ObjectType):
filters=ConfigTemplateFilter, filters=ConfigTemplateFilter,
pagination=True pagination=True
) )
class ConfigTemplateType(TagsMixin, ObjectType): class ConfigTemplateType(SyncedDataMixin, TagsMixin, ObjectType):
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
@ -123,9 +130,8 @@ class CustomLinkType(ObjectType):
filters=ExportTemplateFilter, filters=ExportTemplateFilter,
pagination=True pagination=True
) )
class ExportTemplateType(ObjectType): class ExportTemplateType(SyncedDataMixin, ObjectType):
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None pass
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
@strawberry_django.type( @strawberry_django.type(

View File

@ -59,6 +59,7 @@ class ScriptJob(JobRunner):
else: else:
script.log_failure(msg) script.log_failure(msg)
logger.error(f"Script aborted with error: {e}") logger.error(f"Script aborted with error: {e}")
self.logger.error(f"Script aborted with error: {e}")
else: else:
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
@ -66,9 +67,11 @@ class ScriptJob(JobRunner):
message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```" message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
) )
logger.error(f"Exception raised during script execution: {e}") 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: if type(e) is not AbortTransaction:
script.log_info(message=_("Database changes have been reverted due to error.")) 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. # Clear all pending events. Job termination (including setting the status) is handled by the job framework.
if request: 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 # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc. # change logging, event rules, etc.
if commit: if commit:
self.logger.info("Executing script (commit enabled)")
with ExitStack() as stack: with ExitStack() as stack:
for request_processor in registry['request_processors']: for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request)) stack.enter_context(request_processor(request))
self.run_script(script, request, data, commit) self.run_script(script, request, data, commit)
else: else:
self.logger.warning("Executing script (commit disabled)")
self.run_script(script, request, data, commit) self.run_script(script, request, data, commit)

View File

@ -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 from .fields import CachedValueField
@ -18,6 +19,30 @@ class Empty(Lookup):
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params 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): class NetHost(Lookup):
""" """
Similar to ipam.lookups.NetHost, but casts the field to INET. Similar to ipam.lookups.NetHost, but casts the field to INET.
@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup):
CharField.register_lookup(Empty) CharField.register_lookup(Empty)
JSONField.register_lookup(JSONEmpty)
CachedValueField.register_lookup(NetHost) CachedValueField.register_lookup(NetHost)
CachedValueField.register_lookup(NetContainsOrEquals) CachedValueField.register_lookup(NetContainsOrEquals)

View 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',
),
),
]

View File

@ -1,4 +1,6 @@
import jsonschema
from collections import defaultdict from collections import defaultdict
from jsonschema.exceptions import ValidationError as JSONValidationError
from django.conf import settings from django.conf import settings
from django.core.validators import ValidationError from django.core.validators import ValidationError
@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType from core.models import ObjectType
from extras.models.mixins import RenderTemplateMixin from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet 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 netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.data import deepmerge from utilities.data import deepmerge
from utilities.jsonschema import validate_schema
__all__ = ( __all__ = (
'ConfigContext', 'ConfigContext',
'ConfigContextModel', 'ConfigContextModel',
'ConfigContextProfile',
'ConfigTemplate', 'ConfigTemplate',
) )
@ -24,6 +28,46 @@ __all__ = (
# Config contexts # 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): class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
""" """
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned 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, max_length=100,
unique=True unique=True
) )
profile = models.ForeignKey(
to='extras.ConfigContextProfile',
on_delete=models.PROTECT,
blank=True,
null=True,
related_name='config_contexts',
)
weight = models.PositiveSmallIntegerField( weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'), verbose_name=_('weight'),
default=1000 default=1000
@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
objects = ConfigContextQuerySet.as_manager() objects = ConfigContextQuerySet.as_manager()
clone_fields = ( clone_fields = (
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
'tenants', 'tags', 'data',
) )
class Meta: class Meta:
@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'} {'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): def sync_data(self):
""" """
Synchronize context data from the designated DataFile (if any). Synchronize context data from the designated DataFile (if any).

View File

@ -600,11 +600,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
kwargs = { kwargs = {
'field_name': f'custom_field_data__{self.name}' '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: if lookup_expr is not None:
kwargs['lookup_expr'] = lookup_expr kwargs['lookup_expr'] = lookup_expr
# 'Empty' lookup is always a boolean
if lookup_expr == 'empty':
filter_class = django_filters.BooleanFilter
# Text/URL # Text/URL
if self.type in ( elif self.type in (
CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_TEXT,
CustomFieldTypeChoices.TYPE_LONGTEXT, CustomFieldTypeChoices.TYPE_LONGTEXT,
CustomFieldTypeChoices.TYPE_URL, CustomFieldTypeChoices.TYPE_URL,

View File

@ -872,6 +872,9 @@ class Bookmark(models.Model):
return str(self.object) return str(self.object)
return super().__str__() return super().__str__()
def get_absolute_url(self):
return reverse('account:bookmarks')
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -173,14 +173,17 @@ class NotificationGroup(ChangeLoggedModel):
User.objects.filter(groups__in=self.groups.all()) User.objects.filter(groups__in=self.groups.all())
).order_by('username') ).order_by('username')
def notify(self, **kwargs): def notify(self, object_type, object_id, **kwargs):
""" """
Bulk-create Notifications for all members of this group. Bulk-create Notifications for all members of this group.
""" """
Notification.objects.bulk_create([ for user in self.members:
Notification(user=member, **kwargs) Notification.objects.update_or_create(
for member in self.members object_type=object_type,
]) object_id=object_id,
user=user,
defaults=kwargs
)
notify.alters_data = True notify.alters_data = True

View File

@ -588,9 +588,9 @@ class BaseScript:
""" """
Return data from a YAML file Return data from a YAML file
""" """
# TODO: DEPRECATED: Remove this method in v4.4 # TODO: DEPRECATED: Remove this method in v4.5
self._log( 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 level=LogLevelChoices.LOG_WARNING
) )
file_path = os.path.join(settings.SCRIPTS_ROOT, filename) file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
@ -603,9 +603,9 @@ class BaseScript:
""" """
Return data from a JSON file Return data from a JSON file
""" """
# TODO: DEPRECATED: Remove this method in v4.4 # TODO: DEPRECATED: Remove this method in v4.5
self._log( 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 level=LogLevelChoices.LOG_WARNING
) )
file_path = os.path.join(settings.SCRIPTS_ROOT, filename) file_path = os.path.join(settings.SCRIPTS_ROOT, filename)

View File

@ -2,6 +2,17 @@ from netbox.search import SearchIndex, register_search
from . import models from . import models
@register_search
class ConfigContextProfileIndex(SearchIndex):
model = models.ConfigContextProfile
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@register_search @register_search
class CustomFieldIndex(SearchIndex): class CustomFieldIndex(SearchIndex):
model = models.CustomField model = models.CustomField

View File

@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
__all__ = ( __all__ = (
'BookmarkTable', 'BookmarkTable',
'ConfigContextProfileTable',
'ConfigContextTable', 'ConfigContextTable',
'ConfigTemplateTable', 'ConfigTemplateTable',
'CustomFieldChoiceSetTable', 'CustomFieldChoiceSetTable',
@ -39,9 +40,8 @@ __all__ = (
IMAGEATTACHMENT_IMAGE = """ IMAGEATTACHMENT_IMAGE = """
{% if record.image %} {% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank"> <a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i> <i class="mdi mdi-image"></i></a>
</a>
{% endif %} {% endif %}
<a href="{{ record.get_absolute_url }}">{{ record }}</a> <a href="{{ record.get_absolute_url }}">{{ record }}</a>
""" """
@ -235,6 +235,7 @@ class ImageAttachmentTable(NetBoxTable):
image = columns.TemplateColumn( image = columns.TemplateColumn(
verbose_name=_('Image'), verbose_name=_('Image'),
template_code=IMAGEATTACHMENT_IMAGE, template_code=IMAGEATTACHMENT_IMAGE,
attrs={'td': {'class': 'text-nowrap'}}
) )
name = tables.Column( name = tables.Column(
verbose_name=_('Name'), verbose_name=_('Name'),
@ -254,7 +255,7 @@ class ImageAttachmentTable(NetBoxTable):
verbose_name=_('Object Type'), verbose_name=_('Object Type'),
) )
parent = tables.Column( parent = tables.Column(
verbose_name=_('Parent'), verbose_name=_('Object'),
linkify=True, linkify=True,
orderable=False, orderable=False,
) )
@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
fields = ('id', 'content_type', 'content_object') 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): class ConfigContextTable(NetBoxTable):
profile = tables.Column(
linkify=True,
verbose_name=_('Profile'),
)
data_source = tables.Column( data_source = tables.Column(
verbose_name=_('Data Source'), verbose_name=_('Data Source'),
linkify=True linkify=True
@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ConfigContext model = ConfigContext
fields = ( fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations', 'pk', 'id', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description', 'regions', 'sites',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'locations', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', '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): class ConfigTemplateTable(NetBoxTable):

View File

@ -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): class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext model = ConfigContext
brief_fields = ['description', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'id', 'name', 'url']

View File

@ -1615,6 +1615,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf11': manufacturers[2].pk, 'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk], 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}), }),
Site(name='Site 4', slug='site-4'),
]) ])
def test_filter_integer(self): 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__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__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__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): def test_filter_decimal(self):
self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2) 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__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__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__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): def test_filter_boolean(self):
self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2) 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__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__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__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): def test_filter_text_loose(self):
self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2) 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__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__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__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): def test_filter_url_strict(self):
self.assertEqual( 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__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__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__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): def test_filter_url_loose(self):
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self): def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) 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): def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1) 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': ['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): def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) 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(), self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
2 2
) )
self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
def test_filter_multiobject(self): def test_filter_multiobject(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) 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(), self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
3 3
) )
self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)

View File

@ -45,4 +45,4 @@ class ObjectListWidgetTests(TestCase):
mock_request = Request() mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config) widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request) 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)

View File

@ -871,6 +871,39 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet filterset = ConfigContextFilterSet
@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): 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 = ( regions = (
Region(name='Region 1', slug='region-1'), 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 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'), Platform(name='Platform 3', slug='platform-3'),
) )
Platform.objects.bulk_create(platforms) for platform in platforms:
platform.save()
cluster_types = ( cluster_types = (
ClusterType(name='Cluster Type 1', slug='cluster-type-1'), ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
@ -975,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
is_active = bool(i % 2) is_active = bool(i % 2)
c = ConfigContext.objects.create( c = ConfigContext.objects.create(
name=f"Config Context {i + 1}", name=f"Config Context {i + 1}",
profile=profiles[i],
is_active=is_active, is_active=is_active,
data='{"foo": 123}', data='{"foo": 123}',
description=f"foobar{i + 1}" description=f"foobar{i + 1}"
@ -1011,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]} params = {'region_id': [regions[0].pk, regions[1].pk]}
@ -1184,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'cluster', 'cluster',
'clustergroup', 'clustergroup',
'clustertype', 'clustertype',
'configcontextprofile',
'configtemplate', 'configtemplate',
'consoleport', 'consoleport',
'consoleserverport', 'consoleserverport',

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