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:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.3.5
placeholder: v4.4.0
validations:
required: true
- type: dropdown

View File

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

3
.github/codeql/codeql-config.yml vendored Normal file
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
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
mkdocstrings[python]
mkdocstrings
# Python handler for mkdocstrings
# https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md
mkdocstrings-python
# Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
@ -135,7 +139,8 @@ requests
# rq
# https://github.com/rq/rq/blob/master/CHANGES.md
rq
# RQ v2.5 drops support for Redis < 5.0
rq==2.4.1
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md

View File

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

View File

@ -4,7 +4,7 @@
### Enabling Error Reporting
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
```python
SENTRY_ENABLED = True

View File

@ -22,24 +22,9 @@ Stores registration made using `netbox.denormalized.register()`. For each model,
### `model_features`
A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example:
A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.
```python
{
'custom_fields': {
'circuits': ['provider', 'circuit'],
'dcim': ['site', 'rack', 'devicetype', ...],
...
},
'event_rules': {
'extras': ['configcontext', 'tag', ...],
'dcim': ['site', 'rack', 'devicetype', ...],
},
...
}
```
Supported model features are listed in the [features matrix](./models.md#features-matrix).
Core model features are listed in the [features matrix](./models.md#features-matrix).
### `models`

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

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`.
### Update the Dependency Requirements Matrix
For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
1. Add a new row with the supported dependency versions.
2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
3. Bold any version changes for clarity.
**Example Update:**
```markdown
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
```
### Update System Requirements
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version.
* Update the minimum versions for each dependency.
* Add a new row to the release history table. Bold any version changes for clarity.
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)

View File

