mirror of
https://github.com/netbox-community/netbox.git
synced 2025-09-06 14:23:36 -06:00
Merge branch 'netbox-community:main' into 19896-cf-minmax-mustbe-int
This commit is contained in:
commit
00afc1e7cb
@ -15,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.3.7
|
placeholder: v4.4.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -27,7 +27,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.3.7
|
placeholder: v4.4.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@ yarn-error.log*
|
|||||||
/netbox/netbox/configuration.py
|
/netbox/netbox/configuration.py
|
||||||
/netbox/netbox/ldap_config.py
|
/netbox/netbox/ldap_config.py
|
||||||
/netbox/local/*
|
/netbox/local/*
|
||||||
|
/netbox/media
|
||||||
/netbox/reports/*
|
/netbox/reports/*
|
||||||
!/netbox/reports/__init__.py
|
!/netbox/reports/__init__.py
|
||||||
/netbox/scripts/*
|
/netbox/scripts/*
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
# Shell text coloring
|
||||||
|
# https://github.com/tartley/colorama/blob/master/CHANGELOG.rst
|
||||||
|
colorama
|
||||||
|
|
||||||
# The Python web framework on which NetBox is built
|
# The Python web framework on which NetBox is built
|
||||||
# https://docs.djangoproject.com/en/stable/releases/
|
# https://docs.djangoproject.com/en/stable/releases/
|
||||||
Django==5.2.*
|
Django==5.2.*
|
||||||
@ -102,7 +106,11 @@ mkdocs-material
|
|||||||
|
|
||||||
# Introspection for embedded code
|
# Introspection for embedded code
|
||||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||||
mkdocstrings[python]
|
mkdocstrings
|
||||||
|
|
||||||
|
# Python handler for mkdocstrings
|
||||||
|
# https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md
|
||||||
|
mkdocstrings-python
|
||||||
|
|
||||||
# Library for manipulating IP prefixes and addresses
|
# Library for manipulating IP prefixes and addresses
|
||||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||||
@ -131,7 +139,8 @@ requests
|
|||||||
|
|
||||||
# rq
|
# rq
|
||||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||||
rq
|
# RQ v2.5 drops support for Redis < 5.0
|
||||||
|
rq==2.4.1
|
||||||
|
|
||||||
# Django app for social-auth-core
|
# Django app for social-auth-core
|
||||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||||
@ -141,6 +150,10 @@ social-auth-app-django
|
|||||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||||
social-auth-core
|
social-auth-core
|
||||||
|
|
||||||
|
# Image thumbnail generation
|
||||||
|
# https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst
|
||||||
|
sorl-thumbnail
|
||||||
|
|
||||||
# Strawberry GraphQL
|
# Strawberry GraphQL
|
||||||
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
||||||
strawberry-graphql
|
strawberry-graphql
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=NetBox Housekeeping Service
|
|
||||||
Documentation=https://docs.netbox.dev/
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
|
|
||||||
User=netbox
|
|
||||||
Group=netbox
|
|
||||||
WorkingDirectory=/opt/netbox
|
|
||||||
|
|
||||||
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# This shell script invokes NetBox's housekeeping management command, which
|
|
||||||
# intended to be run nightly. This script can be copied into your system's
|
|
||||||
# daily cron directory (e.g. /etc/cron.daily), or referenced directly from
|
|
||||||
# within the cron configuration file.
|
|
||||||
#
|
|
||||||
# If NetBox has been installed into a nonstandard location, update the paths
|
|
||||||
# below.
|
|
||||||
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
|
|
@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=NetBox Housekeeping Timer
|
|
||||||
Documentation=https://docs.netbox.dev/
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=daily
|
|
||||||
AccuracySec=1h
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
@ -1,49 +0,0 @@
|
|||||||
# Housekeeping
|
|
||||||
|
|
||||||
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
|
|
||||||
|
|
||||||
* Clearing expired authentication sessions from the database
|
|
||||||
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
|
|
||||||
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
|
|
||||||
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
|
|
||||||
|
|
||||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
|
|
||||||
|
|
||||||
## Scheduling
|
|
||||||
|
|
||||||
### Using Cron
|
|
||||||
|
|
||||||
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
|
|
||||||
|
|
||||||
### Using Systemd
|
|
||||||
|
|
||||||
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
|
|
||||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, reload the systemd configuration and enable the timer to start automatically at boot:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now netbox-housekeeping.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
Check the status of your timer by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl list-timers --all
|
|
||||||
```
|
|
||||||
|
|
||||||
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
|
|
||||||
|
|
||||||
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.
|
|
@ -108,8 +108,6 @@ By default, NetBox will prevent the creation of duplicate prefixes and IP addres
|
|||||||
|
|
||||||
## EVENTS_PIPELINE
|
## EVENTS_PIPELINE
|
||||||
|
|
||||||
!!! info "This parameter was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
Default: `['extras.events.process_event_queue',]`
|
Default: `['extras.events.process_event_queue',]`
|
||||||
|
|
||||||
NetBox will call dotted paths to the functions listed here for events (create, update, delete) on models as well as when custom EventRules are fired.
|
NetBox will call dotted paths to the functions listed here for events (create, update, delete) on models as well as when custom EventRules are fired.
|
||||||
|
@ -34,8 +34,6 @@ See the [`DATABASES`](#databases) configuration below for usage.
|
|||||||
|
|
||||||
## DATABASES
|
## DATABASES
|
||||||
|
|
||||||
!!! info "This parameter was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
|
NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
@ -14,8 +14,6 @@ BASE_PATH = 'netbox/'
|
|||||||
|
|
||||||
## DATABASE_ROUTERS
|
## DATABASE_ROUTERS
|
||||||
|
|
||||||
!!! info "This parameter was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
Default: `[]` (empty list)
|
Default: `[]` (empty list)
|
||||||
|
|
||||||
An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured.
|
An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured.
|
||||||
@ -72,6 +70,16 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## HOSTNAME
|
||||||
|
|
||||||
|
!!! info "This parameter was introduced in NetBox v4.4."
|
||||||
|
|
||||||
|
Default: System hostname
|
||||||
|
|
||||||
|
The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## HTTP_PROXIES
|
## HTTP_PROXIES
|
||||||
|
|
||||||
Default: `None`
|
Default: `None`
|
||||||
@ -159,6 +167,7 @@ LOGGING = {
|
|||||||
* `netbox.auth.*` - Authentication events
|
* `netbox.auth.*` - Authentication events
|
||||||
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
||||||
* `netbox.event_rules` - Event rules
|
* `netbox.event_rules` - Event rules
|
||||||
|
* `netbox.jobs.*` - Background jobs
|
||||||
* `netbox.reports.*` - Report execution (`module.name`)
|
* `netbox.reports.*` - Report execution (`module.name`)
|
||||||
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
||||||
* `netbox.views.*` - Views which handle business logic for the web UI
|
* `netbox.views.*` - Views which handle business logic for the web UI
|
||||||
@ -175,8 +184,6 @@ The file path to the location where media files (such as image attachments) are
|
|||||||
|
|
||||||
## PROXY_ROUTERS
|
## PROXY_ROUTERS
|
||||||
|
|
||||||
!!! info "This parameter was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
Default: `["utilities.proxy.DefaultProxyRouter"]`
|
Default: `["utilities.proxy.DefaultProxyRouter"]`
|
||||||
|
|
||||||
A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class.
|
A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class.
|
||||||
|
@ -275,6 +275,15 @@ Stores a numeric integer. Options include:
|
|||||||
* `min_value` - Minimum value
|
* `min_value` - Minimum value
|
||||||
* `max_value` - Maximum value
|
* `max_value` - Maximum value
|
||||||
|
|
||||||
|
### DecimalVar
|
||||||
|
|
||||||
|
Stores a numeric decimal. Options include:
|
||||||
|
|
||||||
|
* `min_value` - Minimum value
|
||||||
|
* `max_value` - Maximum value
|
||||||
|
* `max_digits` - Maximum number of digits, including decimal places
|
||||||
|
* `decimal_places` - Number of decimal places
|
||||||
|
|
||||||
### BooleanVar
|
### BooleanVar
|
||||||
|
|
||||||
A true/false flag. This field has no options beyond the defaults listed above.
|
A true/false flag. This field has no options beyond the defaults listed above.
|
||||||
|
@ -22,24 +22,9 @@ Stores registration made using `netbox.denormalized.register()`. For each model,
|
|||||||
|
|
||||||
### `model_features`
|
### `model_features`
|
||||||
|
|
||||||
A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example:
|
A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.
|
||||||
|
|
||||||
```python
|
Core model features are listed in the [features matrix](./models.md#features-matrix).
|
||||||
{
|
|
||||||
'custom_fields': {
|
|
||||||
'circuits': ['provider', 'circuit'],
|
|
||||||
'dcim': ['site', 'rack', 'devicetype', ...],
|
|
||||||
...
|
|
||||||
},
|
|
||||||
'event_rules': {
|
|
||||||
'extras': ['configcontext', 'tag', ...],
|
|
||||||
'dcim': ['site', 'rack', 'devicetype', ...],
|
|
||||||
},
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported model features are listed in the [features matrix](./models.md#features-matrix).
|
|
||||||
|
|
||||||
### `models`
|
### `models`
|
||||||
|
|
||||||
|
@ -10,19 +10,26 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
|||||||
|
|
||||||
Depending on its classification, each NetBox model may support various features which enhance its operation. Each feature is enabled by inheriting from its designated mixin class, and some features also make use of the [application registry](./application-registry.md#model_features).
|
Depending on its classification, each NetBox model may support various features which enhance its operation. Each feature is enabled by inheriting from its designated mixin class, and some features also make use of the [application registry](./application-registry.md#model_features).
|
||||||
|
|
||||||
| Feature | Feature Mixin | Registry Key | Description |
|
| Feature | Feature Mixin | Registry Key | Description |
|
||||||
|------------------------------------------------------------|-------------------------|--------------------|-----------------------------------------------------------------------------------------|
|
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
|
||||||
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | - | Changes to these objects are automatically recorded in the change log |
|
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
||||||
| Cloning | `CloningMixin` | - | Provides the `clone()` method to prepare a copy |
|
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
|
||||||
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
|
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
|
||||||
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
|
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
|
||||||
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
|
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
|
||||||
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
|
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
|
||||||
| [Job results](../features/background-jobs.md) | `JobsMixin` | `jobs` | Background jobs can be scheduled for these models |
|
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
|
||||||
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
|
| [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events |
|
||||||
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
|
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
|
||||||
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
|
| [Image attachments](../models/extras/imageattachment.md) | `ImageAttachmentsMixin` | `image_attachments` | Image uploads can be attached to these models |
|
||||||
| [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events |
|
| [Jobs](../features/background-jobs.md) | `JobsMixin` | `jobs` | Background jobs can be scheduled for these models |
|
||||||
|
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
|
||||||
|
| [Notifications](../features/notifications.md) | `NotificationsMixin` | `notifications` | These models support user notifications |
|
||||||
|
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
|
||||||
|
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
The above listed features are supported natively by NetBox. Beginning with NetBox v4.4.0, plugins can register their own model features as well.
|
||||||
|
|
||||||
## Models Index
|
## Models Index
|
||||||
|
|
||||||
|
@ -31,28 +31,14 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto
|
|||||||
|
|
||||||
Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
|
Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
|
||||||
|
|
||||||
### Update the Dependency Requirements Matrix
|
|
||||||
|
|
||||||
For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
|
|
||||||
|
|
||||||
1. Add a new row with the supported dependency versions.
|
|
||||||
2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
|
|
||||||
3. Bold any version changes for clarity.
|
|
||||||
|
|
||||||
**Example Update:**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
|
||||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
|
||||||
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update System Requirements
|
### Update System Requirements
|
||||||
|
|
||||||
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
|
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
|
||||||
|
|
||||||
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
||||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version.
|
||||||
|
* Update the minimum versions for each dependency.
|
||||||
|
* Add a new row to the release history table. Bold any version changes for clarity.
|
||||||
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
|
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
|
||||||
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
|
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
|
||||||
|
|
||||||
|
@ -8,6 +8,12 @@ When a request is made, a UUID is generated and attached to any change records r
|
|||||||
|
|
||||||
Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format.
|
Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format.
|
||||||
|
|
||||||
|
## User Messages
|
||||||
|
|
||||||
|
!!! info "This feature was introduced in NetBox v4.4."
|
||||||
|
|
||||||
|
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change.
|
||||||
|
|
||||||
## Correlating Changes by Request
|
## Correlating Changes by Request
|
||||||
|
|
||||||
Every request made to NetBox is assigned a random unique ID that can be used to correlate change records. For example, if you change the status of three sites using the UI's bulk edit feature, you will see three new change records (one for each site) all referencing the same request ID. This shows that all three changes were made as part of the same request.
|
Every request made to NetBox is assigned a random unique ID that can be used to correlate change records. For example, if you change the status of three sites using the UI's bulk edit feature, you will see three new change records (one for each site) all referencing the same request ID. This shows that all three changes were made as part of the same request.
|
||||||
|
@ -62,8 +62,8 @@ VRF modeling in NetBox very closely follows what you find in real-world network
|
|||||||
|
|
||||||
An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR.
|
An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR.
|
||||||
|
|
||||||
## Service Mapping
|
## Application Service Mapping
|
||||||
|
|
||||||
NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools.
|
NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools.
|
||||||
|
|
||||||
To model services in NetBox, begin by creating a service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new services by hand, without a template, however this approach can be tedious.
|
To model application services in NetBox, begin by creating an application service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new application services by hand, without a template, however this approach can be tedious.
|
||||||
|
@ -264,18 +264,6 @@ cd /opt/netbox/netbox
|
|||||||
python3 manage.py createsuperuser
|
python3 manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Schedule the Housekeeping Task
|
|
||||||
|
|
||||||
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
|
|
||||||
|
|
||||||
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
|
||||||
|
|
||||||
## Test the Application
|
## Test the Application
|
||||||
|
|
||||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
|
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
|
||||||
|
@ -25,42 +25,21 @@ NetBox requires the following dependencies:
|
|||||||
|
|
||||||
### Version History
|
### Version History
|
||||||
|
|
||||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
|
||||||
| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
|
| 4.4 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
|
||||||
| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
|
||||||
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
|
| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||||
| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
|
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
|
||||||
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
|
| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
|
||||||
| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
|
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
|
||||||
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
|
| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
|
||||||
| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
|
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
|
||||||
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
|
| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
|
||||||
| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
|
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
|
||||||
| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
|
| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
|
||||||
| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
|
| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
|
||||||
| 2.11 | 3.6 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
|
| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
|
||||||
| 2.10 | 3.6 | 3.8 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
|
|
||||||
| 2.9 | 3.6 | 3.8 | 9.5 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
|
|
||||||
| 2.8 | 3.6 | 3.8 | 9.5 | 3.4 | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
|
|
||||||
| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
|
|
||||||
| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
|
|
||||||
| 2.5 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
|
|
||||||
| 2.4 | 3.4 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
|
|
||||||
| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
|
|
||||||
| 2.2 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
|
|
||||||
| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
|
|
||||||
| 2.0 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
|
|
||||||
| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.5 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
|
|
||||||
| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
|
|
||||||
| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
|
|
||||||
|
|
||||||
## 3. Install the Latest Release
|
## 3. Install the Latest Release
|
||||||
|
|
||||||
@ -183,13 +162,3 @@ Finally, restart the gunicorn and RQ services:
|
|||||||
```no-highlight
|
```no-highlight
|
||||||
sudo systemctl restart netbox netbox-rq
|
sudo systemctl restart netbox netbox-rq
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Verify Housekeeping Scheduling
|
|
||||||
|
|
||||||
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
|
||||||
|
@ -11,6 +11,8 @@ NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-pr
|
|||||||
- Per model insert, update, and delete counters
|
- Per model insert, update, and delete counters
|
||||||
- Per view request counters
|
- Per view request counters
|
||||||
- Per view request latency histograms
|
- Per view request latency histograms
|
||||||
|
- REST API requests (by endpoint & method)
|
||||||
|
- GraphQL API requests
|
||||||
- Request body size histograms
|
- Request body size histograms
|
||||||
- Response body size histograms
|
- Response body size histograms
|
||||||
- Response code counters
|
- Response code counters
|
||||||
|
@ -608,6 +608,28 @@ http://netbox/api/dcim/sites/ \
|
|||||||
!!! note
|
!!! note
|
||||||
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
|
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
|
||||||
|
|
||||||
|
## Changelog Messages
|
||||||
|
|
||||||
|
!!! info "This feature was introduced in NetBox v4.4."
|
||||||
|
|
||||||
|
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Beginning in NetBox v4.4, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
|
||||||
|
|
||||||
|
For example, the following API request will create a new site and record a message in the resulting changelog entry:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
http://netbox/api/dcim/sites/ \
|
||||||
|
--data '{
|
||||||
|
"name": "Site A",
|
||||||
|
"slug": "site-a",
|
||||||
|
"changelog_message": "Adding a site for ticket #4137"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach works when creating, modifying, or deleting objects, either individually or in bulk.
|
||||||
|
|
||||||
## Uploading Files
|
## Uploading Files
|
||||||
|
|
||||||
As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
|
As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
|
||||||
|
@ -38,8 +38,6 @@ The operational status of the circuit. By default, the following statuses are av
|
|||||||
|
|
||||||
### Distance
|
### Distance
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The distance between the circuit's two endpoints, including a unit designation (e.g. 100 meters or 25 feet).
|
The distance between the circuit's two endpoints, including a unit designation (e.g. 100 meters or 25 feet).
|
||||||
|
|
||||||
### Description
|
### Description
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# Virtual Circuits
|
# Virtual Circuits
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md).
|
A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md).
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# Virtual Circuit Terminations
|
# Virtual Circuit Terminations
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md).
|
This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md).
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
@ -46,8 +46,6 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza
|
|||||||
|
|
||||||
### Sync Interval
|
### Sync Interval
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually.
|
The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually.
|
||||||
|
|
||||||
### Last Synced
|
### Last Synced
|
||||||
|
@ -6,8 +6,6 @@ Devices can be organized by functional roles, which are fully customizable by th
|
|||||||
|
|
||||||
### Parent
|
### Parent
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
The parent role of which this role is a child (optional).
|
The parent role of which this role is a child (optional).
|
||||||
|
|
||||||
### Name
|
### Name
|
||||||
|
@ -126,8 +126,6 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl
|
|||||||
|
|
||||||
### Q-in-Q SVLAN
|
### Q-in-Q SVLAN
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
|
The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
|
||||||
|
|
||||||
### Wireless Role
|
### Wireless Role
|
||||||
@ -155,6 +153,4 @@ The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries
|
|||||||
|
|
||||||
### VLAN Translation Policy
|
### VLAN Translation Policy
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).
|
The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).
|
||||||
|
@ -30,8 +30,6 @@ An alternative physical label identifying the inventory item.
|
|||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The inventory item's operational status.
|
The inventory item's operational status.
|
||||||
|
|
||||||
### Role
|
### Role
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# MAC Addresses
|
# MAC Addresses
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface.
|
A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface.
|
||||||
|
|
||||||
Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface.
|
Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface.
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# Module Type Profiles
|
# Module Type Profiles
|
||||||
|
|
||||||
!!! info "This model was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
|
Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
|
||||||
|
|
||||||
Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes.
|
Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes.
|
||||||
|
@ -2,19 +2,27 @@
|
|||||||
|
|
||||||
A platform defines the type of software running on a [device](./device.md) or [virtual machine](../virtualization/virtualmachine.md). This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15.
|
A platform defines the type of software running on a [device](./device.md) or [virtual machine](../virtualization/virtualmachine.md). This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15.
|
||||||
|
|
||||||
|
Platforms may be nested under parents to form a hierarchy. For example, platforms named "Debian" and "RHEL" might both be created under a generic "Linux" parent.
|
||||||
|
|
||||||
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
||||||
|
|
||||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
The assignment of platforms to devices and virtual machines is optional.
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
|
||||||
|
## Parent
|
||||||
|
|
||||||
|
!!! "This field was introduced in NetBox v4.4."
|
||||||
|
|
||||||
|
The parent platform class to which this platform belongs (optional).
|
||||||
|
|
||||||
### Name
|
### Name
|
||||||
|
|
||||||
A unique human-friendly name.
|
A human-friendly name for the platform. Must be unique per manufacturer.
|
||||||
|
|
||||||
### Slug
|
### Slug
|
||||||
|
|
||||||
A unique URL-friendly identifier. (This value can be used for filtering.)
|
A URL-friendly identifier; must be unique per manufacturer. (This value can be used for filtering.)
|
||||||
|
|
||||||
### Manufacturer
|
### Manufacturer
|
||||||
|
|
||||||
|
@ -40,12 +40,8 @@ The operational status of the power outlet. By default, the following statuses a
|
|||||||
!!! tip "Custom power outlet statuses"
|
!!! tip "Custom power outlet statuses"
|
||||||
Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
### Color
|
### Color
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The power outlet's color (optional).
|
The power outlet's color (optional).
|
||||||
|
|
||||||
### Power Port
|
### Power Port
|
||||||
|
@ -12,6 +12,13 @@ The [rack](./rack.md) being reserved.
|
|||||||
|
|
||||||
The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7.
|
The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.)
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
Additional statuses may be defined by setting `RackReservation.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||||
|
|
||||||
### User
|
### User
|
||||||
|
|
||||||
The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.
|
The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.
|
||||||
|
@ -42,8 +42,6 @@ The number of the numerically lowest unit in the rack. This value defaults to on
|
|||||||
|
|
||||||
The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
|
The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
|
||||||
|
|
||||||
!!! info "The `outer_height` field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
### Mounting Depth
|
### Mounting Depth
|
||||||
|
|
||||||
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
|
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
|
||||||
|
@ -14,6 +14,10 @@ A unique human-friendly name.
|
|||||||
|
|
||||||
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
|
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data.
|
||||||
|
|
||||||
### Data
|
### Data
|
||||||
|
|
||||||
The context data expressed in JSON format.
|
The context data expressed in JSON format.
|
||||||
|
33
docs/models/extras/configcontextprofile.md
Normal file
33
docs/models/extras/configcontextprofile.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Config Context Profiles
|
||||||
|
|
||||||
|
Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The later is achieved by defining a [JSON schema](https://json-schema.org/) to which all config context with this profile assigned must comply.
|
||||||
|
|
||||||
|
For example, the following schema defines two keys, `size` and `priority`, of which the former is required:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["high", "medium", "low"],
|
||||||
|
"default": "medium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"size"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### Name
|
||||||
|
|
||||||
|
A unique human-friendly name.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
The JSON schema to be enforced for all assigned config contexts (optional).
|
@ -34,24 +34,15 @@ The `undefined` and `finalize` Jinja environment parameters, which must referenc
|
|||||||
|
|
||||||
### MIME Type
|
### MIME Type
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`.
|
The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`.
|
||||||
|
|
||||||
### File Name
|
### File Name
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
The file name to give to the rendered export file (optional).
|
The file name to give to the rendered export file (optional).
|
||||||
|
|
||||||
### File Extension
|
### File Extension
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
The file extension to append to the file name in the response (optional).
|
The file extension to append to the file name in the response (optional).
|
||||||
|
|
||||||
### As Attachment
|
### As Attachment
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported).
|
If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported).
|
@ -22,8 +22,6 @@ Jinja2 template code for rendering the exported data.
|
|||||||
|
|
||||||
### Environment Parameters
|
### Environment Parameters
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
|
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
|
||||||
|
|
||||||
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
|
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
|
||||||
|
@ -20,8 +20,6 @@ The color to use when displaying the tag in the NetBox UI.
|
|||||||
|
|
||||||
A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
|
A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
### Object Types
|
### Object Types
|
||||||
|
|
||||||
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
|
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
# Services
|
# Application Services
|
||||||
|
|
||||||
A service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine.
|
An application service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine.
|
||||||
|
|
||||||
To aid in the efficient creation of services, users may opt to first create a [service template](./servicetemplate.md) from which service definitions can be quickly replicated.
|
To aid in the efficient creation of application services, users may opt to first create an [application service template](./servicetemplate.md) from which service definitions can be quickly replicated.
|
||||||
|
|
||||||
|
!!! note "Changed in NetBox v4.4"
|
||||||
|
|
||||||
|
Previously, application services were referred to simply as "services". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
|
||||||
### Parent
|
### Parent
|
||||||
|
|
||||||
The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md),
|
The parent object to which the application service is assigned. This must be one of [Device](../dcim/device.md),
|
||||||
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
|
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
|
||||||
|
|
||||||
!!! note "Changed in NetBox v4.3"
|
!!! note "Changed in NetBox v4.3"
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
# Service Templates
|
# Application Service Templates
|
||||||
|
|
||||||
Service templates can be used to instantiate [services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md).
|
Application service templates can be used to instantiate [application services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md).
|
||||||
|
|
||||||
|
!!! note "Changed in NetBox v4.4"
|
||||||
|
|
||||||
|
Previously, application service templates were referred to simply as "service templates". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
|
||||||
|
@ -25,16 +25,15 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
|
|||||||
|
|
||||||
### VLAN Group or Site
|
### VLAN Group or Site
|
||||||
|
|
||||||
|
!!! warning "Site assignment is deprecated"
|
||||||
|
The assignment of individual VLANs directly to a site has been deprecated. This ability will be removed in a future NetBox release. Users are strongly encouraged to utilize VLAN groups, which have the added benefit of supporting the assignment of a VLAN to multiple sites.
|
||||||
|
|
||||||
The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
|
The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
|
||||||
|
|
||||||
### Q-in-Q Role
|
### Q-in-Q Role
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN.
|
For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN.
|
||||||
|
|
||||||
### Q-in-Q Service VLAN
|
### Q-in-Q Service VLAN
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs.
|
The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs.
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# VLAN Translation Policies
|
# VLAN Translation Policies
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details.
|
VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details.
|
||||||
|
|
||||||
There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this:
|
There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this:
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
# VLAN Translation Rules
|
# VLAN Translation Rules
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy.
|
A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy.
|
||||||
|
|
||||||
See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature.
|
See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature.
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
## Interfaces
|
## Interfaces
|
||||||
|
|
||||||
[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and services attached to them, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them.
|
[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them.
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
|
||||||
@ -59,8 +59,6 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl
|
|||||||
|
|
||||||
### Q-in-Q SVLAN
|
### Q-in-Q SVLAN
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
|
The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
|
||||||
|
|
||||||
### VRF
|
### VRF
|
||||||
@ -69,6 +67,4 @@ The [virtual routing and forwarding](../ipam/vrf.md) instance to which this inte
|
|||||||
|
|
||||||
### VLAN Translation Policy
|
### VLAN Translation Policy
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).
|
The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).
|
||||||
|
@ -44,8 +44,6 @@ The operational status of the L2VPN. By default, the following statuses are avai
|
|||||||
!!! tip "Custom L2VPN statuses"
|
!!! tip "Custom L2VPN statuses"
|
||||||
Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
### Identifier
|
### Identifier
|
||||||
|
|
||||||
An optional numeric identifier. This can be used to track a pseudowire ID, for example.
|
An optional numeric identifier. This can be used to track a pseudowire ID, for example.
|
||||||
|
@ -46,6 +46,4 @@ The security key configured on each client to grant access to the secured wirele
|
|||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
!!! info "This field was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated.
|
The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated.
|
||||||
|
@ -39,6 +39,27 @@ You can schedule the background job from within your code (e.g. from a model's `
|
|||||||
|
|
||||||
This is the human-friendly names of your background job. If omitted, the class name will be used.
|
This is the human-friendly names of your background job. If omitted, the class name will be used.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
!!! info "This feature was introduced in NetBox v4.4."
|
||||||
|
|
||||||
|
A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
obj = MyModel.objects.get(pk=kwargs.get('pk'))
|
||||||
|
self.logger.info("Retrieved object {obj}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Four of the standard Python logging levels are supported:
|
||||||
|
|
||||||
|
* `debug()`
|
||||||
|
* `info()`
|
||||||
|
* `warning()`
|
||||||
|
* `error()`
|
||||||
|
|
||||||
|
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
|
||||||
|
|
||||||
### Scheduled Jobs
|
### Scheduled Jobs
|
||||||
|
|
||||||
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
|
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
|
||||||
@ -68,8 +89,6 @@ class MyModel(NetBoxModel):
|
|||||||
|
|
||||||
### System Jobs
|
### System Jobs
|
||||||
|
|
||||||
!!! info "This feature was introduced in NetBox v4.2."
|
|
||||||
|
|
||||||
Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run.
|
Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run.
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
|
@ -24,20 +24,7 @@ Every model includes by default a numeric primary key. This value is generated a
|
|||||||
|
|
||||||
## Enabling NetBox Features
|
## Enabling NetBox Features
|
||||||
|
|
||||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
Plugin models can leverage certain [model features](../../development/models.md#features-matrix) (such as tags, custom fields, event rules, etc.) by inheriting from NetBox's `NetBoxModel` class. This class performs two crucial functions:
|
||||||
|
|
||||||
* Bookmarks
|
|
||||||
* Change logging
|
|
||||||
* Cloning
|
|
||||||
* Custom fields
|
|
||||||
* Custom links
|
|
||||||
* Custom validation
|
|
||||||
* Export templates
|
|
||||||
* Journaling
|
|
||||||
* Tags
|
|
||||||
* Webhooks
|
|
||||||
|
|
||||||
This class performs two crucial functions:
|
|
||||||
|
|
||||||
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
|
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
|
||||||
2. Register the model with NetBox as utilizing these features
|
2. Register the model with NetBox as utilizing these features
|
||||||
@ -119,8 +106,6 @@ For more information about database migrations, see the [Django documentation](h
|
|||||||
|
|
||||||
::: netbox.models.features.ContactsMixin
|
::: netbox.models.features.ContactsMixin
|
||||||
|
|
||||||
!!! info "Plugin support for ContactsMixin was introduced in NetBox v4.3."
|
|
||||||
|
|
||||||
::: netbox.models.features.CustomLinksMixin
|
::: netbox.models.features.CustomLinksMixin
|
||||||
|
|
||||||
::: netbox.models.features.CustomFieldsMixin
|
::: netbox.models.features.CustomFieldsMixin
|
||||||
@ -137,6 +122,27 @@ For more information about database migrations, see the [Django documentation](h
|
|||||||
|
|
||||||
::: netbox.models.features.TagsMixin
|
::: netbox.models.features.TagsMixin
|
||||||
|
|
||||||
|
## Custom Model Features
|
||||||
|
|
||||||
|
In addition to utilizing the model features provided natively by NetBox (listed above), plugins can register their own model features. This is done using the `register_model_feature()` function from `netbox.utils`. This function takes two arguments: a feature name, and a callable which accepts a model class. The callable must return a boolean value indicting whether the given model supports the named feature.
|
||||||
|
|
||||||
|
This function can be used as a decorator:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@register_model_feature('foo')
|
||||||
|
def supports_foo(model):
|
||||||
|
# Your logic here
|
||||||
|
```
|
||||||
|
|
||||||
|
Or it can be called directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
register_model_feature('foo', supports_foo)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
Consider performing feature registration inside your PluginConfig's `ready()` method.
|
||||||
|
|
||||||
## Choice Sets
|
## Choice Sets
|
||||||
|
|
||||||
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)
|
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)
|
||||||
|
@ -51,6 +51,10 @@ This will automatically apply any user-specific preferences for the table. (If u
|
|||||||
|
|
||||||
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
|
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
|
||||||
|
|
||||||
|
::: netbox.tables.ArrayColumn
|
||||||
|
options:
|
||||||
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.BooleanColumn
|
::: netbox.tables.BooleanColumn
|
||||||
options:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
14
docs/plugins/development/user-interface.md
Normal file
14
docs/plugins/development/user-interface.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# User Interface
|
||||||
|
|
||||||
|
## Light & Dark Mode
|
||||||
|
|
||||||
|
The NetBox user interface supports toggling between light and dark versions of the theme. If needed, a plugin can determine the currently active color theme by inspecting `window.localStorage['netbox-color-mode']`, which will indicate either `light` or `dark`.
|
||||||
|
|
||||||
|
Additionally, when the color scheme is toggled by the user, a custom event `netbox.colorModeChanged` indicating the new scheme is dispatched. A plugin can listen for this event if needed to react to the change:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
window.addEventListener('netbox.colorModeChanged', e => {
|
||||||
|
const customEvent = e as CustomEvent<ColorModeData>;
|
||||||
|
console.log('New color mode:', customEvent.detail.netboxColorMode);
|
||||||
|
});
|
||||||
|
```
|
@ -64,6 +64,7 @@ Generic view classes (documented below) facilitate common operations, such as cr
|
|||||||
| `ObjectListView` | View a list of objects |
|
| `ObjectListView` | View a list of objects |
|
||||||
| `BulkImportView` | Import a set of new objects |
|
| `BulkImportView` | Import a set of new objects |
|
||||||
| `BulkEditView` | Edit multiple objects |
|
| `BulkEditView` | Edit multiple objects |
|
||||||
|
| `BulkRenameView` | Rename multiple objects |
|
||||||
| `BulkDeleteView` | Delete multiple objects |
|
| `BulkDeleteView` | Delete multiple objects |
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
@ -171,6 +172,10 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
|||||||
options:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
|
::: netbox.views.generic.BulkRenameView
|
||||||
|
options:
|
||||||
|
members: false
|
||||||
|
|
||||||
::: netbox.views.generic.BulkDeleteView
|
::: netbox.views.generic.BulkDeleteView
|
||||||
options:
|
options:
|
||||||
members:
|
members:
|
||||||
|
75
docs/plugins/development/webhooks.md
Normal file
75
docs/plugins/development/webhooks.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Webhooks
|
||||||
|
|
||||||
|
NetBox supports the configuration of outbound [webhooks](../../integrations/webhooks.md) which can be triggered by custom [event rules](../../features/event-rules.md). By default, a webhook's payload will contain a serialized representation of the object, before & after snapshots (if applicable), and some metadata.
|
||||||
|
|
||||||
|
## Callback Registration
|
||||||
|
|
||||||
|
Plugins can register callback functions to supplement a webhook's payload with their own data. For example, it might be desirable for a plugin to attach information about the status of some objects at the time a change was made.
|
||||||
|
|
||||||
|
This can be accomplished by defining a function which accepts a defined set of keyword arguments and registering it as a webhook callback. Whenever a new webhook is generated, the function will be called, and any data it returns will be attached to the webhook's payload under the `context` key.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from extras.webhooks import register_webhook_callback
|
||||||
|
from my_plugin.utilities import get_foo_status
|
||||||
|
|
||||||
|
@register_webhook_callback
|
||||||
|
def set_foo_status(object_type, event_type, data, request):
|
||||||
|
if status := get_foo_status():
|
||||||
|
return {
|
||||||
|
'foo': status
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting webhook payload will look like the following:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "updated",
|
||||||
|
"timestamp": "2025-08-07T14:24:30.627321+00:00",
|
||||||
|
"object_type": "dcim.site",
|
||||||
|
"username": "admin",
|
||||||
|
"request_id": "49e3e39e-7333-4b9c-a9af-19f0dc1e7dc9",
|
||||||
|
"data": {
|
||||||
|
"id": 2,
|
||||||
|
"url": "/api/dcim/sites/2/",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"snapshots": {...},
|
||||||
|
"context": {
|
||||||
|
"foo": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note "Consider namespacing webhook data"
|
||||||
|
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return {
|
||||||
|
'my_plugin': {
|
||||||
|
'foo': 123,
|
||||||
|
'bar': 456,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Callback Function Arguments
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|---------------|-------------------|-------------------------------------------------------------------|
|
||||||
|
| `object_type` | ObjectType | The ObjectType which represents the triggering object |
|
||||||
|
| `event_type` | String | The type of event which triggered the webhook (see `core.events`) |
|
||||||
|
| `data` | Dictionary | The serialized representation of the object |
|
||||||
|
| `request` | NetBoxFakeRequest | A copy of the request (if any) which resulted in the change |
|
||||||
|
|
||||||
|
## Where to Define Callbacks
|
||||||
|
|
||||||
|
Webhook callbacks can be defined anywhere within a plugin, but must be imported during plugin initialization. If you wish to keep them in a separate module, you can import that module under the PluginConfig's `ready()` method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def ready(self):
|
||||||
|
super().ready()
|
||||||
|
from my_plugin import webhook_callbacks
|
||||||
|
```
|
@ -10,6 +10,13 @@ Minor releases are published in April, August, and December of each calendar yea
|
|||||||
|
|
||||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||||
|
|
||||||
|
#### [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 ([#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)
|
#### [Version 4.3](./version-4.3.md) (May 2025)
|
||||||
|
|
||||||
* Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002))
|
* Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002))
|
||||||
|
@ -434,7 +434,7 @@ A new management command has been added: `manage.py housekeeping`. This command
|
|||||||
* Delete change log records which have surpassed the configured retention period (if configured)
|
* Delete change log records which have surpassed the configured retention period (if configured)
|
||||||
* Check for new NetBox releases (if enabled)
|
* Check for new NetBox releases (if enabled)
|
||||||
|
|
||||||
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the housekeeping documentation for further details.
|
||||||
|
|
||||||
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))
|
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))
|
||||||
|
|
||||||
|
87
docs/release-notes/version-4.4.md
Normal file
87
docs/release-notes/version-4.4.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# NetBox v4.4
|
||||||
|
|
||||||
|
## v4.4.0 (2025-09-02)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
#### Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
|
||||||
|
|
||||||
|
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 ([#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`.
|
||||||
|
|
||||||
|
#### Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
* [#18349](https://github.com/netbox-community/netbox/issues/18349) - The housekeeping script has been replaced with a system job
|
||||||
|
* [#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 `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:
|
||||||
|
* `app_name`
|
||||||
|
* `model_name`
|
||||||
|
* `model_name_plural`
|
||||||
|
* `is_plugin_model`
|
||||||
|
* `rest_api_endpoint`
|
||||||
|
* `description`
|
||||||
|
* Introduced the `/api/extras/config-context-profiles/` endpoint
|
||||||
|
* core.Job
|
||||||
|
* Added the read-only `log_entries` array field
|
||||||
|
* dcim.Interface
|
||||||
|
* The `tx_power` field now accepts negative values
|
||||||
|
* dcim.RackReservation
|
||||||
|
* Added the `status` choice field
|
||||||
|
* dcim.Platform
|
||||||
|
* Add an optional `parent` foreign key field to support nesting
|
||||||
|
* extras.ConfigContext
|
||||||
|
* Added the optional `profile` foreign key field
|
||||||
|
* extras.ImageAttachment
|
||||||
|
* Added an optional `description` field
|
@ -30,6 +30,8 @@ plugins:
|
|||||||
python:
|
python:
|
||||||
paths: ["netbox"]
|
paths: ["netbox"]
|
||||||
options:
|
options:
|
||||||
|
docstring_options:
|
||||||
|
warn_missing_types: false
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
members_order: source
|
members_order: source
|
||||||
show_root_heading: true
|
show_root_heading: true
|
||||||
@ -144,6 +146,8 @@ nav:
|
|||||||
- Search: 'plugins/development/search.md'
|
- Search: 'plugins/development/search.md'
|
||||||
- Event Types: 'plugins/development/event-types.md'
|
- Event Types: 'plugins/development/event-types.md'
|
||||||
- Data Backends: 'plugins/development/data-backends.md'
|
- Data Backends: 'plugins/development/data-backends.md'
|
||||||
|
- Webhooks: 'plugins/development/webhooks.md'
|
||||||
|
- User Interface: 'plugins/development/user-interface.md'
|
||||||
- REST API: 'plugins/development/rest-api.md'
|
- REST API: 'plugins/development/rest-api.md'
|
||||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||||
- Background Jobs: 'plugins/development/background-jobs.md'
|
- Background Jobs: 'plugins/development/background-jobs.md'
|
||||||
@ -158,7 +162,6 @@ nav:
|
|||||||
- Okta: 'administration/authentication/okta.md'
|
- Okta: 'administration/authentication/okta.md'
|
||||||
- Permissions: 'administration/permissions.md'
|
- Permissions: 'administration/permissions.md'
|
||||||
- Error Reporting: 'administration/error-reporting.md'
|
- Error Reporting: 'administration/error-reporting.md'
|
||||||
- Housekeeping: 'administration/housekeeping.md'
|
|
||||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||||
- NetBox Shell: 'administration/netbox-shell.md'
|
- NetBox Shell: 'administration/netbox-shell.md'
|
||||||
- Data Model:
|
- Data Model:
|
||||||
@ -225,6 +228,7 @@ nav:
|
|||||||
- Extras:
|
- Extras:
|
||||||
- Bookmark: 'models/extras/bookmark.md'
|
- Bookmark: 'models/extras/bookmark.md'
|
||||||
- ConfigContext: 'models/extras/configcontext.md'
|
- ConfigContext: 'models/extras/configcontext.md'
|
||||||
|
- ConfigContextProfile: 'models/extras/configcontextprofile.md'
|
||||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||||
- CustomField: 'models/extras/customfield.md'
|
- CustomField: 'models/extras/customfield.md'
|
||||||
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
|
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
|
||||||
@ -309,6 +313,7 @@ nav:
|
|||||||
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
||||||
- Release Notes:
|
- Release Notes:
|
||||||
- Summary: 'release-notes/index.md'
|
- Summary: 'release-notes/index.md'
|
||||||
|
- Version 4.4: 'release-notes/version-4.4.md'
|
||||||
- Version 4.3: 'release-notes/version-4.3.md'
|
- Version 4.3: 'release-notes/version-4.3.md'
|
||||||
- Version 4.2: 'release-notes/version-4.2.md'
|
- Version 4.2: 'release-notes/version-4.2.md'
|
||||||
- Version 4.1: 'release-notes/version-4.1.md'
|
- Version 4.1: 'release-notes/version-4.1.md'
|
||||||
|
@ -35,11 +35,7 @@ urlpatterns = [
|
|||||||
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
|
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
|
||||||
|
|
||||||
# Virtual circuits
|
# Virtual circuits
|
||||||
path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
|
path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))),
|
||||||
path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
|
|
||||||
path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'),
|
|
||||||
path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
|
|
||||||
path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
|
|
||||||
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
|
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
|
||||||
|
|
||||||
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
|
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
|
||||||
|
@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from dcim.views import PathTraceView
|
from dcim.views import PathTraceView
|
||||||
from ipam.models import ASN
|
from ipam.models import ASN
|
||||||
|
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
@ -79,6 +80,11 @@ class ProviderBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.ProviderBulkEditForm
|
form = forms.ProviderBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class ProviderBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = Provider.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
|
||||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = Provider.objects.annotate(
|
queryset = Provider.objects.annotate(
|
||||||
@ -141,6 +147,11 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.ProviderAccountBulkEditForm
|
form = forms.ProviderAccountBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class ProviderAccountBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = ProviderAccount.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
|
||||||
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
|
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = ProviderAccount.objects.annotate(
|
queryset = ProviderAccount.objects.annotate(
|
||||||
@ -212,6 +223,11 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.ProviderNetworkBulkEditForm
|
form = forms.ProviderNetworkBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = ProviderNetwork.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
|
||||||
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
|
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = ProviderNetwork.objects.all()
|
queryset = ProviderNetwork.objects.all()
|
||||||
@ -271,6 +287,11 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.CircuitTypeBulkEditForm
|
form = forms.CircuitTypeBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class CircuitTypeBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = CircuitType.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
|
||||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = CircuitType.objects.annotate(
|
queryset = CircuitType.objects.annotate(
|
||||||
@ -337,6 +358,12 @@ class CircuitBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.CircuitBulkEditForm
|
form = forms.CircuitBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class CircuitBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = Circuit.objects.all()
|
||||||
|
field_name = 'cid'
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
|
||||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = Circuit.objects.prefetch_related(
|
queryset = Circuit.objects.prefetch_related(
|
||||||
@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.CircuitTerminationFilterSet
|
filterset = filtersets.CircuitTerminationFilterSet
|
||||||
filterset_form = forms.CircuitTerminationFilterForm
|
filterset_form = forms.CircuitTerminationFilterForm
|
||||||
table = tables.CircuitTerminationTable
|
table = tables.CircuitTerminationTable
|
||||||
|
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CircuitTermination)
|
@register_model_view(CircuitTermination)
|
||||||
@ -526,6 +554,11 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.CircuitGroupBulkEditForm
|
form = forms.CircuitGroupBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class CircuitGroupBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = CircuitGroup.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
|
||||||
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
|
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = CircuitGroup.objects.all()
|
queryset = CircuitGroup.objects.all()
|
||||||
@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.CircuitGroupAssignmentFilterSet
|
filterset = filtersets.CircuitGroupAssignmentFilterSet
|
||||||
filterset_form = forms.CircuitGroupAssignmentFilterForm
|
filterset_form = forms.CircuitGroupAssignmentFilterForm
|
||||||
table = tables.CircuitGroupAssignmentTable
|
table = tables.CircuitGroupAssignmentTable
|
||||||
|
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CircuitGroupAssignment)
|
@register_model_view(CircuitGroupAssignment)
|
||||||
@ -635,6 +669,11 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.VirtualCircuitTypeBulkEditForm
|
form = forms.VirtualCircuitTypeBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = VirtualCircuitType.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
|
||||||
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = VirtualCircuitType.objects.annotate(
|
queryset = VirtualCircuitType.objects.annotate(
|
||||||
@ -648,6 +687,7 @@ class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
|||||||
# Virtual circuits
|
# Virtual circuits
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@register_model_view(VirtualCircuit, 'list', path='', detail=False)
|
||||||
class VirtualCircuitListView(generic.ObjectListView):
|
class VirtualCircuitListView(generic.ObjectListView):
|
||||||
queryset = VirtualCircuit.objects.annotate(
|
queryset = VirtualCircuit.objects.annotate(
|
||||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||||
@ -662,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView):
|
|||||||
queryset = VirtualCircuit.objects.all()
|
queryset = VirtualCircuit.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(VirtualCircuit, 'add', detail=False)
|
||||||
@register_model_view(VirtualCircuit, 'edit')
|
@register_model_view(VirtualCircuit, 'edit')
|
||||||
class VirtualCircuitEditView(generic.ObjectEditView):
|
class VirtualCircuitEditView(generic.ObjectEditView):
|
||||||
queryset = VirtualCircuit.objects.all()
|
queryset = VirtualCircuit.objects.all()
|
||||||
@ -673,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView):
|
|||||||
queryset = VirtualCircuit.objects.all()
|
queryset = VirtualCircuit.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False)
|
||||||
class VirtualCircuitBulkImportView(generic.BulkImportView):
|
class VirtualCircuitBulkImportView(generic.BulkImportView):
|
||||||
queryset = VirtualCircuit.objects.all()
|
queryset = VirtualCircuit.objects.all()
|
||||||
model_form = forms.VirtualCircuitImportForm
|
model_form = forms.VirtualCircuitImportForm
|
||||||
@ -688,6 +730,7 @@ class VirtualCircuitBulkImportView(generic.BulkImportView):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False)
|
||||||
class VirtualCircuitBulkEditView(generic.BulkEditView):
|
class VirtualCircuitBulkEditView(generic.BulkEditView):
|
||||||
queryset = VirtualCircuit.objects.annotate(
|
queryset = VirtualCircuit.objects.annotate(
|
||||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||||
@ -697,6 +740,13 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.VirtualCircuitBulkEditForm
|
form = forms.VirtualCircuitBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
|
||||||
|
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):
|
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = VirtualCircuit.objects.annotate(
|
queryset = VirtualCircuit.objects.annotate(
|
||||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||||
@ -714,6 +764,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.VirtualCircuitTerminationFilterSet
|
filterset = filtersets.VirtualCircuitTerminationFilterSet
|
||||||
filterset_form = forms.VirtualCircuitTerminationFilterForm
|
filterset_form = forms.VirtualCircuitTerminationFilterForm
|
||||||
table = tables.VirtualCircuitTerminationTable
|
table = tables.VirtualCircuitTerminationTable
|
||||||
|
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(VirtualCircuitTermination)
|
@register_model_view(VirtualCircuitTermination)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from .serializers_.change_logging import *
|
from .serializers_.change_logging import *
|
||||||
from .serializers_.data import *
|
from .serializers_.data import *
|
||||||
from .serializers_.jobs import *
|
from .serializers_.jobs import *
|
||||||
|
from .serializers_.object_types import *
|
||||||
from .serializers_.tasks import *
|
from .serializers_.tasks import *
|
||||||
|
@ -44,7 +44,8 @@ class ObjectChangeSerializer(BaseModelSerializer):
|
|||||||
model = ObjectChange
|
model = ObjectChange
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
|
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
|
||||||
'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
|
'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data',
|
||||||
|
'postchange_data',
|
||||||
]
|
]
|
||||||
|
|
||||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||||
|
@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
|
|||||||
model = Job
|
model = Job
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
|
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
|
||||||
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id',
|
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
|
||||||
]
|
]
|
||||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||||
|
47
netbox/core/api/serializers_/object_types.py
Normal file
47
netbox/core/api/serializers_/object_types.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import inspect
|
||||||
|
|
||||||
|
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_action_url
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ObjectTypeSerializer',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeSerializer(BaseModelSerializer):
|
||||||
|
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)
|
||||||
|
is_plugin_model = serializers.BooleanField(read_only=True)
|
||||||
|
rest_api_endpoint = serializers.SerializerMethodField()
|
||||||
|
description = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ObjectType
|
||||||
|
fields = [
|
||||||
|
'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
|
||||||
|
try:
|
||||||
|
return get_action_url(model, action='list', rest_api=True)
|
||||||
|
except NoReverseMatch:
|
||||||
|
return
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
|
def get_description(self, obj):
|
||||||
|
if not (model := obj.model_class()):
|
||||||
|
return
|
||||||
|
return inspect.getdoc(model)
|
@ -9,7 +9,8 @@ router.APIRootView = views.CoreRootView
|
|||||||
router.register('data-sources', views.DataSourceViewSet)
|
router.register('data-sources', views.DataSourceViewSet)
|
||||||
router.register('data-files', views.DataFileViewSet)
|
router.register('data-files', views.DataFileViewSet)
|
||||||
router.register('jobs', views.JobViewSet)
|
router.register('jobs', views.JobViewSet)
|
||||||
router.register('object-changes', views.ObjectChangeViewSet)
|
router.register('object-changes', views.ObjectChangeViewSet, basename='objectchange')
|
||||||
|
router.register('object-types', views.ObjectTypeViewSet)
|
||||||
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
|
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
|
||||||
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
|
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
|
||||||
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')
|
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')
|
||||||
|
@ -20,6 +20,7 @@ from core import filtersets
|
|||||||
from core.jobs import SyncDataSourceJob
|
from core.jobs import SyncDataSourceJob
|
||||||
from core.models import *
|
from core.models import *
|
||||||
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
|
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
|
||||||
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.pagination import LimitOffsetListPagination
|
from netbox.api.pagination import LimitOffsetListPagination
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
||||||
@ -77,10 +78,22 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|||||||
Retrieve a list of recent changes.
|
Retrieve a list of recent changes.
|
||||||
"""
|
"""
|
||||||
metadata_class = ContentTypeMetadata
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ObjectChange.objects.valid_models()
|
|
||||||
serializer_class = serializers.ObjectChangeSerializer
|
serializer_class = serializers.ObjectChangeSerializer
|
||||||
filterset_class = filtersets.ObjectChangeFilterSet
|
filterset_class = filtersets.ObjectChangeFilterSet
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return ObjectChange.objects.valid_models()
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
Read-only list of ObjectTypes.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||||
|
queryset = ObjectType.objects.order_by('app_label', 'model')
|
||||||
|
serializer_class = serializers.ObjectTypeSerializer
|
||||||
|
filterset_class = filtersets.ObjectTypeFilterSet
|
||||||
|
|
||||||
|
|
||||||
class BaseRQViewSet(viewsets.ViewSet):
|
class BaseRQViewSet(viewsets.ViewSet):
|
||||||
"""
|
"""
|
||||||
|
@ -4,23 +4,31 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from rq.job import JobStatus
|
from rq.job import JobStatus
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'JOB_LOG_ENTRY_LEVELS',
|
||||||
'RQ_TASK_STATUSES',
|
'RQ_TASK_STATUSES',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Status:
|
class Badge:
|
||||||
label: str
|
label: str
|
||||||
color: str
|
color: str
|
||||||
|
|
||||||
|
|
||||||
RQ_TASK_STATUSES = {
|
RQ_TASK_STATUSES = {
|
||||||
JobStatus.QUEUED: Status(_('Queued'), 'cyan'),
|
JobStatus.QUEUED: Badge(_('Queued'), 'cyan'),
|
||||||
JobStatus.FINISHED: Status(_('Finished'), 'green'),
|
JobStatus.FINISHED: Badge(_('Finished'), 'green'),
|
||||||
JobStatus.FAILED: Status(_('Failed'), 'red'),
|
JobStatus.FAILED: Badge(_('Failed'), 'red'),
|
||||||
JobStatus.STARTED: Status(_('Started'), 'blue'),
|
JobStatus.STARTED: Badge(_('Started'), 'blue'),
|
||||||
JobStatus.DEFERRED: Status(_('Deferred'), 'gray'),
|
JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'),
|
||||||
JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'),
|
JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'),
|
||||||
JobStatus.STOPPED: Status(_('Stopped'), 'orange'),
|
JobStatus.STOPPED: Badge(_('Stopped'), 'orange'),
|
||||||
JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'),
|
JobStatus.CANCELED: Badge(_('Cancelled'), 'yellow'),
|
||||||
|
}
|
||||||
|
|
||||||
|
JOB_LOG_ENTRY_LEVELS = {
|
||||||
|
'debug': Badge(_('Debug'), 'gray'),
|
||||||
|
'info': Badge(_('Info'), 'blue'),
|
||||||
|
'warning': Badge(_('Warning'), 'orange'),
|
||||||
|
'error': Badge(_('Error'), 'red'),
|
||||||
}
|
}
|
||||||
|
21
netbox/core/dataclasses.py
Normal file
21
netbox/core/dataclasses.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'JobLogEntry',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JobLogEntry:
|
||||||
|
level: str
|
||||||
|
message: str
|
||||||
|
timestamp: datetime = field(default_factory=timezone.now)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_logrecord(cls, record: logging.LogRecord):
|
||||||
|
return cls(record.levelname.lower(), record.msg)
|
@ -1,9 +1,8 @@
|
|||||||
|
import django_filters
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
import django_filters
|
|
||||||
|
|
||||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
from netbox.utils import get_data_backend_choices
|
from netbox.utils import get_data_backend_choices
|
||||||
from users.models import User
|
from users.models import User
|
||||||
@ -17,6 +16,7 @@ __all__ = (
|
|||||||
'DataSourceFilterSet',
|
'DataSourceFilterSet',
|
||||||
'JobFilterSet',
|
'JobFilterSet',
|
||||||
'ObjectChangeFilterSet',
|
'ObjectChangeFilterSet',
|
||||||
|
'ObjectTypeFilterSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -134,6 +134,31 @@ class JobFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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', 'public')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(app_label__icontains=value) |
|
||||||
|
Q(model__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_features(self, queryset, name, value):
|
||||||
|
return queryset.filter(features__icontains=value)
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeFilterSet(BaseFilterSet):
|
class ObjectChangeFilterSet(BaseFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
@ -167,7 +192,8 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(user_name__icontains=value) |
|
Q(user_name__icontains=value) |
|
||||||
Q(object_repr__icontains=value)
|
Q(object_repr__icontains=value) |
|
||||||
|
Q(message__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from core.models import ObjectChange
|
from core.models import ObjectChange
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from core.graphql.types import DataFileType, DataSourceType
|
||||||
from netbox.core.graphql.types import ObjectChangeType
|
from netbox.core.graphql.types import ObjectChangeType
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ChangelogMixin',
|
'ChangelogMixin',
|
||||||
|
'SyncedDataMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -25,3 +27,9 @@ class ChangelogMixin:
|
|||||||
changed_object_id=self.pk
|
changed_object_id=self.pk
|
||||||
)
|
)
|
||||||
return object_changes.restrict(info.context.request.user, 'view')
|
return object_changes.restrict(info.context.request.user, 'view')
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry.type
|
||||||
|
class SyncedDataMixin:
|
||||||
|
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
|
||||||
|
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
|
||||||
|
@ -1,17 +1,21 @@
|
|||||||
import logging
|
|
||||||
import requests
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import timedelta
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
from core.models import Job, ObjectChange
|
||||||
|
from netbox.config import Config
|
||||||
from netbox.jobs import JobRunner, system_job
|
from netbox.jobs import JobRunner, system_job
|
||||||
from netbox.search.backends import search_backend
|
from netbox.search.backends import search_backend
|
||||||
from utilities.proxy import resolve_proxies
|
from utilities.proxy import resolve_proxies
|
||||||
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
||||||
from .exceptions import SyncError
|
|
||||||
from .models import DataSource
|
from .models import DataSource
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncDataSourceJob(JobRunner):
|
class SyncDataSourceJob(JobRunner):
|
||||||
"""
|
"""
|
||||||
@ -34,19 +38,23 @@ class SyncDataSourceJob(JobRunner):
|
|||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
datasource = DataSource.objects.get(pk=self.job.object_id)
|
datasource = DataSource.objects.get(pk=self.job.object_id)
|
||||||
|
self.logger.debug(f"Found DataSource ID {datasource.pk}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
self.logger.info(f"Syncing data source {datasource}")
|
||||||
datasource.sync()
|
datasource.sync()
|
||||||
|
|
||||||
# Update the search cache for DataFiles belonging to this source
|
# Update the search cache for DataFiles belonging to this source
|
||||||
|
self.logger.debug("Updating search cache for data files")
|
||||||
search_backend.cache(datasource.datafiles.iterator())
|
search_backend.cache(datasource.datafiles.iterator())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error syncing data source: {e}")
|
||||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||||
if type(e) is SyncError:
|
|
||||||
logging.error(e)
|
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
self.logger.info("Syncing completed successfully")
|
||||||
|
|
||||||
|
|
||||||
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
|
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
|
||||||
class SystemHousekeepingJob(JobRunner):
|
class SystemHousekeepingJob(JobRunner):
|
||||||
@ -58,19 +66,29 @@ class SystemHousekeepingJob(JobRunner):
|
|||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
# Skip if running in development or test mode
|
# Skip if running in development or test mode
|
||||||
if settings.DEBUG or 'test' in sys.argv:
|
if settings.DEBUG:
|
||||||
|
self.logger.warning("Aborting execution: Debug is enabled")
|
||||||
|
return
|
||||||
|
if 'test' in sys.argv:
|
||||||
|
self.logger.warning("Aborting execution: Tests are running")
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
|
|
||||||
self.send_census_report()
|
self.send_census_report()
|
||||||
|
self.clear_expired_sessions()
|
||||||
|
self.prune_changelog()
|
||||||
|
self.delete_expired_jobs()
|
||||||
|
self.check_for_new_releases()
|
||||||
|
|
||||||
@staticmethod
|
def send_census_report(self):
|
||||||
def send_census_report():
|
|
||||||
"""
|
"""
|
||||||
Send a census report (if enabled).
|
Send a census report (if enabled).
|
||||||
"""
|
"""
|
||||||
# Skip if census reporting is disabled
|
self.logger.info("Reporting census data...")
|
||||||
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED:
|
if settings.ISOLATED_DEPLOYMENT:
|
||||||
|
self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||||
|
return
|
||||||
|
if not settings.CENSUS_REPORTING_ENABLED:
|
||||||
|
self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
census_data = {
|
census_data = {
|
||||||
@ -87,3 +105,92 @@ class SystemHousekeepingJob(JobRunner):
|
|||||||
)
|
)
|
||||||
except requests.exceptions.RequestException:
|
except requests.exceptions.RequestException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def clear_expired_sessions(self):
|
||||||
|
"""
|
||||||
|
Clear any expired sessions from the database.
|
||||||
|
"""
|
||||||
|
self.logger.info("Clearing expired sessions...")
|
||||||
|
engine = import_module(settings.SESSION_ENGINE)
|
||||||
|
try:
|
||||||
|
engine.SessionStore.clear_expired()
|
||||||
|
self.logger.info("Sessions cleared.")
|
||||||
|
except NotImplementedError:
|
||||||
|
self.logger.warning(
|
||||||
|
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||||
|
f"clearing sessions; skipping."
|
||||||
|
)
|
||||||
|
|
||||||
|
def prune_changelog(self):
|
||||||
|
"""
|
||||||
|
Delete any ObjectChange records older than the configured changelog retention time (if any).
|
||||||
|
"""
|
||||||
|
self.logger.info("Pruning old changelog entries...")
|
||||||
|
config = Config()
|
||||||
|
if not config.CHANGELOG_RETENTION:
|
||||||
|
self.logger.info("No retention period specified; skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
|
||||||
|
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]
|
||||||
|
self.logger.info(f"Deleted {count} expired changelog records")
|
||||||
|
|
||||||
|
def delete_expired_jobs(self):
|
||||||
|
"""
|
||||||
|
Delete any jobs older than the configured retention period (if any).
|
||||||
|
"""
|
||||||
|
self.logger.info("Deleting expired jobs...")
|
||||||
|
config = Config()
|
||||||
|
if not config.JOB_RETENTION:
|
||||||
|
self.logger.info("No retention period specified; skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
|
||||||
|
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]
|
||||||
|
self.logger.info(f"Deleted {count} expired jobs")
|
||||||
|
|
||||||
|
def check_for_new_releases(self):
|
||||||
|
"""
|
||||||
|
Check for new releases and cache the latest release.
|
||||||
|
"""
|
||||||
|
self.logger.info("Checking for new releases...")
|
||||||
|
if settings.ISOLATED_DEPLOYMENT:
|
||||||
|
self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||||
|
return
|
||||||
|
if not settings.RELEASE_CHECK_URL:
|
||||||
|
self.logger.info("RELEASE_CHECK_URL is not set; skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch the latest releases
|
||||||
|
self.logger.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url=settings.RELEASE_CHECK_URL,
|
||||||
|
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
self.logger.error(f"Error fetching release: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine the most recent stable release
|
||||||
|
releases = []
|
||||||
|
for release in response.json():
|
||||||
|
if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
|
||||||
|
continue
|
||||||
|
releases.append((version.parse(release['tag_name']), release.get('html_url')))
|
||||||
|
latest_release = max(releases)
|
||||||
|
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)
|
||||||
|
@ -1,29 +1,48 @@
|
|||||||
import code
|
import code
|
||||||
import platform
|
import platform
|
||||||
import sys
|
from collections import defaultdict
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from colorama import Fore, Style
|
||||||
from django import get_version
|
from django import get_version
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
from core.models import ObjectType
|
from netbox.constants import CORE_APPS
|
||||||
from users.models import User
|
from netbox.plugins.utils import get_installed_plugins
|
||||||
|
|
||||||
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
|
|
||||||
EXCLUDE_MODELS = (
|
|
||||||
'extras.branch',
|
|
||||||
'extras.stagedchange',
|
|
||||||
)
|
|
||||||
|
|
||||||
BANNER_TEXT = """### NetBox interactive shell ({node})
|
def color(color: str, text: str):
|
||||||
### Python {python} | Django {django} | NetBox {netbox}
|
return getattr(Fore, color.upper()) + text + Style.RESET_ALL
|
||||||
### lsmodels() will show available models. Use help(<model>) for more info.""".format(
|
|
||||||
node=platform.node(),
|
|
||||||
python=platform.python_version(),
|
def bright(text: str):
|
||||||
django=get_version(),
|
return Style.BRIGHT + text + Style.RESET_ALL
|
||||||
netbox=settings.RELEASE.name
|
|
||||||
)
|
|
||||||
|
def get_models(app_config):
|
||||||
|
"""
|
||||||
|
Return a list of all non-private models within an app.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
model for model in app_config.get_models()
|
||||||
|
if not getattr(model, '_netbox_private', False)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_constants(app_config):
|
||||||
|
"""
|
||||||
|
Return a dictionary mapping of all constants defined within an app.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
constants = import_string(f'{app_config.name}.constants')
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
name: value for name, value in vars(constants).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -36,47 +55,88 @@ class Command(BaseCommand):
|
|||||||
help='Python code to execute (instead of starting an interactive shell)',
|
help='Python code to execute (instead of starting an interactive shell)',
|
||||||
)
|
)
|
||||||
|
|
||||||
def _lsmodels(self):
|
def _lsapps(self):
|
||||||
for app, models in self.django_models.items():
|
for app_label in self.django_models.keys():
|
||||||
app_name = apps.get_app_config(app).verbose_name
|
app_name = apps.get_app_config(app_label).verbose_name
|
||||||
|
print(f'{app_label} - {app_name}')
|
||||||
|
|
||||||
|
def _lsmodels(self, app_label=None):
|
||||||
|
"""
|
||||||
|
Return a list of all models within each app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_label: The name of a specific app
|
||||||
|
"""
|
||||||
|
if app_label:
|
||||||
|
if app_label not in self.django_models:
|
||||||
|
print(f"No models listed for {app_label}")
|
||||||
|
return
|
||||||
|
app_labels = [app_label]
|
||||||
|
else:
|
||||||
|
app_labels = self.django_models.keys() # All apps
|
||||||
|
|
||||||
|
for app_label in app_labels:
|
||||||
|
app_name = apps.get_app_config(app_label).verbose_name
|
||||||
print(f'{app_name}:')
|
print(f'{app_name}:')
|
||||||
for m in models:
|
for model in self.django_models[app_label]:
|
||||||
print(f' {m}')
|
print(f' {app_label}.{model}')
|
||||||
|
|
||||||
def get_namespace(self):
|
def get_namespace(self):
|
||||||
namespace = {}
|
namespace = defaultdict(SimpleNamespace)
|
||||||
|
|
||||||
# Gather Django models and constants from each app
|
# Iterate through all core apps & plugins to compile namespace of models and constants
|
||||||
for app in APPS:
|
for app_name in [*CORE_APPS, *get_installed_plugins().keys()]:
|
||||||
models = []
|
app_config = apps.get_app_config(app_name)
|
||||||
|
|
||||||
# Load models from each app
|
# Populate models
|
||||||
for model in apps.get_app_config(app).get_models():
|
if models := get_models(app_config):
|
||||||
app_label = model._meta.app_label
|
for model in models:
|
||||||
model_name = model._meta.model_name
|
setattr(namespace[app_name], model.__name__, model)
|
||||||
if f'{app_label}.{model_name}' not in EXCLUDE_MODELS:
|
self.django_models[app_name] = sorted([
|
||||||
namespace[model.__name__] = model
|
model.__name__ for model in models
|
||||||
models.append(model.__name__)
|
])
|
||||||
self.django_models[app] = sorted(models)
|
|
||||||
|
|
||||||
# Constants
|
# Populate constants
|
||||||
try:
|
for const_name, const_value in get_constants(app_config).items():
|
||||||
app_constants = sys.modules[f'{app}.constants']
|
setattr(namespace[app_name], const_name, const_value)
|
||||||
for name in dir(app_constants):
|
|
||||||
namespace[name] = getattr(app_constants, name)
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Additional objects to include
|
return {
|
||||||
namespace['ObjectType'] = ObjectType
|
**namespace,
|
||||||
namespace['User'] = User
|
'lsapps': self._lsapps,
|
||||||
|
|
||||||
# Load convenience commands
|
|
||||||
namespace.update({
|
|
||||||
'lsmodels': self._lsmodels,
|
'lsmodels': self._lsmodels,
|
||||||
})
|
}
|
||||||
|
|
||||||
return namespace
|
@staticmethod
|
||||||
|
def get_banner_text():
|
||||||
|
lines = [
|
||||||
|
'{title} ({hostname})'.format(
|
||||||
|
title=bright('NetBox interactive shell'),
|
||||||
|
hostname=platform.node(),
|
||||||
|
),
|
||||||
|
'{python} | {django} | {netbox}'.format(
|
||||||
|
python=color('green', f'Python v{platform.python_version()}'),
|
||||||
|
django=color('green', f'Django v{get_version()}'),
|
||||||
|
netbox=color('green', settings.RELEASE.name),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if installed_plugins := get_installed_plugins():
|
||||||
|
plugin_list = ', '.join([
|
||||||
|
color('cyan', f'{name} v{version}') for name, version in installed_plugins.items()
|
||||||
|
])
|
||||||
|
lines.append(
|
||||||
|
'Plugins: {plugin_list}'.format(
|
||||||
|
plugin_list=plugin_list
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
'lsapps() & lsmodels() will show available models. Use help(<model>) for more info.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return '\n'.join([
|
||||||
|
f'### {line}' for line in lines
|
||||||
|
])
|
||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
namespace = self.get_namespace()
|
namespace = self.get_namespace()
|
||||||
@ -97,5 +157,4 @@ class Command(BaseCommand):
|
|||||||
readline.parse_and_bind('tab: complete')
|
readline.parse_and_bind('tab: complete')
|
||||||
|
|
||||||
# Run interactive shell
|
# Run interactive shell
|
||||||
shell = code.interact(banner=BANNER_TEXT, local=namespace)
|
return code.interact(banner=self.get_banner_text(), local=namespace)
|
||||||
return shell
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import core.models.contenttypes
|
import core.models.object_types
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
bases=('contenttypes.contenttype',),
|
bases=('contenttypes.contenttype',),
|
||||||
managers=[
|
managers=[
|
||||||
('objects', core.models.contenttypes.ObjectTypeManager()),
|
('objects', core.models.object_types.ObjectTypeManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
28
netbox/core/migrations/0016_job_log_entries.py
Normal file
28
netbox/core/migrations/0016_job_log_entries.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import utilities.json
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0015_remove_redundant_indexes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='job',
|
||||||
|
name='log_entries',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.JSONField(
|
||||||
|
decoder=utilities.json.JobLogDecoder,
|
||||||
|
encoder=django.core.serializers.json.DjangoJSONEncoder
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
size=None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
16
netbox/core/migrations/0017_objectchange_message.py
Normal file
16
netbox/core/migrations/0017_objectchange_message.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0016_job_log_entries'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='objectchange',
|
||||||
|
name='message',
|
||||||
|
field=models.CharField(blank=True, editable=False, max_length=200),
|
||||||
|
),
|
||||||
|
]
|
63
netbox/core/migrations/0018_concrete_objecttype.py
Normal file
63
netbox/core/migrations/0018_concrete_objecttype.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.contrib.postgres.indexes
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('core', '0017_objectchange_message'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Delete the proxy model from the migration state
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='ObjectType',
|
||||||
|
),
|
||||||
|
# Create the new concrete model
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ObjectType',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'contenttype_ptr',
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to='contenttypes.contenttype',
|
||||||
|
related_name='object_type'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'public',
|
||||||
|
models.BooleanField(
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'features',
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(max_length=50),
|
||||||
|
default=list,
|
||||||
|
size=None
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'object type',
|
||||||
|
'verbose_name_plural': 'object types',
|
||||||
|
'ordering': ('app_label', 'model'),
|
||||||
|
'indexes': [
|
||||||
|
django.contrib.postgres.indexes.GinIndex(
|
||||||
|
fields=['features'],
|
||||||
|
name='core_object_feature_aec4de_gin'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bases=('contenttypes.contenttype',),
|
||||||
|
managers=[],
|
||||||
|
),
|
||||||
|
]
|
@ -1,4 +1,4 @@
|
|||||||
from .contenttypes import *
|
from .object_types import *
|
||||||
from .change_logging import *
|
from .change_logging import *
|
||||||
from .config import *
|
from .config import *
|
||||||
from .data import *
|
from .data import *
|
||||||
|
@ -11,8 +11,8 @@ from mptt.models import MPTTModel
|
|||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
from core.querysets import ObjectChangeQuerySet
|
from core.querysets import ObjectChangeQuerySet
|
||||||
from netbox.models.features import ChangeLoggingMixin
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
|
from netbox.models.features import has_feature
|
||||||
from utilities.data import shallow_compare_dict
|
from utilities.data import shallow_compare_dict
|
||||||
from .contenttypes import ObjectType
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ObjectChange',
|
'ObjectChange',
|
||||||
@ -82,6 +82,12 @@ class ObjectChange(models.Model):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
editable=False
|
editable=False
|
||||||
)
|
)
|
||||||
|
message = models.CharField(
|
||||||
|
verbose_name=_('message'),
|
||||||
|
max_length=200,
|
||||||
|
editable=False,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
prechange_data = models.JSONField(
|
prechange_data = models.JSONField(
|
||||||
verbose_name=_('pre-change data'),
|
verbose_name=_('pre-change data'),
|
||||||
editable=False,
|
editable=False,
|
||||||
@ -118,7 +124,7 @@ class ObjectChange(models.Model):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Validate the assigned object type
|
# Validate the assigned object type
|
||||||
if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'):
|
if not has_feature(self.changed_object_type, 'change_logging'):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Change logging is not supported for this object type ({type}).").format(
|
_("Change logging is not supported for this object type ({type}).").format(
|
||||||
type=self.changed_object_type
|
type=self.changed_object_type
|
||||||
|
@ -1,50 +1,3 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType, ContentTypeManager
|
# TODO: Remove this module in NetBox v4.5
|
||||||
from django.db.models import Q
|
# Provided for backward compatibility
|
||||||
|
from .object_types import *
|
||||||
from netbox.registry import registry
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
'ObjectType',
|
|
||||||
'ObjectTypeManager',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectTypeManager(ContentTypeManager):
|
|
||||||
|
|
||||||
def public(self):
|
|
||||||
"""
|
|
||||||
Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed
|
|
||||||
in registry['models'] and intended for reference by other objects.
|
|
||||||
"""
|
|
||||||
q = Q()
|
|
||||||
for app_label, models in registry['models'].items():
|
|
||||||
q |= Q(app_label=app_label, model__in=models)
|
|
||||||
return self.get_queryset().filter(q)
|
|
||||||
|
|
||||||
def with_feature(self, feature):
|
|
||||||
"""
|
|
||||||
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
|
|
||||||
we can find all ContentTypes for models which support webhooks with
|
|
||||||
|
|
||||||
ContentType.objects.with_feature('event_rules')
|
|
||||||
"""
|
|
||||||
if feature not in registry['model_features']:
|
|
||||||
raise KeyError(
|
|
||||||
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
q = Q()
|
|
||||||
for app_label, models in registry['model_features'][feature].items():
|
|
||||||
q |= Q(app_label=app_label, model__in=models)
|
|
||||||
|
|
||||||
return self.get_queryset().filter(q)
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectType(ContentType):
|
|
||||||
"""
|
|
||||||
Wrap Django's native ContentType model to use our custom manager.
|
|
||||||
"""
|
|
||||||
objects = ObjectTypeManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
proxy = True
|
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import asdict
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
import django_rq
|
import django_rq
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
@ -14,8 +17,13 @@ from django.utils.translation import gettext as _
|
|||||||
from rq.exceptions import InvalidJobOperation
|
from rq.exceptions import InvalidJobOperation
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
|
from core.dataclasses import JobLogEntry
|
||||||
|
from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from core.signals import job_end, job_start
|
from core.signals import job_end, job_start
|
||||||
|
from extras.models import Notification
|
||||||
|
from netbox.models.features import has_feature
|
||||||
|
from utilities.json import JobLogDecoder
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.rqworker import get_queue_for_model
|
from utilities.rqworker import get_queue_for_model
|
||||||
|
|
||||||
@ -104,6 +112,15 @@ class Job(models.Model):
|
|||||||
verbose_name=_('job ID'),
|
verbose_name=_('job ID'),
|
||||||
unique=True
|
unique=True
|
||||||
)
|
)
|
||||||
|
log_entries = ArrayField(
|
||||||
|
verbose_name=_('log entries'),
|
||||||
|
base_field=models.JSONField(
|
||||||
|
encoder=DjangoJSONEncoder,
|
||||||
|
decoder=JobLogDecoder,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
@ -116,7 +133,7 @@ class Job(models.Model):
|
|||||||
verbose_name_plural = _('jobs')
|
verbose_name_plural = _('jobs')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.job_id)
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
# TODO: Employ dynamic registration
|
# TODO: Employ dynamic registration
|
||||||
@ -130,11 +147,18 @@ class Job(models.Model):
|
|||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return JobStatusChoices.colors.get(self.status)
|
return JobStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
|
def get_event_type(self):
|
||||||
|
return {
|
||||||
|
JobStatusChoices.STATUS_COMPLETED: JOB_COMPLETED,
|
||||||
|
JobStatusChoices.STATUS_FAILED: JOB_FAILED,
|
||||||
|
JobStatusChoices.STATUS_ERRORED: JOB_ERRORED,
|
||||||
|
}.get(self.status)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Validate the assigned object type
|
# Validate the assigned object type
|
||||||
if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'):
|
if self.object_type and not has_feature(self.object_type, 'jobs'):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||||
)
|
)
|
||||||
@ -201,9 +225,24 @@ class Job(models.Model):
|
|||||||
self.completed = timezone.now()
|
self.completed = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
# Notify the user (if any) of completion
|
||||||
|
if self.user:
|
||||||
|
Notification(
|
||||||
|
user=self.user,
|
||||||
|
object=self,
|
||||||
|
event_type=self.get_event_type(),
|
||||||
|
).save()
|
||||||
|
|
||||||
# Send signal
|
# Send signal
|
||||||
job_end.send(self)
|
job_end.send(self)
|
||||||
|
|
||||||
|
def log(self, record: logging.LogRecord):
|
||||||
|
"""
|
||||||
|
Record a LogRecord from Python's native logging in the job's log.
|
||||||
|
"""
|
||||||
|
entry = JobLogEntry.from_logrecord(record)
|
||||||
|
self.log_entries.append(asdict(entry))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enqueue(
|
def enqueue(
|
||||||
cls,
|
cls,
|
||||||
|
211
netbox/core/models/object_types.py
Normal file
211
netbox/core/models/object_types.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import inspect
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from netbox.plugins import PluginConfig
|
||||||
|
from netbox.registry import registry
|
||||||
|
from utilities.string import title
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ObjectType',
|
||||||
|
'ObjectTypeManager',
|
||||||
|
'ObjectTypeQuerySet',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeQuerySet(models.QuerySet):
|
||||||
|
|
||||||
|
def create(self, **kwargs):
|
||||||
|
# If attempting to create a new ObjectType for a given app_label & model, replace those kwargs
|
||||||
|
# with a reference to the ContentType (if one exists).
|
||||||
|
if (app_label := kwargs.get('app_label')) and (model := kwargs.get('model')):
|
||||||
|
try:
|
||||||
|
kwargs['contenttype_ptr'] = ContentType.objects.get(app_label=app_label, model=model)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
pass
|
||||||
|
return super().create(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeManager(models.Manager):
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return ObjectTypeQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
|
def get_by_natural_key(self, app_label, model):
|
||||||
|
"""
|
||||||
|
Retrieve an ObjectType by its application label & model name.
|
||||||
|
|
||||||
|
This method exists to provide parity with ContentTypeManager.
|
||||||
|
"""
|
||||||
|
return self.get(app_label=app_label, model=model)
|
||||||
|
|
||||||
|
# TODO: Remove in NetBox v4.5
|
||||||
|
def get_for_id(self, id):
|
||||||
|
"""
|
||||||
|
Retrieve an ObjectType by its primary key (numeric ID).
|
||||||
|
|
||||||
|
This method exists to provide parity with ContentTypeManager.
|
||||||
|
"""
|
||||||
|
return self.get(pk=id)
|
||||||
|
|
||||||
|
def _get_opts(self, model, for_concrete_model):
|
||||||
|
if for_concrete_model:
|
||||||
|
model = model._meta.concrete_model
|
||||||
|
return model._meta
|
||||||
|
|
||||||
|
def get_for_model(self, model, for_concrete_model=True):
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
# Use .get() instead of .get_or_create() initially to ensure db_for_read is honored (Django bug #20401).
|
||||||
|
ot = self.get(app_label=opts.app_label, model=opts.model_name)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
# If the ObjectType doesn't exist, create it. (Use .get_or_create() to avoid race conditions.)
|
||||||
|
ot = self.get_or_create(
|
||||||
|
app_label=opts.app_label,
|
||||||
|
model=opts.model_name,
|
||||||
|
public=model_is_public(model),
|
||||||
|
features=get_model_features(model),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
return ot
|
||||||
|
|
||||||
|
def get_for_models(self, *models, for_concrete_models=True):
|
||||||
|
"""
|
||||||
|
Retrieve or create the ObjectTypes for multiple models, returning a mapping {model: ObjectType}.
|
||||||
|
|
||||||
|
This method exists to provide parity with ContentTypeManager.
|
||||||
|
"""
|
||||||
|
from netbox.models.features import get_model_features, model_is_public
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# Compile the model and options mappings
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Fetch existing ObjectType from the database
|
||||||
|
condition = Q(
|
||||||
|
*(
|
||||||
|
Q(('app_label', app_label), ('model__in', model_names))
|
||||||
|
for app_label, model_names in needed_models.items()
|
||||||
|
),
|
||||||
|
_connector=Q.OR,
|
||||||
|
)
|
||||||
|
for ot in self.filter(condition):
|
||||||
|
opts_models = needed_opts.pop((ot.app_label, ot.model), [])
|
||||||
|
for model in opts_models:
|
||||||
|
results[model] = ot
|
||||||
|
|
||||||
|
# Create any missing ObjectTypes
|
||||||
|
for (app_label, model_name), opts_models in needed_opts.items():
|
||||||
|
for model in opts_models:
|
||||||
|
results[model] = self.create(
|
||||||
|
app_label=app_label,
|
||||||
|
model=model_name,
|
||||||
|
public=model_is_public(model),
|
||||||
|
features=get_model_features(model),
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def public(self):
|
||||||
|
"""
|
||||||
|
Includes only ObjectTypes for "public" models.
|
||||||
|
|
||||||
|
Filter the base queryset to return only ObjectTypes corresponding to public models; those which are intended
|
||||||
|
for reference by other objects within the application.
|
||||||
|
"""
|
||||||
|
return self.get_queryset().filter(public=True)
|
||||||
|
|
||||||
|
def with_feature(self, feature):
|
||||||
|
"""
|
||||||
|
Return ObjectTypes only for models which support the given feature.
|
||||||
|
|
||||||
|
Only ObjectTypes which list the specified feature will be included. Supported features are declared in 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')
|
||||||
|
"""
|
||||||
|
if feature not in registry['model_features']:
|
||||||
|
raise KeyError(
|
||||||
|
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
|
||||||
|
)
|
||||||
|
return self.get_queryset().filter(features__contains=[feature])
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectType(ContentType):
|
||||||
|
"""
|
||||||
|
Wrap Django's native ContentType model to use our custom manager.
|
||||||
|
"""
|
||||||
|
contenttype_ptr = models.OneToOneField(
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
to='contenttypes.ContentType',
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
related_name='object_type',
|
||||||
|
)
|
||||||
|
public = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
features = ArrayField(
|
||||||
|
base_field=models.CharField(max_length=50),
|
||||||
|
default=list,
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = ObjectTypeManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('object type')
|
||||||
|
verbose_name_plural = _('object types')
|
||||||
|
ordering = ('app_label', 'model')
|
||||||
|
indexes = [
|
||||||
|
GinIndex(fields=['features']),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_labeled_name(self):
|
||||||
|
# Override ContentType's "app | model" representation style.
|
||||||
|
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_verbose_name(self):
|
||||||
|
if model := self.model_class():
|
||||||
|
return model._meta.app_config.verbose_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_verbose_name(self):
|
||||||
|
if model := self.model_class():
|
||||||
|
return model._meta.verbose_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_verbose_name_plural(self):
|
||||||
|
if model := self.model_class():
|
||||||
|
return model._meta.verbose_name_plural
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_plugin_model(self):
|
||||||
|
if not (model := self.model_class()):
|
||||||
|
return # Return null if model class is invalid
|
||||||
|
return isinstance(model._meta.app_config, PluginConfig)
|
18
netbox/core/object_actions.py
Normal file
18
netbox/core/object_actions.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from netbox.object_actions import ObjectAction
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'BulkSync',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkSync(ObjectAction):
|
||||||
|
"""
|
||||||
|
Synchronize multiple objects at once.
|
||||||
|
"""
|
||||||
|
name = 'bulk_sync'
|
||||||
|
label = _('Sync Data')
|
||||||
|
multi = True
|
||||||
|
permissions_required = {'sync'}
|
||||||
|
template_name = 'core/buttons/bulk_sync.html'
|
@ -2,9 +2,9 @@ import logging
|
|||||||
from threading import local
|
from threading import local
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
|
||||||
from django.dispatch import receiver, Signal
|
from django.dispatch import receiver, Signal
|
||||||
from django.core.signals import request_finished
|
from django.core.signals import request_finished
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -12,12 +12,13 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
|
|||||||
|
|
||||||
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||||
from core.events import *
|
from core.events import *
|
||||||
|
from core.models import ObjectType
|
||||||
from extras.events import enqueue_event
|
from extras.events import enqueue_event
|
||||||
from extras.models import Tag
|
from extras.models import Tag
|
||||||
from extras.utils import run_validators
|
from extras.utils import run_validators
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.context import current_request, events_queue
|
from netbox.context import current_request, events_queue
|
||||||
from netbox.models.features import ChangeLoggingMixin
|
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from .models import ConfigRevision, DataSource, ObjectChange
|
from .models import ConfigRevision, DataSource, ObjectChange
|
||||||
|
|
||||||
@ -41,6 +42,37 @@ post_sync = Signal()
|
|||||||
clear_events = Signal()
|
clear_events = Signal()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Object types
|
||||||
|
#
|
||||||
|
|
||||||
|
@receiver(post_migrate)
|
||||||
|
def update_object_types(sender, **kwargs):
|
||||||
|
"""
|
||||||
|
Create or update the corresponding ObjectType for each model within the migrated app.
|
||||||
|
"""
|
||||||
|
for model in sender.get_models():
|
||||||
|
app_label, model_name = model._meta.label_lower.split('.')
|
||||||
|
|
||||||
|
# Determine whether model is public and its supported features
|
||||||
|
is_public = model_is_public(model)
|
||||||
|
features = get_model_features(model)
|
||||||
|
|
||||||
|
# Create/update the ObjectType for the model
|
||||||
|
try:
|
||||||
|
ot = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name)
|
||||||
|
ot.public = is_public
|
||||||
|
ot.features = features
|
||||||
|
ot.save()
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
ObjectType.objects.create(
|
||||||
|
app_label=app_label,
|
||||||
|
model=model_name,
|
||||||
|
public=is_public,
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Change logging & event handling
|
# Change logging & event handling
|
||||||
#
|
#
|
||||||
@ -116,7 +148,7 @@ def handle_changed_object(sender, instance, **kwargs):
|
|||||||
|
|
||||||
# Enqueue the object for event processing
|
# Enqueue the object for event processing
|
||||||
queue = events_queue.get()
|
queue = events_queue.get()
|
||||||
enqueue_event(queue, instance, request.user, request.id, event_type)
|
enqueue_event(queue, instance, request, event_type)
|
||||||
events_queue.set(queue)
|
events_queue.set(queue)
|
||||||
|
|
||||||
# Increment metric counters
|
# Increment metric counters
|
||||||
@ -200,7 +232,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||||||
|
|
||||||
# Enqueue the object for event processing
|
# Enqueue the object for event processing
|
||||||
queue = events_queue.get()
|
queue = events_queue.get()
|
||||||
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
|
enqueue_event(queue, instance, request, OBJECT_DELETED)
|
||||||
events_queue.set(queue)
|
events_queue.set(queue)
|
||||||
|
|
||||||
# Increment metric counters
|
# Increment metric counters
|
||||||
|
@ -41,6 +41,9 @@ class ObjectChangeTable(NetBoxTable):
|
|||||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
template_code=OBJECTCHANGE_REQUEST_ID,
|
||||||
verbose_name=_('Request ID')
|
verbose_name=_('Request ID')
|
||||||
)
|
)
|
||||||
|
message = tables.Column(
|
||||||
|
verbose_name=_('Message'),
|
||||||
|
)
|
||||||
actions = columns.ActionsColumn(
|
actions = columns.ActionsColumn(
|
||||||
actions=()
|
actions=()
|
||||||
)
|
)
|
||||||
@ -49,5 +52,8 @@ class ObjectChangeTable(NetBoxTable):
|
|||||||
model = ObjectChange
|
model = ObjectChange
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
|
'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
|
||||||
'actions',
|
'message', 'actions',
|
||||||
|
)
|
||||||
|
default_columns = (
|
||||||
|
'pk', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'message', 'actions',
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from core.constants import RQ_TASK_STATUSES
|
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BackendTypeColumn',
|
'BackendTypeColumn',
|
||||||
'RQJobStatusColumn',
|
'BadgeColumn',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -23,14 +22,21 @@ class BackendTypeColumn(tables.Column):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RQJobStatusColumn(tables.Column):
|
class BadgeColumn(tables.Column):
|
||||||
"""
|
"""
|
||||||
Render a colored label for the status of an RQ job.
|
Render a colored badge for a value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
badges: A dictionary mapping of values to core.constants.Badge instances.
|
||||||
"""
|
"""
|
||||||
|
def __init__(self, badges, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.badges = badges
|
||||||
|
|
||||||
def render(self, value):
|
def render(self, value):
|
||||||
status = RQ_TASK_STATUSES.get(value)
|
badge = self.badges.get(value)
|
||||||
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>')
|
return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
|
||||||
|
|
||||||
def value(self, value):
|
def value(self, value):
|
||||||
status = RQ_TASK_STATUSES.get(value)
|
badge = self.badges.get(value)
|
||||||
return status.label
|
return badge.label
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||||
from ..models import Job
|
from core.constants import JOB_LOG_ENTRY_LEVELS
|
||||||
|
from core.models import Job
|
||||||
|
from core.tables.columns import BadgeColumn
|
||||||
|
|
||||||
|
|
||||||
class JobTable(NetBoxTable):
|
class JobTable(NetBoxTable):
|
||||||
@ -40,6 +42,9 @@ class JobTable(NetBoxTable):
|
|||||||
completed = columns.DateTimeColumn(
|
completed = columns.DateTimeColumn(
|
||||||
verbose_name=_('Completed'),
|
verbose_name=_('Completed'),
|
||||||
)
|
)
|
||||||
|
log_entries = tables.Column(
|
||||||
|
verbose_name=_('Log Entries'),
|
||||||
|
)
|
||||||
actions = columns.ActionsColumn(
|
actions = columns.ActionsColumn(
|
||||||
actions=('delete',)
|
actions=('delete',)
|
||||||
)
|
)
|
||||||
@ -53,3 +58,24 @@ class JobTable(NetBoxTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def render_log_entries(self, value):
|
||||||
|
return len(value)
|
||||||
|
|
||||||
|
|
||||||
|
class JobLogEntryTable(BaseTable):
|
||||||
|
timestamp = columns.DateTimeColumn(
|
||||||
|
timespec='milliseconds',
|
||||||
|
verbose_name=_('Time'),
|
||||||
|
)
|
||||||
|
level = BadgeColumn(
|
||||||
|
badges=JOB_LOG_ENTRY_LEVELS,
|
||||||
|
verbose_name=_('Level'),
|
||||||
|
)
|
||||||
|
message = tables.Column(
|
||||||
|
verbose_name=_('Message'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(BaseTable.Meta):
|
||||||
|
empty_text = _('No log entries')
|
||||||
|
fields = ('timestamp', 'level', 'message')
|
||||||
|
@ -2,7 +2,8 @@ import django_tables2 as tables
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_tables2.utils import A
|
from django_tables2.utils import A
|
||||||
|
|
||||||
from core.tables.columns import RQJobStatusColumn
|
from core.constants import RQ_TASK_STATUSES
|
||||||
|
from core.tables.columns import BadgeColumn
|
||||||
from netbox.tables import BaseTable, columns
|
from netbox.tables import BaseTable, columns
|
||||||
|
|
||||||
|
|
||||||
@ -84,7 +85,8 @@ class BackgroundTaskTable(BaseTable):
|
|||||||
ended_at = columns.DateTimeColumn(
|
ended_at = columns.DateTimeColumn(
|
||||||
verbose_name=_("Ended")
|
verbose_name=_("Ended")
|
||||||
)
|
)
|
||||||
status = RQJobStatusColumn(
|
status = BadgeColumn(
|
||||||
|
badges=RQ_TASK_STATUSES,
|
||||||
verbose_name=_("Status"),
|
verbose_name=_("Status"),
|
||||||
accessor='get_status'
|
accessor='get_status'
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||||||
from rq.job import Job as RQ_Job, JobStatus
|
from rq.job import Job as RQ_Job, JobStatus
|
||||||
from rq.registry import FailedJobRegistry, StartedJobRegistry
|
from rq.registry import FailedJobRegistry, StartedJobRegistry
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
from users.models import Token, User
|
from users.models import Token, User
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, TestCase
|
from utilities.testing import APITestCase, APIViewTestCases, TestCase
|
||||||
from utilities.testing.utils import disable_logging
|
from utilities.testing.utils import disable_logging
|
||||||
@ -101,6 +102,22 @@ class DataFileTest(
|
|||||||
DataFile.objects.bulk_create(data_files)
|
DataFile.objects.bulk_create(data_files)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeTest(APITestCase):
|
||||||
|
|
||||||
|
def test_list_objects(self):
|
||||||
|
object_type_count = ObjectType.objects.count()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['count'], object_type_count)
|
||||||
|
|
||||||
|
def test_get_object(self):
|
||||||
|
object_type = ObjectType.objects.first()
|
||||||
|
|
||||||
|
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
|
||||||
|
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTaskTestCase(TestCase):
|
class BackgroundTaskTestCase(TestCase):
|
||||||
user_permissions = ()
|
user_permissions = ()
|
||||||
|
|
||||||
|
@ -150,7 +150,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = ObjectChange.objects.all()
|
queryset = ObjectChange.objects.all()
|
||||||
filterset = ObjectChangeFilterSet
|
filterset = ObjectChangeFilterSet
|
||||||
ignore_fields = ('prechange_data', 'postchange_data')
|
ignore_fields = ('message', 'prechange_data', 'postchange_data')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -241,3 +241,48 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
|
||||||
|
queryset = ObjectType.objects.all()
|
||||||
|
filterset = ObjectTypeFilterSet
|
||||||
|
ignore_fields = (
|
||||||
|
'custom_fields',
|
||||||
|
'custom_links',
|
||||||
|
'event_rules',
|
||||||
|
'export_templates',
|
||||||
|
'object_permissions',
|
||||||
|
'saved_filters',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_q(self):
|
||||||
|
params = {'q': 'vrf'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_app_label(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.filterset({'app_label': ['dcim']}, self.queryset).qs.count(),
|
||||||
|
ObjectType.objects.filter(app_label='dcim').count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_model(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.filterset({'model': ['site']}, self.queryset).qs.count(),
|
||||||
|
ObjectType.objects.filter(model='site').count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.filterset({'public': True}, self.queryset).qs.count(),
|
||||||
|
ObjectType.objects.filter(public=True).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.filterset({'public': False}, self.queryset).qs.count(),
|
||||||
|
ObjectType.objects.filter(public=False).count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_feature(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.filterset({'features': 'tags'}, self.queryset).qs.count(),
|
||||||
|
ObjectType.objects.filter(features__contains=['tags']).count(),
|
||||||
|
)
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from core.models import DataSource
|
from core.models import DataSource, ObjectType
|
||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
|
from dcim.models import Site, Location, Device
|
||||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||||
|
|
||||||
|
|
||||||
@ -120,3 +123,80 @@ class DataSourceChangeLoggingTestCase(TestCase):
|
|||||||
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
|
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
|
||||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
|
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
|
||||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
|
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectTypeTest(TestCase):
|
||||||
|
|
||||||
|
def test_create(self):
|
||||||
|
"""
|
||||||
|
Test that an ObjectType created for a given app_label & model name will be automatically assigned to
|
||||||
|
the appropriate ContentType.
|
||||||
|
"""
|
||||||
|
kwargs = {
|
||||||
|
'app_label': 'foo',
|
||||||
|
'model': 'bar',
|
||||||
|
}
|
||||||
|
ct = ContentType.objects.create(**kwargs)
|
||||||
|
ot = ObjectType.objects.create(**kwargs)
|
||||||
|
self.assertEqual(ot.contenttype_ptr, ct)
|
||||||
|
|
||||||
|
def test_get_by_natural_key(self):
|
||||||
|
"""
|
||||||
|
Test that get_by_natural_key() returns the appropriate ObjectType.
|
||||||
|
"""
|
||||||
|
self.assertEqual(
|
||||||
|
ObjectType.objects.get_by_natural_key('dcim', 'site'),
|
||||||
|
ObjectType.objects.get(app_label='dcim', model='site')
|
||||||
|
)
|
||||||
|
with self.assertRaises(ObjectDoesNotExist):
|
||||||
|
ObjectType.objects.get_by_natural_key('foo', 'bar')
|
||||||
|
|
||||||
|
def test_get_for_id(self):
|
||||||
|
"""
|
||||||
|
Test that get_by_id() returns the appropriate ObjectType.
|
||||||
|
"""
|
||||||
|
ot = ObjectType.objects.get_by_natural_key('dcim', 'site')
|
||||||
|
self.assertEqual(
|
||||||
|
ObjectType.objects.get_for_id(ot.pk),
|
||||||
|
ObjectType.objects.get(pk=ot.pk)
|
||||||
|
)
|
||||||
|
with self.assertRaises(ObjectDoesNotExist):
|
||||||
|
ObjectType.objects.get_for_id(0)
|
||||||
|
|
||||||
|
def test_get_for_model(self):
|
||||||
|
"""
|
||||||
|
Test that get_by_model() returns the appropriate ObjectType.
|
||||||
|
"""
|
||||||
|
self.assertEqual(
|
||||||
|
ObjectType.objects.get_for_model(Site),
|
||||||
|
ObjectType.objects.get_by_natural_key('dcim', 'site')
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_for_models(self):
|
||||||
|
"""
|
||||||
|
Test that get_by_models() returns the appropriate ObjectType mapping.
|
||||||
|
"""
|
||||||
|
self.assertEqual(
|
||||||
|
ObjectType.objects.get_for_models(Site, Location, Device),
|
||||||
|
{
|
||||||
|
Site: ObjectType.objects.get_by_natural_key('dcim', 'site'),
|
||||||
|
Location: ObjectType.objects.get_by_natural_key('dcim', 'location'),
|
||||||
|
Device: ObjectType.objects.get_by_natural_key('dcim', 'device'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public(self):
|
||||||
|
"""
|
||||||
|
Test that public() returns only ObjectTypes for public models.
|
||||||
|
"""
|
||||||
|
public_ots = ObjectType.objects.public()
|
||||||
|
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), public_ots)
|
||||||
|
self.assertNotIn(ObjectType.objects.get_by_natural_key('extras', 'taggeditem'), public_ots)
|
||||||
|
|
||||||
|
def test_with_feature(self):
|
||||||
|
"""
|
||||||
|
Test that with_feature() returns only ObjectTypes for models which support the specified feature.
|
||||||
|
"""
|
||||||
|
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
|
||||||
|
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
|
||||||
|
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -366,6 +367,11 @@ class SystemTestCase(TestCase):
|
|||||||
# Test export
|
# Test export
|
||||||
response = self.client.get(f"{reverse('core:system')}?export=true")
|
response = self.client.get(f"{reverse('core:system')}?export=true")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertIn('netbox_release', data)
|
||||||
|
self.assertIn('plugins', data)
|
||||||
|
self.assertIn('config', data)
|
||||||
|
self.assertIn('objects', data)
|
||||||
|
|
||||||
def test_system_view_with_config_revision(self):
|
def test_system_view_with_config_revision(self):
|
||||||
ConfigRevision.objects.create()
|
ConfigRevision.objects.create()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
from django import __version__ as DJANGO_VERSION
|
from django import __version__ as django_version
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
@ -22,21 +22,23 @@ from rq.worker_registration import clean_worker_registry
|
|||||||
|
|
||||||
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
|
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
|
||||||
from netbox.config import get_config, PARAMS
|
from netbox.config import get_config, PARAMS
|
||||||
from netbox.registry import registry
|
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
||||||
|
from netbox.plugins.utils import get_installed_plugins
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from netbox.views.generic.base import BaseObjectView
|
from netbox.views.generic.base import BaseObjectView
|
||||||
from netbox.views.generic.mixins import TableMixin
|
from netbox.views.generic.mixins import TableMixin
|
||||||
|
from utilities.apps import get_installed_apps
|
||||||
from utilities.data import shallow_compare_dict
|
from utilities.data import shallow_compare_dict
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.htmx import htmx_partial
|
from utilities.htmx import htmx_partial
|
||||||
from utilities.json import ConfigJSONEncoder
|
from utilities.json import ConfigJSONEncoder
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
|
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .jobs import SyncDataSourceJob
|
from .jobs import SyncDataSourceJob
|
||||||
from .models import *
|
from .models import *
|
||||||
from .plugins import get_catalog_plugins, get_local_plugins
|
from .plugins import get_catalog_plugins, get_local_plugins
|
||||||
from .tables import CatalogPluginTable, PluginVersionTable
|
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -114,6 +116,11 @@ class DataSourceBulkEditView(generic.BulkEditView):
|
|||||||
form = forms.DataSourceBulkEditForm
|
form = forms.DataSourceBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
|
||||||
|
class DataSourceBulkRenameView(generic.BulkRenameView):
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
|
||||||
class DataSourceBulkDeleteView(generic.BulkDeleteView):
|
class DataSourceBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = DataSource.objects.annotate(
|
queryset = DataSource.objects.annotate(
|
||||||
@ -133,14 +140,13 @@ class DataFileListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.DataFileFilterSet
|
filterset = filtersets.DataFileFilterSet
|
||||||
filterset_form = forms.DataFileFilterForm
|
filterset_form = forms.DataFileFilterForm
|
||||||
table = tables.DataFileTable
|
table = tables.DataFileTable
|
||||||
actions = {
|
actions = (BulkDelete,)
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(DataFile)
|
@register_model_view(DataFile)
|
||||||
class DataFileView(generic.ObjectView):
|
class DataFileView(generic.ObjectView):
|
||||||
queryset = DataFile.objects.all()
|
queryset = DataFile.objects.all()
|
||||||
|
actions = (DeleteObject,)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(DataFile, 'delete')
|
@register_model_view(DataFile, 'delete')
|
||||||
@ -165,15 +171,32 @@ class JobListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.JobFilterSet
|
filterset = filtersets.JobFilterSet
|
||||||
filterset_form = forms.JobFilterForm
|
filterset_form = forms.JobFilterForm
|
||||||
table = tables.JobTable
|
table = tables.JobTable
|
||||||
actions = {
|
actions = (BulkExport, BulkDelete)
|
||||||
'export': {'view'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Job)
|
@register_model_view(Job)
|
||||||
class JobView(generic.ObjectView):
|
class JobView(generic.ObjectView):
|
||||||
queryset = Job.objects.all()
|
queryset = Job.objects.all()
|
||||||
|
actions = (DeleteObject,)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Job, 'log')
|
||||||
|
class JobLogView(generic.ObjectView):
|
||||||
|
queryset = Job.objects.all()
|
||||||
|
actions = (DeleteObject,)
|
||||||
|
template_name = 'core/job/log.html'
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Log'),
|
||||||
|
badge=lambda obj: len(obj.log_entries),
|
||||||
|
weight=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
table = JobLogEntryTable(instance.log_entries)
|
||||||
|
table.configure(request)
|
||||||
|
return {
|
||||||
|
'table': table,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Job, 'delete')
|
@register_model_view(Job, 'delete')
|
||||||
@ -194,19 +217,23 @@ class JobBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
@register_model_view(ObjectChange, 'list', path='', detail=False)
|
@register_model_view(ObjectChange, 'list', path='', detail=False)
|
||||||
class ObjectChangeListView(generic.ObjectListView):
|
class ObjectChangeListView(generic.ObjectListView):
|
||||||
queryset = ObjectChange.objects.valid_models()
|
queryset = None
|
||||||
filterset = filtersets.ObjectChangeFilterSet
|
filterset = filtersets.ObjectChangeFilterSet
|
||||||
filterset_form = forms.ObjectChangeFilterForm
|
filterset_form = forms.ObjectChangeFilterForm
|
||||||
table = tables.ObjectChangeTable
|
table = tables.ObjectChangeTable
|
||||||
template_name = 'core/objectchange_list.html'
|
template_name = 'core/objectchange_list.html'
|
||||||
actions = {
|
actions = (BulkExport,)
|
||||||
'export': {'view'},
|
|
||||||
}
|
def get_queryset(self, request):
|
||||||
|
return ObjectChange.objects.valid_models()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ObjectChange)
|
@register_model_view(ObjectChange)
|
||||||
class ObjectChangeView(generic.ObjectView):
|
class ObjectChangeView(generic.ObjectView):
|
||||||
queryset = ObjectChange.objects.valid_models()
|
queryset = None
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return ObjectChange.objects.valid_models()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
@ -269,6 +296,7 @@ class ConfigRevisionListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConfigRevisionFilterSet
|
filterset = filtersets.ConfigRevisionFilterSet
|
||||||
filterset_form = forms.ConfigRevisionFilterForm
|
filterset_form = forms.ConfigRevisionFilterForm
|
||||||
table = tables.ConfigRevisionTable
|
table = tables.ConfigRevisionTable
|
||||||
|
actions = (AddObject, BulkExport)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConfigRevision)
|
@register_model_view(ConfigRevision)
|
||||||
@ -525,7 +553,7 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
|
||||||
# System stats
|
# System status
|
||||||
psql_version = db_name = db_size = None
|
psql_version = db_name = db_size = None
|
||||||
try:
|
try:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
@ -540,7 +568,7 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
pass
|
pass
|
||||||
stats = {
|
stats = {
|
||||||
'netbox_release': settings.RELEASE,
|
'netbox_release': settings.RELEASE,
|
||||||
'django_version': DJANGO_VERSION,
|
'django_version': django_version,
|
||||||
'python_version': platform.python_version(),
|
'python_version': platform.python_version(),
|
||||||
'postgresql_version': psql_version,
|
'postgresql_version': psql_version,
|
||||||
'database_name': db_name,
|
'database_name': db_name,
|
||||||
@ -548,19 +576,35 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
'rq_worker_count': Worker.count(get_connection('default')),
|
'rq_worker_count': Worker.count(get_connection('default')),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Django apps
|
||||||
|
django_apps = get_installed_apps()
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
|
# Plugins
|
||||||
|
plugins = get_installed_plugins()
|
||||||
|
|
||||||
|
# Object counts
|
||||||
|
objects = {}
|
||||||
|
for ot in ObjectType.objects.public().order_by('app_label', 'model'):
|
||||||
|
if model := ot.model_class():
|
||||||
|
objects[ot] = model.objects.count()
|
||||||
|
|
||||||
# Raw data export
|
# Raw data export
|
||||||
if 'export' in request.GET:
|
if 'export' in request.GET:
|
||||||
stats['netbox_release'] = stats['netbox_release'].asdict()
|
stats['netbox_release'] = stats['netbox_release'].asdict()
|
||||||
params = [param.name for param in PARAMS]
|
params = [param.name for param in PARAMS]
|
||||||
data = {
|
data = {
|
||||||
**stats,
|
**stats,
|
||||||
'plugins': registry['plugins']['installed'],
|
'django_apps': django_apps,
|
||||||
|
'plugins': plugins,
|
||||||
'config': {
|
'config': {
|
||||||
k: getattr(config, k) for k in sorted(params)
|
k: getattr(config, k) for k in sorted(params)
|
||||||
},
|
},
|
||||||
|
'objects': {
|
||||||
|
f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
|
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
|
||||||
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
|
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
|
||||||
@ -573,7 +617,10 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
|
|
||||||
return render(request, 'core/system.html', {
|
return render(request, 'core/system.html', {
|
||||||
'stats': stats,
|
'stats': stats,
|
||||||
|
'django_apps': django_apps,
|
||||||
'config': config,
|
'config': config,
|
||||||
|
'plugins': plugins,
|
||||||
|
'objects': objects,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ from dcim.models import (
|
|||||||
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||||
)
|
)
|
||||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||||
from netbox.api.serializers import ValidatedModelSerializer
|
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
|
||||||
from utilities.api import get_serializer_for_model
|
from utilities.api import get_serializer_for_model
|
||||||
from wireless.choices import *
|
from wireless.choices import *
|
||||||
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
|
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
|
||||||
@ -31,7 +31,11 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
class ComponentTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConsolePortTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -59,7 +63,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
class ConsoleServerPortTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -87,7 +91,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
class PowerPortTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -116,7 +120,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -156,7 +160,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
class InterfaceTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -202,7 +206,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
class RearPortTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
required=False,
|
required=False,
|
||||||
nested=True,
|
nested=True,
|
||||||
@ -226,7 +230,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
class FrontPortTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -251,7 +255,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ModuleBayTemplateSerializer(ValidatedModelSerializer):
|
class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False,
|
required=False,
|
||||||
@ -274,7 +278,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True
|
nested=True
|
||||||
)
|
)
|
||||||
@ -288,7 +292,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
|
||||||
device_type = DeviceTypeSerializer(
|
device_type = DeviceTypeSerializer(
|
||||||
nested=True
|
nested=True
|
||||||
)
|
)
|
||||||
|
@ -6,11 +6,13 @@ from dcim import models
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'NestedDeviceBaySerializer',
|
'NestedDeviceBaySerializer',
|
||||||
|
'NestedDeviceRoleSerializer',
|
||||||
'NestedDeviceSerializer',
|
'NestedDeviceSerializer',
|
||||||
'NestedInterfaceSerializer',
|
'NestedInterfaceSerializer',
|
||||||
'NestedInterfaceTemplateSerializer',
|
'NestedInterfaceTemplateSerializer',
|
||||||
'NestedLocationSerializer',
|
'NestedLocationSerializer',
|
||||||
'NestedModuleBaySerializer',
|
'NestedModuleBaySerializer',
|
||||||
|
'NestedPlatformSerializer',
|
||||||
'NestedRegionSerializer',
|
'NestedRegionSerializer',
|
||||||
'NestedSiteGroupSerializer',
|
'NestedSiteGroupSerializer',
|
||||||
)
|
)
|
||||||
@ -102,3 +104,10 @@ class NestedModuleBaySerializer(WritableNestedSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = models.ModuleBay
|
model = models.ModuleBay
|
||||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedPlatformSerializer(WritableNestedSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Platform
|
||||||
|
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||||
|
@ -1,26 +1,32 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
from dcim.models import Platform
|
from dcim.models import Platform
|
||||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||||
from netbox.api.fields import RelatedObjectCountField
|
from netbox.api.serializers import NestedGroupModelSerializer
|
||||||
from netbox.api.serializers import NetBoxModelSerializer
|
|
||||||
from .manufacturers import ManufacturerSerializer
|
from .manufacturers import ManufacturerSerializer
|
||||||
|
from .nested import NestedPlatformSerializer
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'PlatformSerializer',
|
'PlatformSerializer',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlatformSerializer(NetBoxModelSerializer):
|
class PlatformSerializer(NestedGroupModelSerializer):
|
||||||
|
parent = NestedPlatformSerializer(required=False, allow_null=True, default=None)
|
||||||
manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
|
manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
|
||||||
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
|
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
# Related object counts
|
# Related object counts
|
||||||
device_count = RelatedObjectCountField('devices')
|
device_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description',
|
'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template',
|
||||||
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||||
|
'virtualmachine_count', '_depth',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
|
brief_fields = (
|
||||||
|
'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth',
|
||||||
|
)
|
||||||
|
@ -137,17 +137,29 @@ class RackSerializer(RackBaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class RackReservationSerializer(NetBoxModelSerializer):
|
class RackReservationSerializer(NetBoxModelSerializer):
|
||||||
rack = RackSerializer(nested=True)
|
rack = RackSerializer(
|
||||||
user = UserSerializer(nested=True)
|
nested=True,
|
||||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
)
|
||||||
|
status = ChoiceField(
|
||||||
|
choices=RackReservationStatusChoices,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
user = UserSerializer(
|
||||||
|
nested=True,
|
||||||
|
)
|
||||||
|
tenant = TenantSerializer(
|
||||||
|
nested=True,
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant',
|
'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
|
||||||
'description', 'comments', 'tags', 'custom_fields',
|
'tenant', 'description', 'comments', 'tags', 'custom_fields',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
|
brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
|
||||||
|
|
||||||
|
|
||||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||||
|
@ -373,8 +373,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
|
|||||||
# Platforms
|
# Platforms
|
||||||
#
|
#
|
||||||
|
|
||||||
class PlatformViewSet(NetBoxModelViewSet):
|
class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||||
queryset = Platform.objects.all()
|
queryset = Platform.objects.add_related_count(
|
||||||
|
Platform.objects.add_related_count(
|
||||||
|
Platform.objects.all(),
|
||||||
|
VirtualMachine,
|
||||||
|
'platform',
|
||||||
|
'virtualmachine_count',
|
||||||
|
cumulative=True
|
||||||
|
),
|
||||||
|
Device,
|
||||||
|
'platform',
|
||||||
|
'device_count',
|
||||||
|
cumulative=True
|
||||||
|
)
|
||||||
serializer_class = serializers.PlatformSerializer
|
serializer_class = serializers.PlatformSerializer
|
||||||
filterset_class = filtersets.PlatformFilterSet
|
filterset_class = filtersets.PlatformFilterSet
|
||||||
|
|
||||||
|
@ -139,6 +139,24 @@ class RackAirflowChoices(ChoiceSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Rack reservations
|
||||||
|
#
|
||||||
|
|
||||||
|
class RackReservationStatusChoices(ChoiceSet):
|
||||||
|
key = 'RackReservation.status'
|
||||||
|
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_ACTIVE = 'active'
|
||||||
|
STATUS_STALE = 'stale'
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(STATUS_PENDING, _('Pending'), 'cyan'),
|
||||||
|
(STATUS_ACTIVE, _('Active'), 'green'),
|
||||||
|
(STATUS_STALE, _('Stale'), 'orange'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# DeviceTypes
|
# DeviceTypes
|
||||||
#
|
#
|
||||||
|
@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label=_('Location (slug)'),
|
label=_('Location (slug)'),
|
||||||
)
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=RackReservationStatusChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
@ -547,14 +551,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label=_('Manufacturer (slug)'),
|
label=_('Manufacturer (slug)'),
|
||||||
)
|
)
|
||||||
default_platform_id = django_filters.ModelMultipleChoiceFilter(
|
default_platform_id = TreeNodeMultipleChoiceFilter(
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
|
field_name='default_platform',
|
||||||
|
lookup_expr='in',
|
||||||
label=_('Default platform (ID)'),
|
label=_('Default platform (ID)'),
|
||||||
)
|
)
|
||||||
default_platform = django_filters.ModelMultipleChoiceFilter(
|
default_platform = TreeNodeMultipleChoiceFilter(
|
||||||
field_name='default_platform__slug',
|
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
|
field_name='default_platform',
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
|
lookup_expr='in',
|
||||||
label=_('Default platform (slug)'),
|
label=_('Default platform (slug)'),
|
||||||
)
|
)
|
||||||
has_front_image = django_filters.BooleanFilter(
|
has_front_image = django_filters.BooleanFilter(
|
||||||
@ -979,6 +986,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class PlatformFilterSet(OrganizationalModelFilterSet):
|
class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||||
|
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
label=_('Immediate parent platform (ID)'),
|
||||||
|
)
|
||||||
|
parent = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='parent__slug',
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Immediate parent platform (slug)'),
|
||||||
|
)
|
||||||
|
ancestor_id = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
field_name='parent',
|
||||||
|
lookup_expr='in',
|
||||||
|
label=_('Parent platform (ID)'),
|
||||||
|
)
|
||||||
|
ancestor = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
field_name='parent',
|
||||||
|
lookup_expr='in',
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Parent platform (slug)'),
|
||||||
|
)
|
||||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='manufacturer',
|
field_name='manufacturer',
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
@ -1058,14 +1088,17 @@ class DeviceFilterSet(
|
|||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
label=_('Parent Device (ID)'),
|
label=_('Parent Device (ID)'),
|
||||||
)
|
)
|
||||||
platform_id = django_filters.ModelMultipleChoiceFilter(
|
platform_id = TreeNodeMultipleChoiceFilter(
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
|
field_name='platform',
|
||||||
|
lookup_expr='in',
|
||||||
label=_('Platform (ID)'),
|
label=_('Platform (ID)'),
|
||||||
)
|
)
|
||||||
platform = django_filters.ModelMultipleChoiceFilter(
|
platform = TreeNodeMultipleChoiceFilter(
|
||||||
field_name='platform__slug',
|
field_name='platform',
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
|
lookup_expr='in',
|
||||||
label=_('Platform (slug)'),
|
label=_('Platform (slug)'),
|
||||||
)
|
)
|
||||||
region_id = TreeNodeMultipleChoiceFilter(
|
region_id = TreeNodeMultipleChoiceFilter(
|
||||||
|
@ -11,6 +11,7 @@ from ipam.choices import VLANQinQRoleChoices
|
|||||||
from ipam.models import ASN, VLAN, VLANGroup, VRF
|
from ipam.models import ASN, VLAN, VLANGroup, VRF
|
||||||
from netbox.choices import *
|
from netbox.choices import *
|
||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
|
from netbox.forms.mixins import ChangelogMessageMixin
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
||||||
@ -475,6 +476,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
|
|
||||||
class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
status = forms.ChoiceField(
|
||||||
|
label=_('Status'),
|
||||||
|
choices=add_blank_choice(RackReservationStatusChoices),
|
||||||
|
required=False,
|
||||||
|
initial=''
|
||||||
|
)
|
||||||
user = forms.ModelChoiceField(
|
user = forms.ModelChoiceField(
|
||||||
label=_('User'),
|
label=_('User'),
|
||||||
queryset=User.objects.order_by('username'),
|
queryset=User.objects.order_by('username'),
|
||||||
@ -494,7 +501,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('user', 'tenant', 'description'),
|
FieldSet('status', 'user', 'tenant', 'description'),
|
||||||
)
|
)
|
||||||
nullable_fields = ('comments',)
|
nullable_fields = ('comments',)
|
||||||
|
|
||||||
@ -681,6 +688,11 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
|
|
||||||
class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
parent = DynamicModelChoiceField(
|
||||||
|
label=_('Parent'),
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
@ -696,12 +708,13 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
comments = CommentField()
|
||||||
|
|
||||||
model = Platform
|
model = Platform
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('manufacturer', 'config_template', 'description'),
|
FieldSet('parent', 'manufacturer', 'config_template', 'description'),
|
||||||
)
|
)
|
||||||
nullable_fields = ('manufacturer', 'config_template', 'description')
|
nullable_fields = ('parent', 'manufacturer', 'config_template', 'description', 'comments')
|
||||||
|
|
||||||
|
|
||||||
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
@ -1037,7 +1050,11 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
# Device component templates
|
# Device component templates
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConsolePortTemplateBulkEditForm(BulkEditForm):
|
class ComponentTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=ConsolePortTemplate.objects.all(),
|
queryset=ConsolePortTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1056,7 +1073,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('label', 'type', 'description')
|
nullable_fields = ('label', 'type', 'description')
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
|
class ConsoleServerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=ConsoleServerPortTemplate.objects.all(),
|
queryset=ConsoleServerPortTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1079,7 +1096,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('label', 'type', 'description')
|
nullable_fields = ('label', 'type', 'description')
|
||||||
|
|
||||||
|
|
||||||
class PowerPortTemplateBulkEditForm(BulkEditForm):
|
class PowerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=PowerPortTemplate.objects.all(),
|
queryset=PowerPortTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1114,7 +1131,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletTemplateBulkEditForm(BulkEditForm):
|
class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=PowerOutletTemplate.objects.all(),
|
queryset=PowerOutletTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1165,7 +1182,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
|
|||||||
self.fields['power_port'].widget.attrs['disabled'] = True
|
self.fields['power_port'].widget.attrs['disabled'] = True
|
||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateBulkEditForm(BulkEditForm):
|
class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=InterfaceTemplate.objects.all(),
|
queryset=InterfaceTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1216,7 +1233,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
|
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTemplateBulkEditForm(BulkEditForm):
|
class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=FrontPortTemplate.objects.all(),
|
queryset=FrontPortTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1243,7 +1260,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('description',)
|
nullable_fields = ('description',)
|
||||||
|
|
||||||
|
|
||||||
class RearPortTemplateBulkEditForm(BulkEditForm):
|
class RearPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=RearPortTemplate.objects.all(),
|
queryset=RearPortTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1270,7 +1287,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('description',)
|
nullable_fields = ('description',)
|
||||||
|
|
||||||
|
|
||||||
class ModuleBayTemplateBulkEditForm(BulkEditForm):
|
class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=ModuleBayTemplate.objects.all(),
|
queryset=ModuleBayTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1288,7 +1305,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('label', 'position', 'description')
|
nullable_fields = ('label', 'position', 'description')
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayTemplateBulkEditForm(BulkEditForm):
|
class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=DeviceBayTemplate.objects.all(),
|
queryset=DeviceBayTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
@ -1306,7 +1323,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm):
|
|||||||
nullable_fields = ('label', 'description')
|
nullable_fields = ('label', 'description')
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemTemplateBulkEditForm(BulkEditForm):
|
class InventoryItemTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(
|
pk = forms.ModelMultipleChoiceField(
|
||||||
queryset=InventoryItemTemplate.objects.all(),
|
queryset=InventoryItemTemplate.objects.all(),
|
||||||
widget=forms.MultipleHiddenInput()
|
widget=forms.MultipleHiddenInput()
|
||||||
|
@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
|
|||||||
required=True,
|
required=True,
|
||||||
help_text=_('Comma-separated list of individual unit numbers')
|
help_text=_('Comma-separated list of individual unit numbers')
|
||||||
)
|
)
|
||||||
|
status = CSVChoiceField(
|
||||||
|
label=_('Status'),
|
||||||
|
choices=RackReservationStatusChoices,
|
||||||
|
help_text=_('Operational status')
|
||||||
|
)
|
||||||
tenant = CSVModelChoiceField(
|
tenant = CSVModelChoiceField(
|
||||||
label=_('Tenant'),
|
label=_('Tenant'),
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags')
|
fields = ('site', 'location', 'rack', 'units', 'status', 'tenant', 'description', 'comments', 'tags')
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(data, *args, **kwargs)
|
super().__init__(data, *args, **kwargs)
|
||||||
@ -504,6 +509,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
|||||||
|
|
||||||
class PlatformImportForm(NetBoxModelImportForm):
|
class PlatformImportForm(NetBoxModelImportForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
parent = CSVModelChoiceField(
|
||||||
|
label=_('Parent'),
|
||||||
|
queryset=Platform.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text=_('Parent platform'),
|
||||||
|
error_messages={
|
||||||
|
'invalid_choice': _('Platform not found.'),
|
||||||
|
}
|
||||||
|
)
|
||||||
manufacturer = CSVModelChoiceField(
|
manufacturer = CSVModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
@ -522,7 +537,7 @@ class PlatformImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -676,6 +691,12 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
})
|
})
|
||||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||||
|
|
||||||
|
# Limit platform queryset by manufacturer
|
||||||
|
params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
|
||||||
|
self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
|
||||||
|
Q(**params) | Q(manufacturer=None)
|
||||||
|
)
|
||||||
|
|
||||||
# Limit device bay queryset by parent device
|
# Limit device bay queryset by parent device
|
||||||
if parent := data.get('parent'):
|
if parent := data.get('parent'):
|
||||||
params = {f"device__{self.fields['parent'].to_field_name}": parent}
|
params = {f"device__{self.fields['parent'].to_field_name}": parent}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user