@ -25,42 +25,21 @@ NetBox requires the following dependencies:
### Version History
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
| 2.11 | 3.6 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
| 2.10 | 3.6 | 3.8 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
| 2.9 | 3.6 | 3.8 | 9.5 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
| 2.8 | 3.6 | 3.8 | 9.5 | 3.4 | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
| 2.5 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
| 2.4 | 3.4 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
| 2.2 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
| 2.0 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
| 1.5 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
| 4.4 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
## 3. Install the Latest Release

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.
Platforms may be nested under parents to form a hierarchy. For example, platforms named "Debian" and "RHEL" might both be created under a generic "Linux" parent.
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
The assignment of platforms to devices and virtual machines is optional.
## Fields
## Parent
!!! "This field was introduced in NetBox v4.4."
The parent platform class to which this platform belongs (optional).
### Name
A human-friendly name for the platform. Must be unique per manufacturer.

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.
### Status
The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.)
!!! tip
Additional statuses may be defined by setting `RackReservation.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### User
The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.

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.
### Profile
The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data.
### Data
The context data expressed in JSON format.

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
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
* Bookmarks
* Change logging
* Cloning
* Custom fields
* Custom links
* Custom validation
* Export templates
* Journaling
* Tags
* Webhooks
This class performs two crucial functions:
Plugin models can leverage certain [model features](../../development/models.md#features-matrix) (such as tags, custom fields, event rules, etc.) by inheriting from NetBox's `NetBoxModel` class. This class performs two crucial functions:
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
2. Register the model with NetBox as utilizing these features
@ -135,6 +122,27 @@ For more information about database migrations, see the [Django documentation](h
::: netbox.models.features.TagsMixin
## Custom Model Features
In addition to utilizing the model features provided natively by NetBox (listed above), plugins can register their own model features. This is done using the `register_model_feature()` function from `netbox.utils`. This function takes two arguments: a feature name, and a callable which accepts a model class. The callable must return a boolean value indicting whether the given model supports the named feature.
This function can be used as a decorator:
```python
@register_model_feature('foo')
def supports_foo(model):
# Your logic here
```
Or it can be called directly:
```python
register_model_feature('foo', supports_foo)
```
!!! tip
Consider performing feature registration inside your PluginConfig's `ready()` method.
## Choice Sets
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)

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

View File

@ -89,7 +89,7 @@ The following condition will evaluate as true:
```
!!! note "Evaluating static choice fields"
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). be sure to specify on which of these you want to match.
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). Be sure to specify on which of these you want to match.
## Condition Sets

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)
* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
* Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
* Logging Mechanism for Background Jobs ([#19816](https://github.com/netbox-community/netbox/issues/19816))
* Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713))
* Config Context Data Validation ([#19377](https://github.com/netbox-community/netbox/issues/19377))
#### [Version 4.3](./version-4.3.md) (May 2025)

View File

@ -1,5 +1,60 @@
# NetBox v4.3
## v4.3.7 (2025-08-26)
### Enhancements
* [#18147](https://github.com/netbox-community/netbox/issues/18147) - Add device & VM interface counts under related objects for VRFs
* [#19990](https://github.com/netbox-community/netbox/issues/19990) - Button to add a missing prerequisite now includes a return URL
* [#20122](https://github.com/netbox-community/netbox/issues/20122) - Improve color contrast of highlighted data under changelog diff view
* [#20131](https://github.com/netbox-community/netbox/issues/20131) - Add object selector for interface to the MAC address edit form
### Bug Fixes
* [#18916](https://github.com/netbox-community/netbox/issues/18916) - Fix dynamic dropdown selection styling for required fields when no selection is made
* [#19645](https://github.com/netbox-community/netbox/issues/19645) - Fix interface selection when adding a cable for a virtual chassis master
* [#19669](https://github.com/netbox-community/netbox/issues/19669) - Restore token authentication support for fetching media assets
* [#19970](https://github.com/netbox-community/netbox/issues/19970) - Device role child device counts should be cumulative
* [#20012](https://github.com/netbox-community/netbox/issues/20012) - Fix support for `empty` filter lookup on custom fields
* [#20043](https://github.com/netbox-community/netbox/issues/20043) - Fix page styling when rack elevations are embedded
* [#20098](https://github.com/netbox-community/netbox/issues/20098) - Fix `AttributeError` exception when assigning tags during bulk import
* [#20120](https://github.com/netbox-community/netbox/issues/20120) - Fix REST API serialization of jobs under `/api/core/background-tasks/`
* [#20157](https://github.com/netbox-community/netbox/issues/20157) - Fix `IntegrityError` exception when a duplicate notification is triggered
* [#20164](https://github.com/netbox-community/netbox/issues/20164) - Fix `ValueError` exception when attempting to add power outlets to devices in bulk
---
## v4.3.6 (2025-08-12)
### Enhancements
* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning
* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables
* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget
* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links
* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices
* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions
* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack
### Bug Fixes
* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices
* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group
* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges
* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses
* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form
* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags
* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget
* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type
* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views
* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data
* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time
* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others
* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks
* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation
---
## v4.3.5 (2025-07-29)
### Enhancements
@ -16,6 +71,11 @@
* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
* [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
!!! note "Plugin Developer Advisory"
The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility.
---
## v4.3.4 (2025-07-15)
### Enhancements

View File

@ -1,6 +1,6 @@
# NetBox v4.4
## v4.4.0 (FUTURE)
## v4.4.0 (2025-09-02)
### New Features
@ -8,7 +8,7 @@
Most bulk operations, such as the import, modification, or deletion of objects can now be executed as a background job. This frees the user to continue working in NetBox while the bulk operation is processed. Once completed, the user will be notified of the job's result.
#### Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
#### Logging Mechanism for Background Jobs ([#19816](https://github.com/netbox-community/netbox/issues/19816))
A dedicated logging mechanism has been implemented for background jobs. Jobs can now easily record log messages by calling e.g. `self.logger.info("Log message")` under the `run()` method. These messages are displayed along with the job's resulting data. Supported log levels include `DEBUG`, `INFO`, `WARNING`, and `ERROR`.
@ -16,25 +16,41 @@ A dedicated logging mechanism has been implemented for background jobs. Jobs can
When creating, editing, or deleting objects in NetBox, users now have the option of providing a short message explaining the change. This message will be recorded on the resulting changelog records for all affected objects.
#### Config Context Data Validation ([#19377](https://github.com/netbox-community/netbox/issues/19377))
A new ConfigContextProfile model has been introduced to support JSON schema validation for config context data. If a validation schema has been defined for a profile, all config contexts assigned to it will have their data validated against the schema whenever a change is made. (The assignment of a config context to a profile is optional.)
### Enhancements
* [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names
* [#18204](https://github.com/netbox-community/netbox/issues/18204) - Improved layout of the image attachments view & tables
* [#18528](https://github.com/netbox-community/netbox/issues/18528) - Introduced the `HOSTNAME` configuration parameter to override the system hostname reported by NetBox
* [#18984](https://github.com/netbox-community/netbox/issues/18984) - Added a `status` field for rack reservations
* [#18990](https://github.com/netbox-community/netbox/issues/18990) - Image attachments now include an optional description field
* [#19134](https://github.com/netbox-community/netbox/issues/19134) - Interface transmit power now accepts negative values
* [#19231](https://github.com/netbox-community/netbox/issues/19231) - Bulk renaming support has been implemented in the UI for most object types
* [#19591](https://github.com/netbox-community/netbox/issues/19591) - Thumbnails for all images attached to an object are now displayed under a dedicated tab
* [#19722](https://github.com/netbox-community/netbox/issues/19722) - The REST API endpoint for object types has been extended to include additional details
* [#19739](https://github.com/netbox-community/netbox/issues/19739) - Introduced a user preference for CSV delimiter
* [#19740](https://github.com/netbox-community/netbox/issues/19740) - Enable nesting of platforms within a hierarchy for improved organization
* [#19773](https://github.com/netbox-community/netbox/issues/19773) - Extend the system UI view with additional information
* [#19893](https://github.com/netbox-community/netbox/issues/19893) - The `/api/status/` REST API endpoint now includes the system hostname
* [#19920](https://github.com/netbox-community/netbox/issues/19920) - Contacts can now be assigned to ASNs
* [#19945](https://github.com/netbox-community/netbox/issues/19945) - Introduce a new custom script variable to represent decimal values
* [#19965](https://github.com/netbox-community/netbox/issues/19965) - Add REST & GraphQL API request counters to the Prometheus metrics exporter
* [#20029](https://github.com/netbox-community/netbox/issues/20029) - Include complete representation of object type in webhook payload data
### Plugins
* [#18006](https://github.com/netbox-community/netbox/issues/18006) - A Javascript is now triggered when UI is toggled between light and dark mode
* [#19735](https://github.com/netbox-community/netbox/issues/19735) - Custom individual and bulk operations can now be registered under individual views using `ObjectAction`
* [#20003](https://github.com/netbox-community/netbox/issues/20003) - Enable registration of callbacks to provide supplementary webhook payload data
* [#20115](https://github.com/netbox-community/netbox/issues/20115) - Support the use of ArrayColumn for plugin tables
* [#20129](https://github.com/netbox-community/netbox/issues/20129) - Enable plugins to register custom model features
### Deprecations
* [#19738](https://github.com/netbox-community/netbox/issues/19738) - The direct assignment of VLANs to sites is now discouraged in favor of VLAN groups
### Other Changes
@ -42,10 +58,11 @@ When creating, editing, or deleting objects in NetBox, users now have the option
* [#18588](https://github.com/netbox-community/netbox/issues/18588) - The "Service" model has been renamed to "Application Service" for clarity (UI change only)
* [#19829](https://github.com/netbox-community/netbox/issues/19829) - The REST API endpoint for object types is now available under `/api/core/`
* [#19924](https://github.com/netbox-community/netbox/issues/19924) - ObjectTypes are now tracked as concrete objects in the database (alongside ContentTypes)
* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbhshell` management command
* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbshell` management command
### REST API Changes
* All object types which support change logging now support the inclusion of a `changelog_message` for write operations. If provided, this message will be attached to the changelog record resulting from the change (if successful).
* The `/api/status/` endpoint now includes the system hostname.
* The `/api/extras/object-types/` endpoint is now available at `/api/core/object-types/`. (The original endpoint will be removed in NetBox v4.5.)
* The `/api/core/object-types/` endpoint has been expanded to include the following read-only fields:
@ -55,7 +72,16 @@ When creating, editing, or deleting objects in NetBox, users now have the option
* `is_plugin_model`
* `rest_api_endpoint`
* `description`
* Introduced the `/api/extras/config-context-profiles/` endpoint
* core.Job
* Added the read-only `log_entries` array field
* dcim.Interface
* The `tx_power` field now accepts negative values
* dcim.RackReservation
* Added the `status` choice field
* dcim.Platform
* Add an optional `parent` foreign key field to support nesting
* extras.ConfigContext
* Added the optional `profile` foreign key field
* extras.ImageAttachment
* Added an optional `description` field

View File

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

View File

@ -35,11 +35,7 @@ urlpatterns = [
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
# Virtual circuits
path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'),
path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))),
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),

View File

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

View File

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

View File

@ -18,8 +18,8 @@ class BackgroundTaskSerializer(serializers.Serializer):
description = serializers.CharField()
origin = serializers.CharField()
func_name = serializers.CharField()
args = serializers.ListField(child=serializers.CharField())
kwargs = serializers.DictField()
args = serializers.SerializerMethodField()
kwargs = serializers.SerializerMethodField()
result = serializers.CharField()
timeout = serializers.IntegerField()
result_ttl = serializers.IntegerField()
@ -42,6 +42,16 @@ class BackgroundTaskSerializer(serializers.Serializer):
is_scheduled = serializers.BooleanField()
is_stopped = serializers.BooleanField()
def get_args(self, obj) -> list:
return [
str(arg) for arg in obj.args
]
def get_kwargs(self, obj) -> dict:
return {
key: str(value) for key, value in obj.kwargs.items()
}
def get_position(self, obj) -> int:
return obj.get_position()

View File

@ -9,7 +9,7 @@ router.APIRootView = views.CoreRootView
router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet)
router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet)
router.register('object-changes', views.ObjectChangeViewSet, basename='objectchange')
router.register('object-types', views.ObjectTypeViewSet)
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')

View File

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

View File

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

View File

@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
from core.models import ObjectChange
if TYPE_CHECKING:
from core.graphql.types import DataFileType, DataSourceType
from netbox.core.graphql.types import ObjectChangeType
__all__ = (
'ChangelogMixin',
'SyncedDataMixin',
)
@ -25,3 +27,9 @@ class ChangelogMixin:
changed_object_id=self.pk
)
return object_changes.restrict(info.context.request.user, 'view')
@strawberry.type
class SyncedDataMixin:
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.events import *
from core.models import ObjectType
from extras.events import enqueue_event
from extras.models import Tag
from extras.utils import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
@ -104,6 +105,17 @@ def handle_changed_object(sender, instance, **kwargs):
# m2m_changed with objects added or removed
m2m_changed = True
event_type = OBJECT_UPDATED
elif kwargs.get('action') == 'post_clear':
# Handle clearing of an M2M field
if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
# Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
# prechange snapshot is empty)
m2m_changed = True
event_type = OBJECT_UPDATED
else:
# Other endpoints are unimpacted as they send post_add and post_remove
# This will impact changes that utilize clear() however so we may want to give consideration for this branch
return
else:
return

View File

@ -241,3 +241,48 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectType.objects.all()
filterset = ObjectTypeFilterSet
ignore_fields = (
'custom_fields',
'custom_links',
'event_rules',
'export_templates',
'object_permissions',
'saved_filters',
)
def test_q(self):
params = {'q': 'vrf'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_app_label(self):
self.assertEqual(
self.filterset({'app_label': ['dcim']}, self.queryset).qs.count(),
ObjectType.objects.filter(app_label='dcim').count(),
)
def test_model(self):
self.assertEqual(
self.filterset({'model': ['site']}, self.queryset).qs.count(),
ObjectType.objects.filter(model='site').count(),
)
def test_public(self):
self.assertEqual(
self.filterset({'public': True}, self.queryset).qs.count(),
ObjectType.objects.filter(public=True).count(),
)
self.assertEqual(
self.filterset({'public': False}, self.queryset).qs.count(),
ObjectType.objects.filter(public=False).count(),
)
def test_feature(self):
self.assertEqual(
self.filterset({'features': 'tags'}, self.queryset).qs.count(),
ObjectType.objects.filter(features__contains=['tags']).count(),
)

View File

@ -1,3 +1,4 @@
import json
import urllib.parse
import uuid
from datetime import datetime
@ -366,6 +367,11 @@ class SystemTestCase(TestCase):
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertIn('netbox_release', data)
self.assertIn('plugins', data)
self.assertIn('config', data)
self.assertIn('objects', data)
def test_system_view_with_config_revision(self):
ConfigRevision.objects.create()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from utilities.api import get_serializer_for_model
from utilities.query_functions import CollateAsChar
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@ -351,7 +352,19 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
#
class DeviceRoleViewSet(NetBoxModelViewSet):
queryset = DeviceRole.objects.all()
queryset = DeviceRole.objects.add_related_count(
DeviceRole.objects.add_related_count(
DeviceRole.objects.all(),
VirtualMachine,
'role',
'virtualmachine_count',
cumulative=True
),
Device,
'role',
'device_count',
cumulative=True
)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filtersets.DeviceRoleFilterSet
@ -360,8 +373,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
# Platforms
#
class PlatformViewSet(NetBoxModelViewSet):
queryset = Platform.objects.all()
class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Platform.objects.add_related_count(
Platform.objects.add_related_count(
Platform.objects.all(),
VirtualMachine,
'platform',
'virtualmachine_count',
cumulative=True
),
Device,
'platform',
'device_count',
cumulative=True
)
serializer_class = serializers.PlatformSerializer
filterset_class = filtersets.PlatformFilterSet

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

View File

@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label=_('Location (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=RackReservationStatusChoices,
null_value=None
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label=_('User (ID)'),
@ -547,14 +551,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
default_platform_id = django_filters.ModelMultipleChoiceFilter(
default_platform_id = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='default_platform',
lookup_expr='in',
label=_('Default platform (ID)'),
)
default_platform = django_filters.ModelMultipleChoiceFilter(
field_name='default_platform__slug',
default_platform = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='default_platform',
to_field_name='slug',
lookup_expr='in',
label=_('Default platform (slug)'),
)
has_front_image = django_filters.BooleanFilter(
@ -979,6 +986,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
class PlatformFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label=_('Immediate parent platform (ID)'),
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label=_('Immediate parent platform (slug)'),
)
ancestor_id = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='parent',
lookup_expr='in',
label=_('Parent platform (ID)'),
)
ancestor = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='parent',
lookup_expr='in',
to_field_name='slug',
label=_('Parent platform (slug)'),
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer',
queryset=Manufacturer.objects.all(),
@ -1058,14 +1088,17 @@ class DeviceFilterSet(
queryset=Device.objects.all(),
label=_('Parent Device (ID)'),
)
platform_id = django_filters.ModelMultipleChoiceFilter(
platform_id = TreeNodeMultipleChoiceFilter(
queryset=Platform.objects.all(),
field_name='platform',
lookup_expr='in',
label=_('Platform (ID)'),
)
platform = django_filters.ModelMultipleChoiceFilter(
field_name='platform__slug',
platform = TreeNodeMultipleChoiceFilter(
field_name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',
lookup_expr='in',
label=_('Platform (slug)'),
)
region_id = TreeNodeMultipleChoiceFilter(
@ -1515,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
field_name='_site',
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site name (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__location',
field_name='_location',
queryset=Location.objects.all(),
label=_('Location (ID)'),
)
location = django_filters.ModelMultipleChoiceFilter(
field_name='device__location__slug',
field_name='_location__slug',
queryset=Location.objects.all(),
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack',
field_name='_rack',
queryset=Rack.objects.all(),
label=_('Rack (ID)'),
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name',
field_name='_rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
label=_('Rack (name)'),
@ -1885,6 +1918,16 @@ class InterfaceFilterSet(
PathEndpointFilterSet,
CommonInterfaceFilterSet
):
virtual_chassis_member_or_master = MultiValueCharFilter(
method='filter_virtual_chassis_member_or_master',
field_name='name',
label=_('Virtual Chassis Interfaces for Device when device is master')
)
virtual_chassis_member_or_master_id = MultiValueNumberFilter(
method='filter_virtual_chassis_member_or_master',
field_name='pk',
label=_('Virtual Chassis Interfaces for Device when device is master (ID)')
)
virtual_chassis_member = MultiValueCharFilter(
method='filter_virtual_chassis_member',
field_name='name',
@ -1995,11 +2038,14 @@ class InterfaceFilterSet(
'cable_id', 'cable_end',
)
def filter_virtual_chassis_member(self, queryset, name, value):
def filter_virtual_chassis_member_or_master(self, queryset, name, value):
return self.filter_virtual_chassis_member(queryset, name, value, if_master=True)
def filter_virtual_chassis_member(self, queryset, name, value, if_master=False):
try:
vc_interface_ids = []
for device in Device.objects.filter(**{f'{name}__in': value}):
vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
vc_interface_ids.extend(device.vc_interfaces(if_master=if_master).values_list('id', flat=True))
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()

View File

@ -69,11 +69,14 @@ class PowerPortBulkCreateForm(
class PowerOutletBulkCreateForm(
form_from_model(PowerOutlet, ['type', 'color', 'feed_leg', 'mark_connected']),
form_from_model(PowerOutlet, ['type', 'status', 'color', 'feed_leg', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = PowerOutlet
field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
field_order = (
'name', 'label', 'type', 'status', 'color', 'feed_leg', 'mark_connected',
'description', 'tags',
)
class InterfaceBulkCreateForm(

View File

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

View File

@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
required=True,
help_text=_('Comma-separated list of individual unit numbers')
)
status = CSVChoiceField(
label=_('Status'),
choices=RackReservationStatusChoices,
help_text=_('Operational status')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
class Meta:
model = RackReservation
fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags')
fields = ('site', 'location', 'rack', 'units', 'status', 'tenant', 'description', 'comments', 'tags')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@ -504,6 +509,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class PlatformImportForm(NetBoxModelImportForm):
slug = SlugField()
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent platform'),
error_messages={
'invalid_choice': _('Platform not found.'),
}
)
manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
@ -522,7 +537,7 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta:
model = Platform
fields = (
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags',
)
@ -676,6 +691,12 @@ class DeviceImportForm(BaseDeviceImportForm):
})
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
# Limit platform queryset by manufacturer
params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
Q(**params) | Q(manufacturer=None)
)
# Limit device bay queryset by parent device
if parent := data.get('parent'):
params = {f"device__{self.fields['parent'].to_field_name}": parent}

View File

@ -19,6 +19,11 @@ def get_cable_form(a_type, b_type):
# Device component
if hasattr(term_cls, 'device'):
# Dynamically change the param field for interfaces to use virtual_chassis filter
query_param_device_field = 'device_id'
if term_cls == Interface:
query_param_device_field = 'virtual_chassis_member_or_master_id'
attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
label=_('Device'),
@ -36,7 +41,7 @@ def get_cable_form(a_type, b_type):
'parent': 'device',
},
query_params={
'device_id': f'$termination_{cable_end}_device',
query_param_device_field: f'$termination_{cable_end}_device',
'kind': 'physical', # Exclude virtual interfaces
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

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):
dependencies = [
('dcim', '0208_devicerole_uniqueness'),
('dcim', '0210_macaddress_ordering'),
('extras', '0129_fix_script_paths'),
]

View File

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

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.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.exceptions import AbortRequest
@ -156,6 +157,15 @@ class Cable(PrimaryModel):
self._terminations_modified = True
self._b_terminations = value
@property
def color_name(self):
color_name = ""
for hex_code, label in ColorChoices.CHOICES:
if hex_code.lower() == self.color.lower():
color_name = str(label)
return color_name
def clean(self):
super().clean()

View File

@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
blank=True
)
# Denormalized references replicated from the parent Device
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
)
_rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
)
class Meta:
abstract = True
ordering = ('device', 'name')
@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
"device": _("Components cannot be moved to a different device.")
})
def save(self, *args, **kwargs):
# Save denormalized references
self._site = self.device.site
self._location = self.device.location
self._rack = self.device.rack
super().save(*args, **kwargs)
@property
def parent_object(self):
return self.device

View File

@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError
from django.db.models import F, ProtectedError, prefetch_related_objects
from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
from .device_components import *
from .mixins import RenderConfigMixin
@ -424,7 +425,7 @@ class DeviceRole(NestedGroupModel):
verbose_name_plural = _('device roles')
class Platform(OrganizationalModel):
class Platform(NestedGroupModel):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
Platform may optionally be associated with a particular Manufacturer.
@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer')
)
# Override name & slug from OrganizationalModel to not enforce uniqueness
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
)
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
@ -454,6 +446,8 @@ class Platform(OrganizationalModel):
null=True
)
clone_fields = ('parent', 'description')
class Meta:
ordering = ('name',)
verbose_name = _('platform')
@ -955,7 +949,10 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
model.objects.bulk_create(components)
components = model.objects.bulk_create(components)
# Prefetch related objects to minimize queries needed during post_save
prefetch_fields = get_prefetchable_fields(model)
prefetch_related_objects(components, *prefetch_fields)
# Manually send the post_save signal for each of the newly created components
for component in components:
post_save.send(
@ -1303,7 +1300,7 @@ class MACAddress(PrimaryModel):
)
class Meta:
ordering = ('mac_address',)
ordering = ('mac_address', 'pk',)
verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses')

View File

@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
schema = models.JSONField(
blank=True,
null=True,
verbose_name=_('schema')
validators=[validate_schema],
verbose_name=_('schema'),
)
clone_fields = ('schema',)
@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
def __str__(self):
return self.name
def clean(self):
super().clean()
# Validate the schema definition
if self.schema is not None:
try:
validate_schema(self.schema)
except ValidationError as e:
raise ValidationError({
'schema': e.message,
})
class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
"""

View File

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

View File

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

View File

@ -3,13 +3,28 @@ import logging
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from dcim.choices import CableEndChoices, LinkStatusChoices
from .models import (
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
COMPONENT_MODELS = (
ConsolePort,
ConsoleServerPort,
DeviceBay,
FrontPort,
Interface,
InventoryItem,
ModuleBay,
PowerOutlet,
PowerPort,
RearPort,
)
#
# Location/rack/device assignment
@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs):
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
@receiver(post_save, sender=Device)
def handle_device_site_change(instance, created, **kwargs):
"""
Update child components to update the parent Site, Location, and Rack when a Device is saved.
"""
if not created:
for model in COMPONENT_MODELS:
model.objects.filter(device=instance).update(
_site=instance.site,
_location=instance.location,
_rack=instance.rack,
)
#
# Virtual chassis
#

View File

@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
order_by=('_abs_length')
)
color = columns.ColorColumn()
color_name = tables.Column(
verbose_name=_('Color Name'),
orderable=False
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:cable_list'
@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
'length', 'description', 'comments', 'tags', 'created', 'last_updated',
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

View File

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

View File

@ -229,6 +229,9 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name=_('Units')
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag
from extras.models import ConfigContext, ConfigContextProfile, Tag
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
@ -15,11 +15,43 @@ from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterG
from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextProfileSerializer',
'ConfigContextSerializer',
)
class ConfigContextProfileSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
tags = serializers.SlugRelatedField(
queryset=Tag.objects.all(),
slug_field='slug',
required=False,
many=True
)
data_source = DataSourceSerializer(
nested=True,
required=False
)
data_file = DataFileSerializer(
nested=True,
read_only=True
)
class Meta:
model = ConfigContextProfile
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'tags', 'comments', 'data_source',
'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
profile = ConfigContextProfileSerializer(
nested=True,
required=False,
allow_null=True,
default=None,
)
regions = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
class Meta:
model = ConfigContext
fields = [
'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions',
'id', 'url', 'display_url', 'display', 'name', 'weight', 'profile', 'description', 'is_active', 'regions',
'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path',
'data_file', 'data_synced', 'data', 'created', 'last_updated',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
'data_synced', 'data', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ from .models import *
__all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet',
'ConfigContextProfileFilterSet',
'ConfigTemplateFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet',
@ -588,11 +589,51 @@ class TaggedItemFilterSet(BaseFilterSet):
)
class ConfigContextProfileFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data file (ID)'),
)
class Meta:
model = ConfigContextProfile
fields = (
'id', 'name', 'description', 'auto_sync_enabled', 'data_synced',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
profile_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigContextProfile.objects.all(),
label=_('Profile (ID)'),
)
profile = django_filters.ModelMultipleChoiceFilter(
field_name='profile__name',
queryset=ConfigContextProfile.objects.all(),
to_field_name='name',
label=_('Profile (name)'),
)
region_id = django_filters.ModelMultipleChoiceFilter(
field_name='regions',
queryset=Region.objects.all(),

View File

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

View File

@ -18,6 +18,7 @@ from utilities.forms.fields import (
)
__all__ = (
'ConfigContextProfileImportForm',
'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm',
@ -149,6 +150,15 @@ class ExportTemplateImportForm(CSVModelForm):
)
class ConfigContextProfileImportForm(NetBoxModelImportForm):
class Meta:
model = ConfigContextProfile
fields = [
'name', 'description', 'schema', 'comments', 'tags',
]
class ConfigTemplateImportForm(CSVModelForm):
class Meta:

View File

@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextFilterForm',
'ConfigContextProfileFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm',
@ -354,16 +355,43 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
)
class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigContextProfile
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)
data_file_id = DynamicModelMultipleChoiceField(
queryset=DataFile.objects.all(),
required=False,
label=_('Data file'),
query_params={
'source_id': '$data_source_id'
}
)
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('profile', name=_('Config Context')),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
)
profile_id = DynamicModelMultipleChoiceField(
queryset=ConfigContextProfile.objects.all(),
required=False,
label=_('Profile')
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
required=False,

View File

@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'BookmarkForm',
'ConfigContextForm',
'ConfigContextProfileForm',
'ConfigTemplateForm',
'CustomFieldChoiceSetForm',
'CustomFieldForm',
@ -585,7 +586,36 @@ class TagForm(ChangelogMessageMixin, forms.ModelForm):
]
class ConfigContextProfileForm(SyncedDataMixin, NetBoxModelForm):
schema = JSONField(
label=_('Schema'),
required=False,
help_text=_("Enter a valid JSON schema to define supported attributes.")
)
tags = DynamicModelMultipleChoiceField(
label=_('Tags'),
queryset=Tag.objects.all(),
required=False
)
fieldsets = (
FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')),
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
)
class Meta:
model = ConfigContextProfile
fields = (
'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'comments', 'tags',
)
class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
profile = DynamicModelChoiceField(
label=_('Profile'),
queryset=ConfigContextProfile.objects.all(),
required=False
)
regions = DynamicModelMultipleChoiceField(
label=_('Regions'),
queryset=Region.objects.all(),
@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
)
fieldsets = (
FieldSet('name', 'weight', 'description', 'data', 'is_active', name=_('Config Context')),
FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')),
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
FieldSet(
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
class Meta:
model = ConfigContext
fields = (
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
)
def __init__(self, *args, initial=None, **kwargs):

View File

@ -8,7 +8,7 @@ from strawberry_django import FilterLookup
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from extras import models
from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import SyncedDataFilterMixin
from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin
if TYPE_CHECKING:
from core.graphql.filters import ContentTypeFilter
@ -24,6 +24,7 @@ if TYPE_CHECKING:
__all__ = (
'ConfigContextFilter',
'ConfigContextProfileFilter',
'ConfigTemplateFilter',
'CustomFieldFilter',
'CustomFieldChoiceSetFilter',
@ -97,6 +98,13 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
)
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] = strawberry_django.filter_field()
description: FilterLookup[str] = strawberry_django.filter_field()
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@ -11,6 +11,9 @@ class ExtrasQuery:
config_context: ConfigContextType = strawberry_django.field()
config_context_list: List[ConfigContextType] = strawberry_django.field()
config_context_profile: ConfigContextProfileType = strawberry_django.field()
config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field()
config_template: ConfigTemplateType = strawberry_django.field()
config_template_list: List[ConfigTemplateType] = strawberry_django.field()

View File

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

View File

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

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
@ -18,6 +19,30 @@ class Empty(Lookup):
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
class JSONEmpty(Lookup):
"""
Support "empty" lookups for JSONField keys.
A key is considered empty if it is "", null, or does not exist.
"""
lookup_name = "empty"
def as_sql(self, compiler, connection):
# self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
# Rebuild the expression using KeyTextTransform to guarantee ->> (text)
text_expr = KeyTextTransform(self.lhs.key_name, self.lhs.lhs)
lhs_sql, lhs_params = compiler.compile(text_expr)
value = self.rhs
if value not in (True, False):
raise ValueError("The 'empty' lookup only accepts True or False.")
condition = '' if value else 'NOT '
sql = f"(NULLIF({lhs_sql}, '') IS {condition}NULL)"
return sql, lhs_params
class NetHost(Lookup):
"""
Similar to ipam.lookups.NetHost, but casts the field to INET.
@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup):
CharField.register_lookup(Empty)
JSONField.register_lookup(JSONEmpty)
CachedValueField.register_lookup(NetHost)
CachedValueField.register_lookup(NetContainsOrEquals)

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 jsonschema.exceptions import ValidationError as JSONValidationError
from django.conf import settings
from django.core.validators import ValidationError
@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet
from netbox.models import ChangeLoggedModel
from netbox.models import ChangeLoggedModel, PrimaryModel
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.data import deepmerge
from utilities.jsonschema import validate_schema
__all__ = (
'ConfigContext',
'ConfigContextModel',
'ConfigContextProfile',
'ConfigTemplate',
)
@ -24,6 +28,46 @@ __all__ = (
# Config contexts
#
class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
"""
A profile which can be used to enforce parameters on a ConfigContext.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
schema = models.JSONField(
blank=True,
null=True,
validators=[validate_schema],
verbose_name=_('schema'),
help_text=_('A JSON schema specifying the structure of the context data for this profile')
)
clone_fields = ('schema',)
class Meta:
ordering = ('name',)
verbose_name = _('config context profile')
verbose_name_plural = _('config context profiles')
def __str__(self):
return self.name
def sync_data(self):
"""
Synchronize schema from the designated DataFile (if any).
"""
self.schema = self.data_file.get_data()
sync_data.alters_data = True
class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
@ -35,6 +79,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
max_length=100,
unique=True
)
profile = models.ForeignKey(
to='extras.ConfigContextProfile',
on_delete=models.PROTECT,
blank=True,
null=True,
related_name='config_contexts',
)
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=1000
@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
objects = ConfigContextQuerySet.as_manager()
clone_fields = (
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data',
'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
class Meta:
@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
)
# Validate config data against the assigned profile's schema (if any)
if self.profile and self.profile.schema:
try:
jsonschema.validate(self.data, schema=self.profile.schema)
except JSONValidationError as e:
raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e))
def sync_data(self):
"""
Synchronize context data from the designated DataFile (if any).

View File

@ -600,11 +600,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
kwargs = {
'field_name': f'custom_field_data__{self.name}'
}
# Native numeric filters will use `isnull` by default for empty lookups, but
# JSON fields require `empty` (see bug #20012).
if lookup_expr == 'isnull':
lookup_expr = 'empty'
if lookup_expr is not None:
kwargs['lookup_expr'] = lookup_expr
# 'Empty' lookup is always a boolean
if lookup_expr == 'empty':
filter_class = django_filters.BooleanFilter
# Text/URL
if self.type in (
elif self.type in (
CustomFieldTypeChoices.TYPE_TEXT,
CustomFieldTypeChoices.TYPE_LONGTEXT,
CustomFieldTypeChoices.TYPE_URL,

View File

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

View File

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

View File

@ -588,9 +588,9 @@ class BaseScript:
"""
Return data from a YAML file
"""
# TODO: DEPRECATED: Remove this method in v4.4
# TODO: DEPRECATED: Remove this method in v4.5
self._log(
_("load_yaml is deprecated and will be removed in v4.4"),
_("load_yaml is deprecated and will be removed in v4.5"),
level=LogLevelChoices.LOG_WARNING
)
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
@ -603,9 +603,9 @@ class BaseScript:
"""
Return data from a JSON file
"""
# TODO: DEPRECATED: Remove this method in v4.4
# TODO: DEPRECATED: Remove this method in v4.5
self._log(
_("load_json is deprecated and will be removed in v4.4"),
_("load_json is deprecated and will be removed in v4.5"),
level=LogLevelChoices.LOG_WARNING
)
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)

View File

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

View File

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

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):
model = ConfigContext
brief_fields = ['description', 'display', 'id', 'name', 'url']

View File

@ -1615,6 +1615,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}),
Site(name='Site 4', slug='site-4'),
])
def test_filter_integer(self):
@ -1624,6 +1625,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
def test_filter_decimal(self):
self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
@ -1632,6 +1634,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
def test_filter_boolean(self):
self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
@ -1648,6 +1651,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
def test_filter_text_loose(self):
self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
@ -1659,6 +1663,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
def test_filter_url_strict(self):
self.assertEqual(
@ -1674,17 +1679,20 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
def test_filter_url_loose(self):
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) # Contains a literal null
self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
@ -1692,6 +1700,7 @@ class CustomFieldModelFilterTest(TestCase):
self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
2
)
self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
def test_filter_multiobject(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
@ -1703,3 +1712,4 @@ class CustomFieldModelFilterTest(TestCase):
self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
3
)
self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)

View File

@ -45,4 +45,4 @@ class ObjectListWidgetTests(TestCase):
mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request)
self.assertTrue('Unable to load content. Invalid view name:' in rendered)
self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)

View File

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

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