mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-01 23:23:24 +02:00
Compare commits
116 Commits
v4.5.5
...
21770-embe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c08941542 | ||
|
|
be1a29d7ee | ||
|
|
f06f8f3f1d | ||
|
|
a45ec6620a | ||
|
|
bd35afe320 | ||
|
|
364868a207 | ||
|
|
d4569df305 | ||
|
|
b62c5e1ac4 | ||
|
|
1277bb6138 | ||
|
|
e98e5e11a7 | ||
|
|
3ce2bf75b4 | ||
|
|
b1af9a7218 | ||
|
|
b73f7f7d00 | ||
|
|
9492b55f4b | ||
|
|
2563122352 | ||
|
|
0455e14c29 | ||
|
|
76c02d5aa9 | ||
|
|
8bc691099c | ||
|
|
95011821bb | ||
|
|
b8b12f3f90 | ||
|
|
e5b9e5a279 | ||
|
|
05059f4a86 | ||
|
|
2389feea6b | ||
|
|
e4e4c1c56d | ||
|
|
c99d8481b2 | ||
|
|
0923a3dec8 | ||
|
|
80b9c25674 | ||
|
|
6d13bc8b96 | ||
|
|
ee17e83da6 | ||
|
|
5ab9608e38 | ||
|
|
c7504628bd | ||
|
|
e54ed87863 | ||
|
|
55daf4c52f | ||
|
|
a45e8571da | ||
|
|
0154a09856 | ||
|
|
757c4f69d2 | ||
|
|
d5f37d7a87 | ||
|
|
f30786d8fe | ||
|
|
74aa822b27 | ||
|
|
bb73601d80 | ||
|
|
9bc66ee0bf | ||
|
|
99e9d96787 | ||
|
|
296b89ae02 | ||
|
|
3ec0551680 | ||
|
|
8a58d760fa | ||
|
|
f5c97e367c | ||
|
|
84670af18b | ||
|
|
a3a204f2fd | ||
|
|
ea756b29e9 | ||
|
|
b929e1aa1b | ||
|
|
91d5382a61 | ||
|
|
e76203238d | ||
|
|
3f58648115 | ||
|
|
b904dc5c75 | ||
|
|
2c0b6c4d55 | ||
|
|
bf27ff9593 | ||
|
|
29239ca58a | ||
|
|
981f31304d | ||
|
|
2a39ab47d6 | ||
|
|
aa01c16db0 | ||
|
|
2a78c05984 | ||
|
|
e04986617c | ||
|
|
bc66d9f136 | ||
|
|
b8ce81c8fe | ||
|
|
41d05490fc | ||
|
|
83cf193cdc | ||
|
|
d497198f49 | ||
|
|
82df20a8a9 | ||
|
|
f303ae2cd7 | ||
|
|
4e479c547f | ||
|
|
e44c0a2119 | ||
|
|
3ab0613708 | ||
|
|
9f16734266 | ||
|
|
1f336eee2e | ||
|
|
6030fc383a | ||
|
|
c3c7cf15b2 | ||
|
|
1fb6507cc1 | ||
|
|
753fedf5e7 | ||
|
|
ca021e808b | ||
|
|
38afed60ef | ||
|
|
45b53ee036 | ||
|
|
992630d670 | ||
|
|
c8cd5fd6cd | ||
|
|
268ef4f59f | ||
|
|
671b1cd470 | ||
|
|
2f5543933e | ||
|
|
1fc43026d0 | ||
|
|
5804b53bb1 | ||
|
|
775d6aa936 | ||
|
|
639a739b5b | ||
|
|
b01d92c98b | ||
|
|
625c4eb5bb | ||
|
|
02165a28a0 | ||
|
|
80cc7e0d91 | ||
|
|
e2665ef211 | ||
|
|
c384cec453 | ||
|
|
e3d9fe622d | ||
|
|
719effb548 | ||
|
|
6659bb3abe | ||
|
|
0a5f40338d | ||
|
|
fd6e0e9784 | ||
|
|
2a176df28a | ||
|
|
cd5d88ff8a | ||
|
|
6e3fd9d4b2 | ||
|
|
53ae164c75 | ||
|
|
c40640af81 | ||
|
|
3c6596de8f | ||
|
|
b3de0b9bee | ||
|
|
ec0fe62df5 | ||
|
|
d3a0566ee3 | ||
|
|
694e3765dd | ||
|
|
303199dc8f | ||
|
|
6eafffb497 | ||
|
|
53ea48efa9 | ||
|
|
1a404f5c0f | ||
|
|
3320e07b70 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.5.5
|
||||
placeholder: v4.5.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.5.5
|
||||
placeholder: v4.5.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
2
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.5.5
|
||||
placeholder: v4.5.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check Python linting & PEP8 compliance
|
||||
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
|
||||
@@ -63,12 +63,12 @@ jobs:
|
||||
src: "netbox/"
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
pip install coverage tblib
|
||||
|
||||
- name: Build documentation
|
||||
run: mkdocs build
|
||||
run: zensical build
|
||||
|
||||
- name: Collect static files
|
||||
run: python netbox/manage.py collectstatic --no-input
|
||||
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue is being closed as no further information has been provided. If
|
||||
|
||||
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
# General parameters
|
||||
operations-per-run: 200
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -27,16 +27,16 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
2
.github/workflows/lock-threads.yml
vendored
2
.github/workflows/lock-threads.yml
vendored
@@ -19,6 +19,6 @@ jobs:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v6.0.0
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
discussion-inactive-days: 180
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ repos:
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
- id: mkdocs-build
|
||||
- id: zensical-build
|
||||
name: "Build documentation"
|
||||
description: "Build the documentation with mkdocs"
|
||||
description: "Build the documentation with Zensical"
|
||||
files: 'docs/'
|
||||
entry: mkdocs build
|
||||
entry: zensical build
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: yarn-validate
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
version: 2
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
mkdocs:
|
||||
configuration: mkdocs.yml
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.txt
|
||||
commands:
|
||||
- pip install -r requirements.txt
|
||||
- python -m zensical build --config-file mkdocs.yml
|
||||
- mkdir -p $READTHEDOCS_OUTPUT/html/
|
||||
- cp -r netbox/project-static/docs/* $READTHEDOCS_OUTPUT/html/
|
||||
|
||||
@@ -54,7 +54,8 @@ python manage.py nbshell # NetBox-enhanced shell
|
||||
|
||||
## Architecture Conventions
|
||||
- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
|
||||
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`.
|
||||
- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
|
||||
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
|
||||
- **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
|
||||
- **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
|
||||
- **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
|
||||
@@ -68,6 +69,8 @@ python manage.py nbshell # NetBox-enhanced shell
|
||||
- API serializers must include a `url` field (absolute URL of the object).
|
||||
- Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
|
||||
- Avoid adding new dependencies without strong justification.
|
||||
- Avoid running `ruff format` on existing files, as this tends to introduce unnecessary style changes.
|
||||
- Don't craft Django database migrations manually: Prompt the user to run `manage.py makemigrations` instead.
|
||||
|
||||
## Branch & PR Conventions
|
||||
- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
|
||||
|
||||
@@ -4,7 +4,7 @@ colorama
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://docs.djangoproject.com/en/stable/releases/
|
||||
Django==5.2.*
|
||||
Django==6.0.*
|
||||
|
||||
# Django middleware which permits cross-domain API requests
|
||||
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
|
||||
@@ -35,7 +35,9 @@ django-pglocks
|
||||
|
||||
# Prometheus metrics library for Django
|
||||
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
|
||||
django-prometheus
|
||||
# TODO: 2.4.1 is incompatible with Django>=6.0, but a fixed release is expected
|
||||
# https://github.com/django-commons/django-prometheus/issues/494
|
||||
django-prometheus>=2.4.0,<2.5.0,!=2.4.1
|
||||
|
||||
# Django caching backend using Redis
|
||||
# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
|
||||
@@ -99,10 +101,6 @@ jsonschema
|
||||
# https://python-markdown.github.io/changelog/
|
||||
Markdown
|
||||
|
||||
# MkDocs
|
||||
# https://github.com/mkdocs/mkdocs/releases
|
||||
mkdocs<2.0
|
||||
|
||||
# MkDocs Material theme (for documentation build)
|
||||
# https://squidfunk.github.io/mkdocs-material/changelog/
|
||||
mkdocs-material
|
||||
@@ -176,3 +174,7 @@ tablib
|
||||
# Timezone data (required by django-timezone-field on Python 3.9+)
|
||||
# https://github.com/python/tzdata/blob/master/NEWS.md
|
||||
tzdata
|
||||
|
||||
# Documentation builder (succeeds mkdocs)
|
||||
# https://github.com/zensical/zensical
|
||||
zensical
|
||||
|
||||
@@ -416,9 +416,13 @@
|
||||
"800gbase-dr8",
|
||||
"800gbase-sr8",
|
||||
"800gbase-vr8",
|
||||
"1.6tbase-cr8",
|
||||
"1.6tbase-dr8",
|
||||
"1.6tbase-dr8-2",
|
||||
"100base-x-sfp",
|
||||
"1000base-x-gbic",
|
||||
"1000base-x-sfp",
|
||||
"2.5gbase-x-sfp",
|
||||
"10gbase-x-sfpp",
|
||||
"10gbase-x-xenpak",
|
||||
"10gbase-x-xfp",
|
||||
@@ -448,6 +452,9 @@
|
||||
"400gbase-x-osfp-rhs",
|
||||
"800gbase-x-osfp",
|
||||
"800gbase-x-qsfpdd",
|
||||
"1.6tbase-x-osfp1600",
|
||||
"1.6tbase-x-osfp1600-rhs",
|
||||
"1.6tbase-x-qsfpdd1600",
|
||||
"1000base-kx",
|
||||
"2.5gbase-kx",
|
||||
"5gbase-kr",
|
||||
@@ -459,6 +466,7 @@
|
||||
"100gbase-kp4",
|
||||
"100gbase-kr2",
|
||||
"100gbase-kr4",
|
||||
"1.6tbase-kr8",
|
||||
"ieee802.11a",
|
||||
"ieee802.11g",
|
||||
"ieee802.11n",
|
||||
|
||||
File diff suppressed because one or more lines are too long
18
docs/_theme/partials/copyright.html
vendored
18
docs/_theme/partials/copyright.html
vendored
@@ -1,18 +0,0 @@
|
||||
<div class="md-copyright">
|
||||
{% if config.copyright %}
|
||||
<div class="md-copyright__highlight">
|
||||
{{ config.copyright }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not config.extra.generator == false %}
|
||||
Made with
|
||||
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
|
||||
Material for MkDocs
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not config.extra.build_public %}
|
||||
<div class="md-copyright">
|
||||
ℹ️ Documentation is being served locally
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -21,6 +21,7 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
|
||||
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
|
||||
* [`BANNER_LOGIN`](./miscellaneous.md#banner_login)
|
||||
* [`BANNER_TOP`](./miscellaneous.md#banner_top)
|
||||
* [`CHANGELOG_RETAIN_CREATE_LAST_UPDATE`](./miscellaneous.md#changelog_retain_create_last_update)
|
||||
* [`CHANGELOG_RETENTION`](./miscellaneous.md#changelog_retention)
|
||||
* [`CUSTOM_VALIDATORS`](./data-validation.md#custom_validators)
|
||||
* [`DEFAULT_USER_PREFERENCES`](./default-values.md#default_user_preferences)
|
||||
|
||||
@@ -73,6 +73,27 @@ This data enables the project maintainers to estimate how many NetBox deployment
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETAIN_CREATE_LAST_UPDATE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: `True`
|
||||
|
||||
When pruning expired changelog entries (per `CHANGELOG_RETENTION`), retain each non-deleted object's original `create`
|
||||
change record and its most recent `update` change record. If an object has a `delete` change record, its changelog
|
||||
entries are pruned normally according to `CHANGELOG_RETENTION`.
|
||||
|
||||
!!! note
|
||||
For objects without a `delete` change record, the original `create` record and most recent `update` record are
|
||||
exempt from pruning. All other changelog records (including intermediate `update` records and all `delete` records)
|
||||
remain subject to pruning per `CHANGELOG_RETENTION`.
|
||||
|
||||
!!! warning
|
||||
This setting is enabled by default. Upgrading deployments that rely on complete pruning of expired changelog entries
|
||||
should explicitly set `CHANGELOG_RETAIN_CREATE_LAST_UPDATE = False` to preserve the previous behavior.
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETENTION
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
@@ -63,6 +63,7 @@ NetBox supports limited custom validation for custom field values. Following are
|
||||
* Text: Regular expression (optional)
|
||||
* Integer: Minimum and/or maximum value (optional)
|
||||
* Selection: Must exactly match one of the prescribed choices
|
||||
* JSON: Must adhere to the defined validation schema (if any)
|
||||
|
||||
### Custom Selection Fields
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate co
|
||||
* Run the `ruff` Python linter
|
||||
* Run Django's internal system check
|
||||
* Check for missing database migrations
|
||||
* Validate any changes to the documentation with `mkdocs`
|
||||
* Validate any changes to the documentation with `zensical`
|
||||
* Validate Typescript & Sass styling with `yarn`
|
||||
* Ensure that any modified static front end assets have been recompiled
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ These are considered the "core" application models which are used to model netwo
|
||||
* [core.DataSource](../models/core/datasource.md)
|
||||
* [core.Job](../models/core/job.md)
|
||||
* [dcim.Cable](../models/dcim/cable.md)
|
||||
* [dcim.CableBundle](../models/dcim/cablebundle.md)
|
||||
* [dcim.Device](../models/dcim/device.md)
|
||||
* [dcim.DeviceType](../models/dcim/devicetype.md)
|
||||
* [dcim.Module](../models/dcim/module.md)
|
||||
@@ -73,6 +74,7 @@ These are considered the "core" application models which are used to model netwo
|
||||
* [tenancy.Tenant](../models/tenancy/tenant.md)
|
||||
* [virtualization.Cluster](../models/virtualization/cluster.md)
|
||||
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
|
||||
* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md)
|
||||
* [vpn.IKEPolicy](../models/vpn/ikepolicy.md)
|
||||
* [vpn.IKEProposal](../models/vpn/ikeproposal.md)
|
||||
* [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md)
|
||||
@@ -92,6 +94,7 @@ Organization models are used to organize and classify primary models.
|
||||
* [dcim.DeviceRole](../models/dcim/devicerole.md)
|
||||
* [dcim.Manufacturer](../models/dcim/manufacturer.md)
|
||||
* [dcim.Platform](../models/dcim/platform.md)
|
||||
* [dcim.RackGroup](../models/dcim/rackgroup.md)
|
||||
* [dcim.RackRole](../models/dcim/rackrole.md)
|
||||
* [ipam.ASNRange](../models/ipam/asnrange.md)
|
||||
* [ipam.RIR](../models/ipam/rir.md)
|
||||
|
||||
@@ -47,7 +47,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
|
||||
Start the documentation server and navigate to the current version of the installation docs:
|
||||
|
||||
```no-highlight
|
||||
mkdocs serve
|
||||
zensical serve
|
||||
```
|
||||
|
||||
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||
|
||||
@@ -5,10 +5,6 @@ img {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.md-content img {
|
||||
background-color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
margin-bottom: 24px;
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
# Virtualization
|
||||
|
||||
Virtual machines and clusters can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
|
||||
Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ClusterGroup & ClusterType --> Cluster
|
||||
VirtualMachineType --> VirtualMachine
|
||||
Device --> VirtualMachine
|
||||
Cluster --> VirtualMachine
|
||||
Platform --> VirtualMachine
|
||||
VirtualMachine --> VMInterface
|
||||
|
||||
click Cluster "../../models/virtualization/cluster/"
|
||||
click ClusterGroup "../../models/virtualization/clustergroup/"
|
||||
click ClusterType "../../models/virtualization/clustertype/"
|
||||
click Platform "../../models/dcim/platform/"
|
||||
click VirtualMachine "../../models/virtualization/virtualmachine/"
|
||||
click VMInterface "../../models/virtualization/vminterface/"
|
||||
click Cluster "../../models/virtualization/cluster/"
|
||||
click ClusterGroup "../../models/virtualization/clustergroup/"
|
||||
click ClusterType "../../models/virtualization/clustertype/"
|
||||
click VirtualMachineType "../../models/virtualization/virtualmachinetype/"
|
||||
click Device "../../models/dcim/device/"
|
||||
click Platform "../../models/dcim/platform/"
|
||||
click VirtualMachine "../../models/virtualization/virtualmachine/"
|
||||
click VMInterface "../../models/virtualization/vminterface/"
|
||||
```
|
||||
|
||||
## Clusters
|
||||
|
||||
A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
|
||||
A cluster is one or more physical host devices on which virtual machines can run.
|
||||
|
||||
Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
|
||||
|
||||
## Virtual Machine Types
|
||||
|
||||
A virtual machine type provides reusable classification for virtual machines and can define create-time defaults for platform, vCPUs, and memory. This is useful when multiple virtual machines share a common sizing or profile while still allowing per-instance overrides after creation.
|
||||
|
||||
## Virtual Machines
|
||||
|
||||
A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may also define its compute, memory, and storage resources as well.
|
||||
A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes.
|
||||
|
||||
For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A VM can optionally be assigned a [virtual machine type](../models/virtualization/virtualmachinetype.md) to classify it and provide default values for selected attributes at creation time.
|
||||
|
||||
A VM can be placed in one of three ways:
|
||||
|
||||
- Assigned to a site alone for logical grouping.
|
||||
- Assigned to a cluster and optionally pinned to a specific host device within that cluster.
|
||||
- Assigned directly to a standalone device that does not belong to any cluster.
|
||||
|
||||
@@ -341,7 +341,7 @@ When retrieving devices and virtual machines via the REST API, each will include
|
||||
|
||||
## Pagination
|
||||
|
||||
API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes:
|
||||
API responses which contain a list of many objects will be paginated for efficiency. NetBox employs offset-based pagination by default, which forms a page by skipping the number of objects indicated by the `offset` URL parameter. The root JSON object returned by a list endpoint contains the following attributes:
|
||||
|
||||
* `count`: The total number of all objects matching the query
|
||||
* `next`: A hyperlink to the next page of results (if applicable)
|
||||
@@ -398,6 +398,49 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_
|
||||
!!! warning
|
||||
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
|
||||
|
||||
### Cursor-Based Pagination
|
||||
|
||||
For large datasets, offset-based pagination can become inefficient because the database must scan all rows up to the offset. As an alternative, cursor-based pagination uses the `start` query parameter to filter results by primary key (PK), enabling efficient keyset pagination.
|
||||
|
||||
To use cursor-based pagination, pass `start` (the minimum PK value) and `limit` (the page size):
|
||||
|
||||
```
|
||||
http://netbox/api/dcim/devices/?start=0&limit=100
|
||||
```
|
||||
|
||||
This returns objects with an `id` greater than or equal to zero, ordered by PK, limited to 100 results. Below is an example showing an arbitrary `start` value.
|
||||
|
||||
```json
|
||||
{
|
||||
"count": null,
|
||||
"next": "http://netbox/api/dcim/devices/?start=356&limit=100",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 109,
|
||||
"name": "dist-router07",
|
||||
...
|
||||
},
|
||||
...
|
||||
{
|
||||
"id": 356,
|
||||
"name": "acc-switch492",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To iterate through all results, use the `id` of the last object in each response plus one as the `start` value for the next request. Continue until `next` is null.
|
||||
|
||||
!!! info
|
||||
Some important differences from offset-based pagination:
|
||||
|
||||
* `start` and `offset` are **mutually exclusive**; specifying both will result in a 400 error.
|
||||
* Results are always ordered by primary key when using `start`. This is required to ensure deterministic behavior.
|
||||
* `count` is always `null` in cursor mode, as counting all matching rows would partially negate its performance benefit.
|
||||
* `previous` is always `null`: cursor-based pagination supports only forward navigation.
|
||||
|
||||
## Interacting with Objects
|
||||
|
||||
### Retrieving Multiple Objects
|
||||
|
||||
@@ -31,6 +31,11 @@ The following data is available as context for Jinja2 templates:
|
||||
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
|
||||
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
|
||||
|
||||
!!! warning "Deprecation of legacy fields"
|
||||
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
|
||||
|
||||
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
|
||||
|
||||
### Default Request Body
|
||||
|
||||
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
|
||||
|
||||
15
docs/models/dcim/cablebundle.md
Normal file
15
docs/models/dcim/cablebundle.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Cable Bundles
|
||||
|
||||
A cable bundle is a logical grouping of individual [cables](./cable.md). Bundles can be used to organize cables that share a common purpose, route, or physical grouping (such as a conduit or harness).
|
||||
|
||||
Assigning cables to a bundle is optional and does not affect cable tracing or connectivity. Bundles persist independently of their member cables: deleting a cable clears its bundle assignment but does not delete the bundle itself.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique name for the cable bundle.
|
||||
|
||||
### Description
|
||||
|
||||
A brief description of the bundle's purpose or contents.
|
||||
@@ -23,3 +23,8 @@ The device bay's name. Must be unique to the parent device.
|
||||
### Label
|
||||
|
||||
An alternative physical label identifying the device bay.
|
||||
|
||||
### Enabled
|
||||
|
||||
Whether this device bay is enabled. Disabled device bays are not available for installation.
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment
|
||||
!!! note
|
||||
This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.
|
||||
|
||||
## Automatic Component Renaming
|
||||
|
||||
When adding component templates to a device type, the string `{vc_position}` can be used in component template names to reference the
|
||||
`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
|
||||
|
||||
For example, an interface template named `Gi{vc_position}/0/0` installed on a Virtual Chassis
|
||||
member with position `2` will be rendered as `Gi2/0/0`.
|
||||
|
||||
If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
|
||||
fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
|
||||
For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
|
||||
|
||||
## Fields
|
||||
|
||||
### Manufacturer
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Module Bays
|
||||
|
||||
Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device.
|
||||
Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules, in turn, hold additional components that become available to the parent device.
|
||||
|
||||
!!! note
|
||||
If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead.
|
||||
@@ -29,3 +29,8 @@ An alternative physical label identifying the module bay.
|
||||
### Position
|
||||
|
||||
The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
|
||||
|
||||
### Enabled
|
||||
|
||||
Whether this module bay is enabled. Disabled module bays are not available for installation.
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ When adding component templates to a module type, the string `{module}` can be u
|
||||
|
||||
For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`.
|
||||
|
||||
Similarly, the string `{vc_position}` can be used in component template names to reference the
|
||||
`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
|
||||
|
||||
For example, an interface template named `Gi{vc_position}/{module}/0` installed on a Virtual Chassis
|
||||
member with position `2` and module bay position `3` will be rendered as `Gi2/3/0`.
|
||||
|
||||
If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
|
||||
fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
|
||||
For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
|
||||
|
||||
Automatic renaming is supported for all modular component types (those listed above).
|
||||
|
||||
## Fields
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Racks
|
||||
|
||||
The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
|
||||
The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles or by [rack groups](./rackgroup.md). The name and facility ID of each rack within a location must be unique.
|
||||
|
||||
Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
|
||||
|
||||
@@ -16,6 +16,10 @@ The [site](./site.md) to which the rack is assigned.
|
||||
|
||||
The [location](./location.md) within a site where the rack has been installed (optional).
|
||||
|
||||
### Rack Group
|
||||
|
||||
The [group](./rackgroup.md) used to organize racks by physical placement (optional).
|
||||
|
||||
### Name
|
||||
|
||||
The rack's name or identifier. Must be unique to the rack's location, if assigned.
|
||||
|
||||
15
docs/models/dcim/rackgroup.md
Normal file
15
docs/models/dcim/rackgroup.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Rack Groups
|
||||
|
||||
Racks can optionally be assigned to rack groups to reflect their physical placement. Rack groups provide a secondary means of categorization alongside [locations](./location.md), which is particularly useful for datacenter operators who need to group racks by row, aisle, or similar physical arrangement while keeping them assigned to the same location, such as a cage or room. Rack groups are flat and do not form a hierarchy.
|
||||
|
||||
Rack groups can also be used to scope [VLAN groups](../ipam/vlangroup.md), which can help model L2 domains spanning rows or pairs of racks.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name.
|
||||
|
||||
### Slug
|
||||
|
||||
A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
@@ -12,6 +12,10 @@ 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.
|
||||
|
||||
### Total U's
|
||||
|
||||
A calculated (read-only) field that reflects the total number of units in the reservation. Can be filtered upon using `unit_count_min` and `unit_count_max` parameters in the UI or API.
|
||||
|
||||
### 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.)
|
||||
|
||||
@@ -118,3 +118,7 @@ For numeric custom fields only. The maximum valid value (optional).
|
||||
### Validation Regex
|
||||
|
||||
For string-based custom fields only. A regular expression used to validate the field's value (optional).
|
||||
|
||||
### Validation Schema
|
||||
|
||||
For JSON custom fields, users have the option of defining a [validation schema](https://json-schema.org). Any value applied to this custom field on a model will be validated against the provided schema, if any.
|
||||
|
||||
@@ -81,10 +81,13 @@ The following context variables are available to the text and link templates.
|
||||
|
||||
| Variable | Description |
|
||||
|---------------|------------------------------------------------------|
|
||||
| `event` | The event type (`created`, `updated`, or `deleted`) |
|
||||
| `event` | The event type (`create`, `update`, or `delete`) |
|
||||
| `timestamp` | The time at which the event occurred |
|
||||
| `object_type` | The type of object impacted (`app_label.model_name`) |
|
||||
| `username` | The name of the user associated with the change |
|
||||
| `request_id` | The unique request ID |
|
||||
| `data` | A complete serialized representation of the object |
|
||||
| `snapshots` | Pre- and post-change snapshots of the object |
|
||||
|
||||
!!! warning "Deprecation of legacy fields"
|
||||
The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.)
|
||||
|
||||
@@ -14,6 +14,10 @@ The 16- or 32-bit AS number.
|
||||
|
||||
The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN.
|
||||
|
||||
### Role
|
||||
|
||||
The user-defined functional [role](./role.md) assigned to this ASN.
|
||||
|
||||
### Sites
|
||||
|
||||
The [site(s)](../dcim/site.md) to which this ASN is assigned.
|
||||
|
||||
@@ -18,6 +18,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
|
||||
The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
|
||||
|
||||
### Total VLAN IDs
|
||||
|
||||
A read-only integer indicating the total count of VLAN IDs available within the group, calculated from the configured VLAN ID Ranges. For example, a group with ranges `100-199` and `300-399` would have a total of 200 VLAN IDs. This value is automatically computed and updated whenever the VLAN ID ranges are modified.
|
||||
|
||||
### Scope
|
||||
|
||||
The domain covered by a VLAN group, defined as one of the supported object types. This conveys the context in which a VLAN group applies.
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
# Virtual Machines
|
||||
|
||||
A virtual machine (VM) represents a virtual compute instance hosted within a [cluster](./cluster.md). Each VM must be assigned to a [site](../dcim/site.md) and/or cluster, and may optionally be assigned to a particular host [device](../dcim/device.md) within a cluster.
|
||||
A virtual machine (VM) represents a virtual compute instance hosted within a cluster or directly on a device. Each VM must be assigned to at least one of: a [site](../dcim/site.md), a [cluster](./cluster.md), or a [device](../dcim/device.md).
|
||||
|
||||
Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6.
|
||||
Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the VM can be designated, for both IPv4 and IPv6.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The virtual machine's configured name. Must be unique to the assigned cluster and tenant.
|
||||
The virtual machine's configured name. Must be unique within its scoping context:
|
||||
|
||||
- If assigned to a **cluster**: unique within the cluster and tenant.
|
||||
- If assigned to a **device** (no cluster): unique within the device and tenant.
|
||||
|
||||
### Type
|
||||
|
||||
The [virtual machine type](./virtualmachinetype.md) assigned to the VM. A type classifies a virtual machine and can provide default values for platform, vCPUs, and memory when the VM is created.
|
||||
|
||||
Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
|
||||
|
||||
### Role
|
||||
|
||||
The functional [role](../dcim/devicerole.md) assigned to the VM.
|
||||
The functional role assigned to the VM.
|
||||
|
||||
### Status
|
||||
|
||||
@@ -21,24 +30,28 @@ The VM's operational status.
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Start on boot
|
||||
### Start on Boot
|
||||
|
||||
The start on boot setting from the hypervisor.
|
||||
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Site & Cluster
|
||||
### Site / Cluster / Device
|
||||
|
||||
The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.
|
||||
The location or host for this VM. At least one must be specified:
|
||||
|
||||
### Device
|
||||
- **Site only**: The VM exists at a site but is not assigned to a specific cluster or device.
|
||||
- **Cluster only**: The VM belongs to a virtualization cluster. The site is automatically inferred from the cluster's scope.
|
||||
- **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site.
|
||||
- **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster.
|
||||
|
||||
The physical host [device](../dcim/device.md) within the assigned site/cluster on which this VM resides.
|
||||
!!! info "New in NetBox v4.6"
|
||||
Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster.
|
||||
|
||||
### Platform
|
||||
|
||||
A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system.
|
||||
A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.
|
||||
|
||||
### Primary IPv4 & IPv6 Addresses
|
||||
|
||||
@@ -49,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f
|
||||
|
||||
### vCPUs
|
||||
|
||||
The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU).
|
||||
The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). If a virtual machine type defines a default vCPU allocation, it will be applied when the VM is created unless an explicit value is specified.
|
||||
|
||||
### Memory
|
||||
|
||||
The amount of running memory provisioned, in megabytes.
|
||||
The amount of running memory provisioned, in megabytes. If a virtual machine type defines a default memory allocation, it will be applied when the VM is created unless an explicit value is specified.
|
||||
|
||||
### Disk
|
||||
|
||||
@@ -64,4 +77,7 @@ The amount of disk storage provisioned, in megabytes.
|
||||
|
||||
### Serial Number
|
||||
|
||||
Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers.
|
||||
Optional serial number assigned to this virtual machine.
|
||||
|
||||
!!! info
|
||||
Unlike devices, uniqueness is not enforced for virtual machine serial numbers.
|
||||
|
||||
27
docs/models/virtualization/virtualmachinetype.md
Normal file
27
docs/models/virtualization/virtualmachinetype.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Virtual Machine Types
|
||||
|
||||
A virtual machine type defines a reusable classification and default configuration for [virtual machines](./virtualmachine.md).
|
||||
|
||||
A type can optionally provide default values for a VM's [platform](../dcim/platform.md), vCPU allocation, and memory allocation. When a virtual machine is created with an assigned type, any unset values among these fields will inherit their defaults from the type. Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name.
|
||||
|
||||
### Slug
|
||||
|
||||
A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
|
||||
### Default Platform
|
||||
|
||||
If defined, virtual machines instantiated with this type will automatically inherit the selected platform when no explicit platform is provided.
|
||||
|
||||
### Default vCPUs
|
||||
|
||||
The default number of vCPUs to assign when creating a virtual machine from this type.
|
||||
|
||||
### Default Memory
|
||||
|
||||
The default amount of memory, in megabytes, to assign when creating a virtual machine from this type.
|
||||
@@ -1,12 +1,14 @@
|
||||
# Search
|
||||
|
||||
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
|
||||
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models.
|
||||
|
||||
```python
|
||||
```python title="search.py"
|
||||
# search.py
|
||||
from netbox.search import SearchIndex
|
||||
from netbox.search import SearchIndex, register_search
|
||||
|
||||
from .models import MyModel
|
||||
|
||||
@register_search
|
||||
class MyModelIndex(SearchIndex):
|
||||
model = MyModel
|
||||
fields = (
|
||||
@@ -17,15 +19,11 @@ class MyModelIndex(SearchIndex):
|
||||
display_attrs = ('site', 'device', 'status', 'description')
|
||||
```
|
||||
|
||||
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
|
||||
Decorate each `SearchIndex` subclass with `@register_search` to register it with NetBox. When using the default `search.py` module, no additional `indexes = [...]` list is required.
|
||||
|
||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
||||
|
||||
```python
|
||||
indexes = [MyModelIndex]
|
||||
```
|
||||
Fields listed in `display_attrs` are not cached for matching, but they are displayed alongside the object in global search results to provide additional context.
|
||||
|
||||
!!! tip
|
||||
The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
|
||||
The legacy `indexes = [...]` list remains supported via `PluginConfig.search_indexes` for backward compatibility and custom loading patterns.
|
||||
|
||||
::: netbox.search.SearchIndex
|
||||
|
||||
@@ -43,6 +43,11 @@ The resulting webhook payload will look like the following:
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "Deprecation of legacy fields"
|
||||
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
|
||||
|
||||
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
|
||||
|
||||
!!! 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:
|
||||
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# NetBox v4.5
|
||||
|
||||
## v4.5.6 (2026-03-31)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#21480](https://github.com/netbox-community/netbox/issues/21480) - Add OSFP224 (1.6T) interface type
|
||||
* [#21727](https://github.com/netbox-community/netbox/issues/21727) - Add 2.5GBASE-X SFP modular interface type
|
||||
* [#21743](https://github.com/netbox-community/netbox/issues/21743) - Improve object change diff styling and layout
|
||||
* [#21793](https://github.com/netbox-community/netbox/issues/21793) - Add 50 Gbps, 800 Gbps, and 1.6 Tbps interface speed options
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#20467](https://github.com/netbox-community/netbox/issues/20467) - Fix resolution of the `{module}` variable for position fields in nested modules
|
||||
* [#21698](https://github.com/netbox-community/netbox/issues/21698) - Adjust custom field URL filter to support non-standard port numbers
|
||||
* [#21707](https://github.com/netbox-community/netbox/issues/21707) - Fix grouping of owner fields in provider account add/edit forms
|
||||
* [#21749](https://github.com/netbox-community/netbox/issues/21749) - Fix `FieldError` exception when sorting the circuit group assignment table by the member column
|
||||
* [#21763](https://github.com/netbox-community/netbox/issues/21763) - Use separate add/remove form fields when editing a site or provider with a large number of ASNs assigned
|
||||
|
||||
---
|
||||
|
||||
## v4.5.5 (2026-03-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Note: NetBox has migrated from MkDocs to Zensical
|
||||
site_name: NetBox Documentation
|
||||
site_dir: netbox/project-static/docs
|
||||
site_url: https://docs.netbox.dev/
|
||||
@@ -189,6 +190,7 @@ nav:
|
||||
- Job: 'models/core/job.md'
|
||||
- DCIM:
|
||||
- Cable: 'models/dcim/cable.md'
|
||||
- CableBundle: 'models/dcim/cablebundle.md'
|
||||
- ConsolePort: 'models/dcim/consoleport.md'
|
||||
- ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
|
||||
- ConsoleServerPort: 'models/dcim/consoleserverport.md'
|
||||
@@ -221,6 +223,7 @@ nav:
|
||||
- PowerPort: 'models/dcim/powerport.md'
|
||||
- PowerPortTemplate: 'models/dcim/powerporttemplate.md'
|
||||
- Rack: 'models/dcim/rack.md'
|
||||
- RackGroup: 'models/dcim/rackgroup.md'
|
||||
- RackReservation: 'models/dcim/rackreservation.md'
|
||||
- RackRole: 'models/dcim/rackrole.md'
|
||||
- RackType: 'models/dcim/racktype.md'
|
||||
@@ -285,6 +288,7 @@ nav:
|
||||
- VMInterface: 'models/virtualization/vminterface.md'
|
||||
- VirtualDisk: 'models/virtualization/virtualdisk.md'
|
||||
- VirtualMachine: 'models/virtualization/virtualmachine.md'
|
||||
- VirtualMachineType: 'models/virtualization/virtualmachinetype.md'
|
||||
- VPN:
|
||||
- IKEPolicy: 'models/vpn/ikepolicy.md'
|
||||
- IKEProposal: 'models/vpn/ikeproposal.md'
|
||||
|
||||
@@ -22,7 +22,7 @@ from utilities.forms.fields import (
|
||||
SlugField,
|
||||
)
|
||||
from utilities.forms.mixins import DistanceValidationMixin
|
||||
from utilities.forms.rendering import FieldSet, InlineFields
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
|
||||
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
|
||||
from utilities.templatetags.builtins.filters import bettertitle
|
||||
|
||||
@@ -48,17 +48,42 @@ class ProviderForm(PrimaryModelForm):
|
||||
label=_('ASNs'),
|
||||
required=False
|
||||
)
|
||||
add_asns = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
label=_('Add ASNs'),
|
||||
required=False
|
||||
)
|
||||
remove_asns = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
label=_('Remove ASNs'),
|
||||
required=False
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'slug', 'asns', 'description', 'tags'),
|
||||
FieldSet('name', 'slug', M2MAddRemoveFields('asns'), 'description', 'tags'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = [
|
||||
'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
|
||||
'name', 'slug', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
|
||||
# Add/remove mode for large M2M sets
|
||||
self.fields.pop('asns')
|
||||
self.fields['add_asns'].widget.add_query_param('provider_id__n', self.instance.pk)
|
||||
self.fields['remove_asns'].widget.add_query_param('provider_id', self.instance.pk)
|
||||
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
|
||||
else:
|
||||
# Simple mode for new objects or small M2M sets
|
||||
self.fields.pop('add_asns')
|
||||
self.fields.pop('remove_asns')
|
||||
if self.instance.pk:
|
||||
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
|
||||
|
||||
|
||||
class ProviderAccountForm(PrimaryModelForm):
|
||||
provider = DynamicModelChoiceField(
|
||||
@@ -68,10 +93,14 @@ class ProviderAccountForm(PrimaryModelForm):
|
||||
quick_add=True
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('provider', 'account', 'name', 'description', 'tags'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProviderAccount
|
||||
fields = [
|
||||
'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
|
||||
'provider', 'account', 'name', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -190,14 +190,16 @@ class CircuitGroupAssignmentTable(NetBoxTable):
|
||||
provider = tables.Column(
|
||||
accessor='member__provider',
|
||||
verbose_name=_('Provider'),
|
||||
linkify=True
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
)
|
||||
member_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
member = tables.Column(
|
||||
verbose_name=_('Circuit'),
|
||||
linkify=True
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
)
|
||||
priority = tables.Column(
|
||||
verbose_name=_('Priority'),
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
from django.test import RequestFactory, TestCase, tag
|
||||
|
||||
from circuits.models import CircuitTermination
|
||||
from circuits.tables import CircuitTerminationTable
|
||||
from circuits.models import CircuitGroupAssignment, CircuitTermination
|
||||
from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
|
||||
|
||||
|
||||
@tag('regression')
|
||||
class CircuitTerminationTableTest(TestCase):
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
terminations = CircuitTermination.objects.all()
|
||||
disallowed = {'actions', }
|
||||
disallowed = {
|
||||
'actions',
|
||||
}
|
||||
|
||||
orderable_columns = [
|
||||
column.name for column in CircuitTerminationTable(terminations).columns
|
||||
column.name
|
||||
for column in CircuitTerminationTable(terminations).columns
|
||||
if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get("/")
|
||||
fake_request = RequestFactory().get('/')
|
||||
|
||||
for col in orderable_columns:
|
||||
for dir in ('-', ''):
|
||||
for direction in ('-', ''):
|
||||
table = CircuitTerminationTable(terminations)
|
||||
table.order_by = f'{dir}{col}'
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
|
||||
|
||||
@tag('regression')
|
||||
class CircuitGroupAssignmentTableTest(TestCase):
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
assignment = CircuitGroupAssignment.objects.all()
|
||||
disallowed = {
|
||||
'actions',
|
||||
}
|
||||
|
||||
orderable_columns = [
|
||||
column.name
|
||||
for column in CircuitGroupAssignmentTable(assignment).columns
|
||||
if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get('/')
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
table = CircuitGroupAssignmentTable(assignment)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
|
||||
0
netbox/circuits/ui/__init__.py
Normal file
0
netbox/circuits/ui/__init__.py
Normal file
139
netbox/circuits/ui/panels.py
Normal file
139
netbox/circuits/ui/panels.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import actions, attrs, panels
|
||||
from utilities.data import resolve_attr_path
|
||||
|
||||
|
||||
class CircuitCircuitTerminationPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel showing the CircuitTermination assigned to the object.
|
||||
"""
|
||||
|
||||
template_name = 'circuits/panels/circuit_circuit_termination.html'
|
||||
title = _('Termination')
|
||||
|
||||
def __init__(self, accessor=None, side=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
if accessor is not None:
|
||||
self.accessor = accessor
|
||||
if side is not None:
|
||||
self.side = side
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'side': self.side,
|
||||
'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'),
|
||||
}
|
||||
|
||||
|
||||
class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
|
||||
"""
|
||||
A panel showing all Circuit Groups attached to the object.
|
||||
"""
|
||||
|
||||
title = _('Group Assignments')
|
||||
actions = [
|
||||
actions.AddObject(
|
||||
'circuits.CircuitGroupAssignment',
|
||||
url_params={
|
||||
'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
|
||||
'member': lambda ctx: ctx['object'].pk,
|
||||
'return_url': lambda ctx: ctx['object'].get_absolute_url(),
|
||||
},
|
||||
label=_('Assign Group'),
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
'circuits.CircuitGroupAssignment',
|
||||
filters={
|
||||
'member_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
|
||||
'member_id': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CircuitGroupPanel(panels.OrganizationalObjectPanel):
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
|
||||
|
||||
class CircuitGroupAssignmentPanel(panels.ObjectAttributesPanel):
|
||||
group = attrs.RelatedObjectAttr('group', linkify=True)
|
||||
provider = attrs.RelatedObjectAttr('member.provider', linkify=True)
|
||||
member = attrs.GenericForeignKeyAttr('member', linkify=True)
|
||||
priority = attrs.ChoiceAttr('priority')
|
||||
|
||||
|
||||
class CircuitPanel(panels.ObjectAttributesPanel):
|
||||
provider = attrs.RelatedObjectAttr('provider', linkify=True)
|
||||
provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
|
||||
cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
|
||||
type = attrs.RelatedObjectAttr('type', linkify=True)
|
||||
status = attrs.ChoiceAttr('status')
|
||||
distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
install_date = attrs.DateTimeAttr('install_date', spec='date')
|
||||
termination_date = attrs.DateTimeAttr('termination_date', spec='date')
|
||||
commit_rate = attrs.TemplatedAttr('commit_rate', template_name='circuits/circuit/attrs/commit_rate.html')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class CircuitTypePanel(panels.OrganizationalObjectPanel):
|
||||
color = attrs.ColorAttr('color')
|
||||
|
||||
|
||||
class ProviderPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
asns = attrs.RelatedObjectListAttr('asns', linkify=True, label=_('ASNs'))
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ProviderAccountPanel(panels.ObjectAttributesPanel):
|
||||
provider = attrs.RelatedObjectAttr('provider', linkify=True)
|
||||
account = attrs.TextAttr('account', style='font-monospace', copy_button=True)
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ProviderNetworkPanel(panels.ObjectAttributesPanel):
|
||||
provider = attrs.RelatedObjectAttr('provider', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
service_id = attrs.TextAttr('service_id', label=_('Service ID'), style='font-monospace', copy_button=True)
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class VirtualCircuitTypePanel(panels.OrganizationalObjectPanel):
|
||||
color = attrs.ColorAttr('color')
|
||||
|
||||
|
||||
class VirtualCircuitPanel(panels.ObjectAttributesPanel):
|
||||
provider = attrs.RelatedObjectAttr('provider', linkify=True)
|
||||
provider_network = attrs.RelatedObjectAttr('provider_network', linkify=True)
|
||||
provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
|
||||
cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
|
||||
type = attrs.RelatedObjectAttr('type', linkify=True)
|
||||
status = attrs.ChoiceAttr('status')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class VirtualCircuitTerminationPanel(panels.ObjectAttributesPanel):
|
||||
provider = attrs.RelatedObjectAttr('virtual_circuit.provider', linkify=True)
|
||||
provider_network = attrs.RelatedObjectAttr('virtual_circuit.provider_network', linkify=True)
|
||||
provider_account = attrs.RelatedObjectAttr('virtual_circuit.provider_account', linkify=True)
|
||||
virtual_circuit = attrs.RelatedObjectAttr('virtual_circuit', linkify=True)
|
||||
role = attrs.ChoiceAttr('role')
|
||||
|
||||
|
||||
class VirtualCircuitTerminationInterfacePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Interface')
|
||||
|
||||
device = attrs.RelatedObjectAttr('interface.device', linkify=True)
|
||||
interface = attrs.RelatedObjectAttr('interface', linkify=True)
|
||||
type = attrs.ChoiceAttr('interface.type')
|
||||
description = attrs.TextAttr('interface.description')
|
||||
@@ -1,13 +1,23 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
|
||||
from ipam.models import ASN
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||
from netbox.ui import actions, layout
|
||||
from netbox.ui.panels import (
|
||||
CommentsPanel,
|
||||
ObjectsTablePanel,
|
||||
Panel,
|
||||
RelatedObjectsPanel,
|
||||
)
|
||||
from netbox.views import generic
|
||||
from utilities.query import count_related
|
||||
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
from .ui import panels
|
||||
|
||||
#
|
||||
# Providers
|
||||
@@ -29,6 +39,37 @@ class ProviderListView(generic.ObjectListView):
|
||||
@register_model_view(Provider)
|
||||
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Provider.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ProviderPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='circuits.ProviderAccount',
|
||||
filters={'provider_id': lambda ctx: ctx['object'].pk},
|
||||
exclude_columns=['provider'],
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
model='circuits.Circuit',
|
||||
filters={'provider_id': lambda ctx: ctx['object'].pk},
|
||||
exclude_columns=['provider'],
|
||||
actions=[
|
||||
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -44,7 +85,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'provider_id',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +149,33 @@ class ProviderAccountListView(generic.ObjectListView):
|
||||
@register_model_view(ProviderAccount)
|
||||
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ProviderAccountPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='circuits.Circuit',
|
||||
filters={'provider_account_id': lambda ctx: ctx['object'].pk},
|
||||
exclude_columns=['provider_account'],
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'circuits.Circuit',
|
||||
url_params={
|
||||
'provider': lambda ctx: ctx['object'].provider.pk,
|
||||
'provider_account': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -174,6 +242,33 @@ class ProviderNetworkListView(generic.ObjectListView):
|
||||
@register_model_view(ProviderNetwork)
|
||||
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ProviderNetworkPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='circuits.Circuit',
|
||||
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
model='circuits.VirtualCircuit',
|
||||
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
|
||||
exclude_columns=['provider_network'],
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -251,6 +346,17 @@ class CircuitTypeListView(generic.ObjectListView):
|
||||
@register_model_view(CircuitType)
|
||||
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = CircuitType.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CircuitTypePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -318,6 +424,20 @@ class CircuitListView(generic.ObjectListView):
|
||||
@register_model_view(Circuit)
|
||||
class CircuitView(generic.ObjectView):
|
||||
queryset = Circuit.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CircuitPanel(),
|
||||
panels.CircuitGroupAssignmentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.CircuitCircuitTerminationPanel(side='A'),
|
||||
panels.CircuitCircuitTerminationPanel(side='Z'),
|
||||
ImageAttachmentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Circuit, 'add', detail=False)
|
||||
@@ -390,6 +510,18 @@ class CircuitTerminationListView(generic.ObjectListView):
|
||||
@register_model_view(CircuitTermination)
|
||||
class CircuitTerminationView(generic.ObjectView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
Panel(
|
||||
template_name='circuits/panels/circuit_termination.html',
|
||||
title=_('Circuit Termination'),
|
||||
)
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(CircuitTermination, 'add', detail=False)
|
||||
@@ -446,6 +578,17 @@ class CircuitGroupListView(generic.ObjectListView):
|
||||
@register_model_view(CircuitGroup)
|
||||
class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = CircuitGroup.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CircuitGroupPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -508,6 +651,15 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
|
||||
@register_model_view(CircuitGroupAssignment)
|
||||
class CircuitGroupAssignmentView(generic.ObjectView):
|
||||
queryset = CircuitGroupAssignment.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CircuitGroupAssignmentPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(CircuitGroupAssignment, 'add', detail=False)
|
||||
@@ -560,6 +712,17 @@ class VirtualCircuitTypeListView(generic.ObjectListView):
|
||||
@register_model_view(VirtualCircuitType)
|
||||
class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VirtualCircuitType.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VirtualCircuitTypePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -627,6 +790,31 @@ class VirtualCircuitListView(generic.ObjectListView):
|
||||
@register_model_view(VirtualCircuit)
|
||||
class VirtualCircuitView(generic.ObjectView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VirtualCircuitPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
panels.CircuitGroupAssignmentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='circuits.VirtualCircuitTermination',
|
||||
title=_('Terminations'),
|
||||
filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
|
||||
exclude_columns=['virtual_circuit'],
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'circuits.VirtualCircuitTermination',
|
||||
url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'add', detail=False)
|
||||
@@ -698,6 +886,16 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
|
||||
@register_model_view(VirtualCircuitTermination)
|
||||
class VirtualCircuitTerminationView(generic.ObjectView):
|
||||
queryset = VirtualCircuitTermination.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VirtualCircuitTerminationPanel(),
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.VirtualCircuitTerminationInterfacePanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuitTermination, 'edit')
|
||||
|
||||
@@ -165,9 +165,10 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
|
||||
FieldSet('PAGINATE_COUNT', 'MAX_PAGE_SIZE', name=_('Pagination')),
|
||||
FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')),
|
||||
FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')),
|
||||
FieldSet('CHANGELOG_RETENTION', 'CHANGELOG_RETAIN_CREATE_LAST_UPDATE', name=_('Change Log')),
|
||||
FieldSet(
|
||||
'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION',
|
||||
'MAPS_URL', name=_('Miscellaneous'),
|
||||
'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'JOB_RETENTION', 'MAPS_URL',
|
||||
name=_('Miscellaneous'),
|
||||
),
|
||||
FieldSet('comment', name=_('Config Revision'))
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from importlib import import_module
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Exists, OuterRef, Subquery
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
@@ -14,7 +15,7 @@ from netbox.jobs import JobRunner, system_job
|
||||
from netbox.search.backends import search_backend
|
||||
from utilities.proxy import resolve_proxies
|
||||
|
||||
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
||||
from .choices import DataSourceStatusChoices, JobIntervalChoices, ObjectChangeActionChoices
|
||||
from .models import DataSource
|
||||
|
||||
|
||||
@@ -126,19 +127,51 @@ class SystemHousekeepingJob(JobRunner):
|
||||
"""
|
||||
Delete any ObjectChange records older than the configured changelog retention time (if any).
|
||||
"""
|
||||
self.logger.info("Pruning old changelog entries...")
|
||||
self.logger.info('Pruning old changelog entries...')
|
||||
config = Config()
|
||||
if not config.CHANGELOG_RETENTION:
|
||||
self.logger.info("No retention period specified; skipping.")
|
||||
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})"
|
||||
)
|
||||
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")
|
||||
expired_qs = ObjectChange.objects.filter(time__lt=cutoff)
|
||||
|
||||
# When enabled, retain each object's original create record and most recent update record while pruning expired
|
||||
# changelog entries. This applies only to objects without a delete record.
|
||||
if config.CHANGELOG_RETAIN_CREATE_LAST_UPDATE:
|
||||
self.logger.debug('Retaining changelog create records and last update records (excluding deleted objects)')
|
||||
|
||||
deleted_exists = ObjectChange.objects.filter(
|
||||
action=ObjectChangeActionChoices.ACTION_DELETE,
|
||||
changed_object_type_id=OuterRef('changed_object_type_id'),
|
||||
changed_object_id=OuterRef('changed_object_id'),
|
||||
)
|
||||
|
||||
# Keep create records only where no delete exists for that object
|
||||
create_pks_to_keep = (
|
||||
ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE)
|
||||
.annotate(has_delete=Exists(deleted_exists))
|
||||
.filter(has_delete=False)
|
||||
.values('pk')
|
||||
)
|
||||
|
||||
# Keep the most recent update per object only where no delete exists for the object
|
||||
latest_update_pks_to_keep = (
|
||||
ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
.annotate(has_delete=Exists(deleted_exists))
|
||||
.filter(has_delete=False)
|
||||
.order_by('changed_object_type_id', 'changed_object_id', '-time', '-pk')
|
||||
.distinct('changed_object_type_id', 'changed_object_id')
|
||||
.values('pk')
|
||||
)
|
||||
|
||||
expired_qs = expired_qs.exclude(pk__in=Subquery(create_pks_to_keep))
|
||||
expired_qs = expired_qs.exclude(pk__in=Subquery(latest_update_pks_to_keep))
|
||||
|
||||
count = expired_qs.delete()[0]
|
||||
self.logger.info(f'Deleted {count} expired changelog records')
|
||||
|
||||
def delete_expired_jobs(self):
|
||||
"""
|
||||
|
||||
@@ -11,7 +11,7 @@ from mptt.models import MPTTModel
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.querysets import ObjectChangeQuerySet
|
||||
from netbox.models.features import ChangeLoggingMixin, has_feature
|
||||
from utilities.data import shallow_compare_dict
|
||||
from utilities.data import deep_compare_dict
|
||||
|
||||
__all__ = (
|
||||
'ObjectChange',
|
||||
@@ -199,18 +199,18 @@ class ObjectChange(models.Model):
|
||||
# Determine which attributes have changed
|
||||
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
changed_attrs = sorted(postchange_data.keys())
|
||||
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||
return {
|
||||
'pre': {k: prechange_data.get(k) for k in changed_attrs},
|
||||
'post': {k: postchange_data.get(k) for k in changed_attrs},
|
||||
}
|
||||
if self.action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||
changed_attrs = sorted(prechange_data.keys())
|
||||
else:
|
||||
# TODO: Support deep (recursive) comparison
|
||||
changed_data = shallow_compare_dict(prechange_data, postchange_data)
|
||||
changed_attrs = sorted(changed_data.keys())
|
||||
|
||||
return {
|
||||
'pre': {k: prechange_data.get(k) for k in changed_attrs},
|
||||
'post': {k: postchange_data.get(k) for k in changed_attrs},
|
||||
}
|
||||
diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data)
|
||||
return {
|
||||
'pre': {
|
||||
k: prechange_data.get(k) for k in changed_attrs
|
||||
},
|
||||
'post': {
|
||||
k: postchange_data.get(k) for k in changed_attrs
|
||||
},
|
||||
'pre': dict(sorted(diff_removed.items())),
|
||||
'post': dict(sorted(diff_added.items())),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.constants import JOB_LOG_ENTRY_LEVELS
|
||||
@@ -82,3 +84,9 @@ class JobLogEntryTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
empty_text = _('No log entries')
|
||||
fields = ('timestamp', 'level', 'message')
|
||||
|
||||
def render_message(self, record, value):
|
||||
if record.get('level') == 'error' and '\n' in value:
|
||||
value = conditional_escape(value)
|
||||
return mark_safe(f'<pre class="p-0">{value}</pre>')
|
||||
return value
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.jobs import SystemHousekeepingJob
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
|
||||
from dcim.models import (
|
||||
@@ -694,3 +701,99 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
|
||||
self.assertEqual(changes[3].changed_object_id, module.pk)
|
||||
self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
|
||||
|
||||
class ChangelogPruneRetentionTest(TestCase):
|
||||
"""Test suite for Changelog pruning retention settings."""
|
||||
|
||||
@staticmethod
|
||||
def _make_oc(*, ct, obj_id, action, ts):
|
||||
oc = ObjectChange.objects.create(
|
||||
changed_object_type=ct,
|
||||
changed_object_id=obj_id,
|
||||
action=action,
|
||||
user_name='test',
|
||||
request_id=uuid.uuid4(),
|
||||
object_repr=f'Object {obj_id}',
|
||||
)
|
||||
ObjectChange.objects.filter(pk=oc.pk).update(time=ts)
|
||||
return oc.pk
|
||||
|
||||
@staticmethod
|
||||
def _run_prune(*, retention_days, retain_create_last_update):
|
||||
job = SystemHousekeepingJob.__new__(SystemHousekeepingJob)
|
||||
job.logger = logging.getLogger('netbox.tests.changelog_prune')
|
||||
|
||||
with patch('core.jobs.Config') as MockConfig:
|
||||
cfg = MockConfig.return_value
|
||||
cfg.CHANGELOG_RETENTION = retention_days
|
||||
cfg.CHANGELOG_RETAIN_CREATE_LAST_UPDATE = retain_create_last_update
|
||||
job.prune_changelog()
|
||||
|
||||
def test_prune_retain_create_last_update_excludes_deleted_objects(self):
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
|
||||
retention_days = 90
|
||||
now = timezone.now()
|
||||
cutoff = now - timedelta(days=retention_days)
|
||||
|
||||
expired_old = cutoff - timedelta(days=10)
|
||||
expired_newer = cutoff - timedelta(days=1)
|
||||
not_expired = cutoff + timedelta(days=1)
|
||||
|
||||
# A) Not deleted: should keep CREATE + latest UPDATE, prune intermediate UPDATEs
|
||||
a_create = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
|
||||
a_update1 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_old)
|
||||
a_update2 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
|
||||
|
||||
# B) Deleted (all expired): should keep NOTHING
|
||||
b_create = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
|
||||
b_update = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
|
||||
b_delete = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_DELETE, ts=expired_newer)
|
||||
|
||||
# C) Deleted but delete is not expired: create/update expired should be pruned; delete remains
|
||||
c_create = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
|
||||
c_update = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
|
||||
c_delete = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
|
||||
|
||||
self._run_prune(retention_days=retention_days, retain_create_last_update=True)
|
||||
|
||||
remaining = set(ObjectChange.objects.values_list('pk', flat=True))
|
||||
|
||||
# A) Not deleted -> create + latest update remain
|
||||
self.assertIn(a_create, remaining)
|
||||
self.assertIn(a_update2, remaining)
|
||||
self.assertNotIn(a_update1, remaining)
|
||||
|
||||
# B) Deleted (all expired) -> nothing remains
|
||||
self.assertNotIn(b_create, remaining)
|
||||
self.assertNotIn(b_update, remaining)
|
||||
self.assertNotIn(b_delete, remaining)
|
||||
|
||||
# C) Deleted, delete not expired -> delete remains, but create/update are pruned
|
||||
self.assertNotIn(c_create, remaining)
|
||||
self.assertNotIn(c_update, remaining)
|
||||
self.assertIn(c_delete, remaining)
|
||||
|
||||
def test_prune_disabled_deletes_all_expired(self):
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
|
||||
retention_days = 90
|
||||
now = timezone.now()
|
||||
cutoff = now - timedelta(days=retention_days)
|
||||
expired = cutoff - timedelta(days=1)
|
||||
not_expired = cutoff + timedelta(days=1)
|
||||
|
||||
# expired create/update should be deleted when feature disabled
|
||||
x_create = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired)
|
||||
x_update = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired)
|
||||
|
||||
# non-expired delete should remain regardless
|
||||
y_delete = self._make_oc(ct=ct, obj_id=11, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
|
||||
|
||||
self._run_prune(retention_days=retention_days, retain_create_last_update=False)
|
||||
|
||||
remaining = set(ObjectChange.objects.values_list('pk', flat=True))
|
||||
self.assertNotIn(x_create, remaining)
|
||||
self.assertNotIn(x_update, remaining)
|
||||
self.assertIn(y_delete, remaining)
|
||||
|
||||
0
netbox/core/ui/__init__.py
Normal file
0
netbox/core/ui/__init__.py
Normal file
91
netbox/core/ui/panels.py
Normal file
91
netbox/core/ui/panels.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import attrs, panels
|
||||
|
||||
|
||||
class DataSourcePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Data Source')
|
||||
name = attrs.TextAttr('name')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
sync_interval = attrs.ChoiceAttr('sync_interval', label=_('Sync interval'))
|
||||
last_synced = attrs.DateTimeAttr('last_synced', label=_('Last synced'))
|
||||
description = attrs.TextAttr('description')
|
||||
source_url = attrs.TemplatedAttr(
|
||||
'source_url',
|
||||
label=_('URL'),
|
||||
template_name='core/datasource/attrs/source_url.html',
|
||||
)
|
||||
ignore_rules = attrs.TemplatedAttr(
|
||||
'ignore_rules',
|
||||
label=_('Ignore rules'),
|
||||
template_name='core/datasource/attrs/ignore_rules.html',
|
||||
)
|
||||
|
||||
|
||||
class DataSourceBackendPanel(panels.ObjectPanel):
|
||||
template_name = 'core/panels/datasource_backend.html'
|
||||
title = _('Backend')
|
||||
|
||||
|
||||
class DataFilePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Data File')
|
||||
source = attrs.RelatedObjectAttr('source', linkify=True)
|
||||
path = attrs.TextAttr('path', style='font-monospace', copy_button=True)
|
||||
last_updated = attrs.DateTimeAttr('last_updated')
|
||||
size = attrs.TemplatedAttr('size', template_name='core/datafile/attrs/size.html')
|
||||
hash = attrs.TextAttr('hash', label=_('SHA256 hash'), style='font-monospace', copy_button=True)
|
||||
|
||||
|
||||
class DataFileContentPanel(panels.ObjectPanel):
|
||||
template_name = 'core/panels/datafile_content.html'
|
||||
title = _('Content')
|
||||
|
||||
|
||||
class JobPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Job')
|
||||
object_type = attrs.TemplatedAttr(
|
||||
'object_type',
|
||||
label=_('Object type'),
|
||||
template_name='core/job/attrs/object_type.html',
|
||||
)
|
||||
name = attrs.TextAttr('name')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
error = attrs.TextAttr('error')
|
||||
user = attrs.TextAttr('user', label=_('Created by'))
|
||||
|
||||
|
||||
class JobSchedulingPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Scheduling')
|
||||
created = attrs.DateTimeAttr('created')
|
||||
scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
|
||||
started = attrs.DateTimeAttr('started')
|
||||
completed = attrs.DateTimeAttr('completed')
|
||||
queue = attrs.TextAttr('queue_name', label=_('Queue'))
|
||||
|
||||
|
||||
class ObjectChangePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Change')
|
||||
time = attrs.DateTimeAttr('time')
|
||||
user = attrs.TemplatedAttr(
|
||||
'user_name',
|
||||
label=_('User'),
|
||||
template_name='core/objectchange/attrs/user.html',
|
||||
)
|
||||
action = attrs.ChoiceAttr('action')
|
||||
changed_object_type = attrs.TextAttr(
|
||||
'changed_object_type',
|
||||
label=_('Object type'),
|
||||
)
|
||||
changed_object = attrs.TemplatedAttr(
|
||||
'object_repr',
|
||||
label=_('Object'),
|
||||
template_name='core/objectchange/attrs/changed_object.html',
|
||||
)
|
||||
message = attrs.TextAttr('message')
|
||||
request_id = attrs.TemplatedAttr(
|
||||
'request_id',
|
||||
label=_('Request ID'),
|
||||
template_name='core/objectchange/attrs/request_id.html',
|
||||
)
|
||||
@@ -23,14 +23,25 @@ from rq.worker import Worker
|
||||
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 extras.ui.panels import CustomFieldsPanel, TagsPanel
|
||||
from netbox.config import PARAMS, get_config
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
||||
from netbox.plugins.utils import get_installed_plugins
|
||||
from netbox.ui import layout
|
||||
from netbox.ui.panels import (
|
||||
CommentsPanel,
|
||||
ContextTablePanel,
|
||||
JSONPanel,
|
||||
ObjectsTablePanel,
|
||||
PluginContentPanel,
|
||||
RelatedObjectsPanel,
|
||||
TemplatePanel,
|
||||
)
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.apps import get_installed_apps
|
||||
from utilities.data import shallow_compare_dict
|
||||
from utilities.data import deep_compare_dict
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.json import ConfigJSONEncoder
|
||||
@@ -48,6 +59,7 @@ from .jobs import SyncDataSourceJob
|
||||
from .models import *
|
||||
from .plugins import get_catalog_plugins, get_local_plugins
|
||||
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
|
||||
from .ui import panels
|
||||
|
||||
#
|
||||
# Data sources
|
||||
@@ -67,6 +79,25 @@ class DataSourceListView(generic.ObjectListView):
|
||||
@register_model_view(DataSource)
|
||||
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = DataSource.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.DataSourcePanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.DataSourceBackendPanel(),
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='core.DataFile',
|
||||
filters={'source_id': lambda ctx: ctx['object'].pk},
|
||||
exclude_columns=['source'],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -157,6 +188,14 @@ class DataFileListView(generic.ObjectListView):
|
||||
class DataFileView(generic.ObjectView):
|
||||
queryset = DataFile.objects.all()
|
||||
actions = (DeleteObject,)
|
||||
layout = layout.Layout(
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
panels.DataFilePanel(),
|
||||
panels.DataFileContentPanel(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(DataFile, 'delete')
|
||||
@@ -188,6 +227,17 @@ class JobListView(generic.ObjectListView):
|
||||
class JobView(generic.ObjectView):
|
||||
queryset = Job.objects.all()
|
||||
actions = (DeleteObject,)
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.JobPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.JobSchedulingPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
JSONPanel('data', title=_('Data')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Job, 'log')
|
||||
@@ -200,6 +250,13 @@ class JobLogView(generic.ObjectView):
|
||||
badge=lambda obj: len(obj.log_entries),
|
||||
weight=500,
|
||||
)
|
||||
layout = layout.Layout(
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
ContextTablePanel('table', title=_('Log Entries')),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
table = JobLogEntryTable(instance.log_entries)
|
||||
@@ -241,6 +298,26 @@ class ObjectChangeListView(generic.ObjectListView):
|
||||
@register_model_view(ObjectChange)
|
||||
class ObjectChangeView(generic.ObjectView):
|
||||
queryset = None
|
||||
layout = layout.Layout(
|
||||
layout.Row(
|
||||
layout.Column(panels.ObjectChangePanel()),
|
||||
layout.Column(TemplatePanel('core/panels/objectchange_difference.html')),
|
||||
),
|
||||
layout.Row(
|
||||
layout.Column(TemplatePanel('core/panels/objectchange_prechange.html')),
|
||||
layout.Column(TemplatePanel('core/panels/objectchange_postchange.html')),
|
||||
),
|
||||
layout.Row(
|
||||
layout.Column(PluginContentPanel('left_page')),
|
||||
layout.Column(PluginContentPanel('right_page')),
|
||||
),
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
TemplatePanel('core/panels/objectchange_related.html'),
|
||||
PluginContentPanel('full_width_page'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return ObjectChange.objects.valid_models()
|
||||
@@ -273,17 +350,14 @@ class ObjectChangeView(generic.ObjectView):
|
||||
prechange_data = instance.prechange_data_clean
|
||||
|
||||
if prechange_data and instance.postchange_data:
|
||||
diff_added = shallow_compare_dict(
|
||||
prechange_data or dict(),
|
||||
instance.postchange_data_clean or dict(),
|
||||
diff_added, diff_removed = deep_compare_dict(
|
||||
prechange_data,
|
||||
instance.postchange_data_clean,
|
||||
exclude=['last_updated'],
|
||||
)
|
||||
diff_removed = {
|
||||
x: prechange_data.get(x) for x in diff_added
|
||||
} if prechange_data else {}
|
||||
else:
|
||||
diff_added = None
|
||||
diff_removed = None
|
||||
diff_added = {}
|
||||
diff_removed = {}
|
||||
|
||||
return {
|
||||
'diff_added': diff_added,
|
||||
@@ -312,6 +386,14 @@ class ConfigRevisionListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigRevision)
|
||||
class ConfigRevisionView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
layout = layout.Layout(
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
TemplatePanel('core/panels/configrevision_data.html'),
|
||||
TemplatePanel('core/panels/configrevision_comment.html'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.models import Cable, CablePath, CableTermination
|
||||
from dcim.models import Cable, CableBundle, CablePath, CableTermination
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||
from netbox.api.gfk_fields import GFKSerializerField
|
||||
from netbox.api.serializers import (
|
||||
@@ -16,6 +16,7 @@ from tenancy.api.serializers_.tenants import TenantSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
__all__ = (
|
||||
'CableBundleSerializer',
|
||||
'CablePathSerializer',
|
||||
'CableSerializer',
|
||||
'CableTerminationSerializer',
|
||||
@@ -24,6 +25,18 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CableBundleSerializer(PrimaryModelSerializer):
|
||||
cable_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta:
|
||||
model = CableBundle
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'cable_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class CableSerializer(PrimaryModelSerializer):
|
||||
a_terminations = GenericObjectSerializer(many=True, required=False)
|
||||
b_terminations = GenericObjectSerializer(many=True, required=False)
|
||||
@@ -31,12 +44,13 @@ class CableSerializer(PrimaryModelSerializer):
|
||||
profile = ChoiceField(choices=CableProfileChoices, required=False)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||
bundle = CableBundleSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
|
||||
'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
||||
'tenant', 'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'label', 'description')
|
||||
|
||||
@@ -423,27 +423,29 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
|
||||
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled',
|
||||
'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
|
||||
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied')
|
||||
|
||||
|
||||
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'enabled', 'description',
|
||||
'installed_device', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'enabled', 'description', '_occupied',)
|
||||
|
||||
|
||||
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
|
||||
@@ -6,7 +6,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
|
||||
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
|
||||
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
|
||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||
from ipam.api.serializers_.ip import IPAddressSerializer
|
||||
@@ -150,15 +150,145 @@ class ModuleSerializer(PrimaryModelSerializer):
|
||||
module_bay = NestedModuleBaySerializer()
|
||||
module_type = ModuleTypeSerializer(nested=True)
|
||||
status = ChoiceField(choices=ModuleStatusChoices, required=False)
|
||||
replicate_components = serializers.BooleanField(
|
||||
required=False,
|
||||
default=True,
|
||||
write_only=True,
|
||||
label=_('Replicate components'),
|
||||
help_text=_('Automatically populate components associated with this module type (default: true)')
|
||||
)
|
||||
adopt_components = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
write_only=True,
|
||||
label=_('Adopt components'),
|
||||
help_text=_('Adopt already existing components')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
|
||||
'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'replicate_components', 'adopt_components',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
|
||||
|
||||
def validate(self, data):
|
||||
# When used as a nested serializer (e.g. as the `module` field on device component
|
||||
# serializers), `data` is already a resolved Module instance — skip our custom logic.
|
||||
if self.nested:
|
||||
return super().validate(data)
|
||||
|
||||
# Pop write-only transient fields before ValidatedModelSerializer tries to
|
||||
# construct a Module instance for full_clean(); restore them afterwards.
|
||||
replicate_components = data.pop('replicate_components', True)
|
||||
adopt_components = data.pop('adopt_components', False)
|
||||
data = super().validate(data)
|
||||
|
||||
# For updates these fields are not meaningful; omit them from validated_data so that
|
||||
# ModelSerializer.update() does not set unexpected attributes on the instance.
|
||||
if self.instance:
|
||||
return data
|
||||
|
||||
# Always pass the flags to create() so it can set the correct private attributes.
|
||||
data['replicate_components'] = replicate_components
|
||||
data['adopt_components'] = adopt_components
|
||||
|
||||
# Skip conflict checks when no component operations are requested.
|
||||
if not replicate_components and not adopt_components:
|
||||
return data
|
||||
|
||||
device = data.get('device')
|
||||
module_type = data.get('module_type')
|
||||
module_bay = data.get('module_bay')
|
||||
|
||||
# Required-field validation fires separately; skip here if any are missing.
|
||||
if not all([device, module_type, module_bay]):
|
||||
return data
|
||||
|
||||
# Build module bay tree for MODULE_TOKEN placeholder resolution (outermost to innermost)
|
||||
module_bays = []
|
||||
current_bay = module_bay
|
||||
while current_bay:
|
||||
module_bays.append(current_bay)
|
||||
current_bay = current_bay.module.module_bay if current_bay.module else None
|
||||
module_bays.reverse()
|
||||
|
||||
for templates_attr, component_attr in [
|
||||
('consoleporttemplates', 'consoleports'),
|
||||
('consoleserverporttemplates', 'consoleserverports'),
|
||||
('interfacetemplates', 'interfaces'),
|
||||
('powerporttemplates', 'powerports'),
|
||||
('poweroutlettemplates', 'poweroutlets'),
|
||||
('rearporttemplates', 'rearports'),
|
||||
('frontporttemplates', 'frontports'),
|
||||
]:
|
||||
installed_components = {
|
||||
component.name: component
|
||||
for component in getattr(device, component_attr).all()
|
||||
}
|
||||
|
||||
for template in getattr(module_type, templates_attr).all():
|
||||
resolved_name = template.name
|
||||
if MODULE_TOKEN in template.name:
|
||||
if not module_bay.position:
|
||||
raise serializers.ValidationError(
|
||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||
)
|
||||
if template.name.count(MODULE_TOKEN) != len(module_bays):
|
||||
raise serializers.ValidationError(
|
||||
_(
|
||||
"Cannot install module with placeholder values in a module bay tree {level} in tree "
|
||||
"but {tokens} placeholders given."
|
||||
).format(
|
||||
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
|
||||
)
|
||||
)
|
||||
for bay in module_bays:
|
||||
resolved_name = resolved_name.replace(MODULE_TOKEN, bay.position, 1)
|
||||
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise serializers.ValidationError(
|
||||
_("Cannot adopt {model} {name} as it already belongs to a module").format(
|
||||
model=template.component_model.__name__,
|
||||
name=resolved_name
|
||||
)
|
||||
)
|
||||
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise serializers.ValidationError(
|
||||
_("A {model} named {name} already exists").format(
|
||||
model=template.component_model.__name__,
|
||||
name=resolved_name
|
||||
)
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
replicate_components = validated_data.pop('replicate_components', True)
|
||||
adopt_components = validated_data.pop('adopt_components', False)
|
||||
|
||||
# Tags are handled after save; pop them here to pass to _save_tags()
|
||||
tags = validated_data.pop('tags', None)
|
||||
|
||||
# _adopt_components and _disable_replication must be set on the instance before
|
||||
# save() is called, so we cannot delegate to super().create() here.
|
||||
instance = self.Meta.model(**validated_data)
|
||||
if adopt_components:
|
||||
instance._adopt_components = True
|
||||
if not replicate_components:
|
||||
instance._disable_replication = True
|
||||
instance.save()
|
||||
|
||||
if tags is not None:
|
||||
self._save_tags(instance, tags)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class MACAddressSerializer(PrimaryModelSerializer):
|
||||
assigned_object_type = ContentTypeField(
|
||||
|
||||
@@ -317,10 +317,10 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
|
||||
class Meta:
|
||||
model = ModuleBayTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
|
||||
|
||||
|
||||
class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
|
||||
@@ -331,10 +331,10 @@ class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'description',
|
||||
'id', 'url', 'display', 'device_type', 'name', 'label', 'enabled', 'description',
|
||||
'created', 'last_updated'
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
|
||||
|
||||
|
||||
class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework import serializers
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import Rack, RackReservation, RackRole, RackType
|
||||
from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
|
||||
from netbox.api.fields import ChoiceField, RelatedObjectCountField
|
||||
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
|
||||
from netbox.choices import *
|
||||
@@ -16,6 +16,7 @@ from .sites import LocationSerializer, SiteSerializer
|
||||
|
||||
__all__ = (
|
||||
'RackElevationDetailFilterSerializer',
|
||||
'RackGroupSerializer',
|
||||
'RackReservationSerializer',
|
||||
'RackRoleSerializer',
|
||||
'RackSerializer',
|
||||
@@ -23,6 +24,20 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class RackGroupSerializer(OrganizationalModelSerializer):
|
||||
|
||||
# Related object counts
|
||||
rack_count = RelatedObjectCountField('racks')
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'rack_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
|
||||
|
||||
|
||||
class RackRoleSerializer(OrganizationalModelSerializer):
|
||||
|
||||
# Related object counts
|
||||
@@ -87,6 +102,11 @@ class RackSerializer(RackBaseSerializer):
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
group = RackGroupSerializer(
|
||||
nested=True,
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
tenant = TenantSerializer(
|
||||
nested=True,
|
||||
required=False,
|
||||
@@ -127,11 +147,11 @@ class RackSerializer(RackBaseSerializer):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
|
||||
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
|
||||
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
|
||||
'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'device_count', 'powerfeed_count',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'group', 'tenant',
|
||||
'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit',
|
||||
'weight', 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
|
||||
|
||||
@@ -153,11 +173,16 @@ class RackReservationSerializer(PrimaryModelSerializer):
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
unit_count = serializers.SerializerMethodField()
|
||||
|
||||
def get_unit_count(self, obj):
|
||||
return len(obj.units)
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
|
||||
'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
|
||||
'id', 'url', 'display_url', 'display', 'rack', 'units', 'unit_count', 'status', 'created', 'last_updated',
|
||||
'user', 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ router.register('sites', views.SiteViewSet)
|
||||
|
||||
# Racks
|
||||
router.register('locations', views.LocationViewSet)
|
||||
router.register('rack-groups', views.RackGroupViewSet)
|
||||
router.register('rack-types', views.RackTypeViewSet)
|
||||
router.register('rack-roles', views.RackRoleViewSet)
|
||||
router.register('racks', views.RackViewSet)
|
||||
@@ -63,6 +64,7 @@ router.register('mac-addresses', views.MACAddressViewSet)
|
||||
# Cables
|
||||
router.register('cables', views.CableViewSet)
|
||||
router.register('cable-terminations', views.CableTerminationViewSet)
|
||||
router.register('cable-bundles', views.CableBundleViewSet)
|
||||
|
||||
# Virtual chassis
|
||||
router.register('virtual-chassis', views.VirtualChassisViewSet)
|
||||
|
||||
@@ -19,6 +19,7 @@ from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.query import count_related
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
@@ -154,6 +155,17 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
filterset_class = filtersets.LocationFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
|
||||
class RackGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = RackGroup.objects.all()
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
filterset_class = filtersets.RackGroupFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
@@ -574,6 +586,14 @@ class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
class CableBundleViewSet(NetBoxModelViewSet):
|
||||
queryset = CableBundle.objects.annotate(
|
||||
cable_count=count_related(Cable, 'bundle')
|
||||
)
|
||||
serializer_class = serializers.CableBundleSerializer
|
||||
filterset_class = filtersets.CableBundleFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@@ -1003,10 +1003,16 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_800GE_SR8 = '800gbase-sr8'
|
||||
TYPE_800GE_VR8 = '800gbase-vr8'
|
||||
|
||||
# 1.6 Tbps Ethernet
|
||||
TYPE_1TE_CR8 = '1.6tbase-cr8'
|
||||
TYPE_1TE_DR8 = '1.6tbase-dr8'
|
||||
TYPE_1TE_DR8_2 = '1.6tbase-dr8-2'
|
||||
|
||||
# Ethernet (modular)
|
||||
TYPE_100ME_SFP = '100base-x-sfp'
|
||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||
TYPE_2GE_SFP = '2.5gbase-x-sfp'
|
||||
TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
|
||||
TYPE_10GE_XFP = '10gbase-x-xfp'
|
||||
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
|
||||
@@ -1034,8 +1040,11 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
TYPE_800GE_OSFP = '800gbase-x-osfp'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' # TODO: Rename to _QSFP_DD800
|
||||
TYPE_800GE_OSFP = '800gbase-x-osfp' # TODO: Rename to _OSFP800
|
||||
TYPE_1TE_OSFP1600 = '1.6tbase-x-osfp1600'
|
||||
TYPE_1TE_OSFP1600_RHS = '1.6tbase-x-osfp1600-rhs'
|
||||
TYPE_1TE_QSFP_DD1600 = '1.6tbase-x-qsfpdd1600'
|
||||
|
||||
# Backplane Ethernet
|
||||
TYPE_1GE_KX = '1000base-kx'
|
||||
@@ -1049,6 +1058,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100GE_KP4 = '100gbase-kp4'
|
||||
TYPE_100GE_KR2 = '100gbase-kr2'
|
||||
TYPE_100GE_KR4 = '100gbase-kr4'
|
||||
TYPE_1TE_KR8 = '1.6tbase-kr8'
|
||||
|
||||
# Wireless
|
||||
TYPE_80211A = 'ieee802.11a'
|
||||
@@ -1298,12 +1308,21 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_800GE_VR8, '800GBASE-VR8 (800GE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
_('1.6 Tbps Ethernet'),
|
||||
(
|
||||
(TYPE_1TE_CR8, '1.6TBASE-CR8 (1.6TE)'),
|
||||
(TYPE_1TE_DR8, '1.6TBASE-DR8 (1.6TE)'),
|
||||
(TYPE_1TE_DR8_2, '1.6TBASE-DR8-2 (1.6TE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
_('Pluggable transceivers'),
|
||||
(
|
||||
(TYPE_100ME_SFP, 'SFP (100ME)'),
|
||||
(TYPE_1GE_GBIC, 'GBIC (1GE)'),
|
||||
(TYPE_1GE_SFP, 'SFP (1GE)'),
|
||||
(TYPE_2GE_SFP, 'SFP (2.5GE)'),
|
||||
(TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
|
||||
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
|
||||
(TYPE_10GE_XFP, 'XFP (10GE)'),
|
||||
@@ -1333,6 +1352,9 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
(TYPE_1TE_OSFP1600, 'OSFP1600 (1.6TE)'),
|
||||
(TYPE_1TE_OSFP1600_RHS, 'OSFP1600-RHS (1.6TE)'),
|
||||
(TYPE_1TE_QSFP_DD1600, 'QSFP-DD1600 (1.6TE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
@@ -1349,6 +1371,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
|
||||
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
|
||||
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
|
||||
(TYPE_1TE_KR8, '1.6TBASE-KR8 (1.6TE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
@@ -1495,9 +1518,12 @@ class InterfaceSpeedChoices(ChoiceSet):
|
||||
(10000000, '10 Gbps'),
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(50000000, '50 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(200000000, '200 Gbps'),
|
||||
(400000000, '400 Gbps'),
|
||||
(800000000, '800 Gbps'),
|
||||
(1600000000, '1.6 Tbps'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from .choices import InterfaceTypeChoices
|
||||
@@ -79,6 +81,7 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
#
|
||||
|
||||
MODULE_TOKEN = '{module}'
|
||||
VC_POSITION_RE = re.compile(r'\{vc_position(?::([^}]*))?\}')
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
|
||||
app_label='dcim',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import django_filters
|
||||
import netaddr
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Func, IntegerField
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
@@ -45,6 +46,7 @@ from .constants import *
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
'CableBundleFilterSet',
|
||||
'CableFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'CabledObjectFilterSet',
|
||||
@@ -85,6 +87,7 @@ __all__ = (
|
||||
'PowerPortFilterSet',
|
||||
'PowerPortTemplateFilterSet',
|
||||
'RackFilterSet',
|
||||
'RackGroupFilterSet',
|
||||
'RackReservationFilterSet',
|
||||
'RackRoleFilterSet',
|
||||
'RackTypeFilterSet',
|
||||
@@ -312,6 +315,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
|
||||
return queryset
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackGroupFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ('id', 'name', 'slug', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
class RackRoleFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
@@ -416,6 +427,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RackGroup.objects.all(),
|
||||
distinct=False,
|
||||
label=_('Group (ID)'),
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='group__slug',
|
||||
queryset=RackGroup.objects.all(),
|
||||
distinct=False,
|
||||
to_field_name='slug',
|
||||
label=_('Group (slug)'),
|
||||
)
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -550,6 +573,19 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RackGroup.objects.all(),
|
||||
field_name='rack__group',
|
||||
distinct=False,
|
||||
label=_('Group (ID)'),
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack__group__slug',
|
||||
queryset=RackGroup.objects.all(),
|
||||
distinct=False,
|
||||
to_field_name='slug',
|
||||
label=_('Group (slug)'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=RackReservationStatusChoices,
|
||||
distinct=False,
|
||||
@@ -571,11 +607,30 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
field_name='units',
|
||||
lookup_expr='contains'
|
||||
)
|
||||
unit_count_min = django_filters.NumberFilter(
|
||||
field_name='unit_count',
|
||||
lookup_expr='gte',
|
||||
label=_('Minimum unit count'),
|
||||
)
|
||||
unit_count_max = django_filters.NumberFilter(
|
||||
field_name='unit_count',
|
||||
lookup_expr='lte',
|
||||
label=_('Maximum unit count'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ('id', 'created', 'description')
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
# Annotate unit_count here so unit_count_min/unit_count_max filters can reference it.
|
||||
# When called from the list view the queryset is already annotated; Django silently
|
||||
# overwrites a duplicate annotation with the same expression, so this is safe.
|
||||
queryset = queryset.annotate(
|
||||
unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
|
||||
)
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
@@ -994,7 +1049,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
|
||||
class Meta:
|
||||
model = ModuleBayTemplate
|
||||
fields = ('id', 'name', 'label', 'position', 'description')
|
||||
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -1002,7 +1057,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ('id', 'name', 'label', 'description')
|
||||
fields = ('id', 'name', 'label', 'enabled', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -2359,7 +2414,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = ('id', 'name', 'label', 'position', 'description')
|
||||
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -2379,7 +2434,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ('id', 'name', 'label', 'description')
|
||||
fields = ('id', 'name', 'label', 'enabled', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -2532,6 +2587,23 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CableBundleFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CableBundle
|
||||
fields = ('id', 'name', 'description')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
termination_a_type = MultiValueContentTypeFilter(
|
||||
@@ -2552,6 +2624,16 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
method='_unterminated',
|
||||
label=_('Unterminated'),
|
||||
)
|
||||
bundle_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=CableBundle.objects.all(),
|
||||
label=_('Cable bundle (ID)'),
|
||||
)
|
||||
bundle = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='bundle__name',
|
||||
queryset=CableBundle.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Cable bundle (name)'),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices,
|
||||
distinct=False,
|
||||
|
||||
@@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import *
|
||||
from extras.models import Tag
|
||||
from netbox.forms.mixins import CustomFieldsMixin
|
||||
from netbox.forms.mixins import ChangelogMessageMixin, CustomFieldsMixin
|
||||
from utilities.forms import form_from_model
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from utilities.forms.mixins import BackgroundJobMixin
|
||||
|
||||
from .object_create import ComponentCreateForm
|
||||
|
||||
@@ -27,7 +28,7 @@ __all__ = (
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
|
||||
class DeviceBulkAddComponentForm(BackgroundJobMixin, ChangelogMessageMixin, CustomFieldsMixin, ComponentCreateForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -108,10 +109,13 @@ class RearPortBulkCreateForm(
|
||||
field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
|
||||
|
||||
|
||||
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
class ModuleBayBulkCreateForm(
|
||||
form_from_model(ModuleBay, ['enabled']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = ModuleBay
|
||||
field_order = ('name', 'label', 'position', 'description', 'tags')
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
field_order = ('name', 'label', 'position', 'enabled', 'description', 'tags')
|
||||
replication_fields = ('name', 'label', 'position', 'enabled')
|
||||
position = ExpandableNameField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
@@ -119,9 +123,12 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
class DeviceBayBulkCreateForm(
|
||||
form_from_model(DeviceBay, ['enabled']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = DeviceBay
|
||||
field_order = ('name', 'label', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'enabled', 'description', 'tags')
|
||||
|
||||
|
||||
class InventoryItemBulkCreateForm(
|
||||
|
||||
@@ -29,6 +29,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
|
||||
__all__ = (
|
||||
'CableBulkEditForm',
|
||||
'CableBundleBulkEditForm',
|
||||
'ConsolePortBulkEditForm',
|
||||
'ConsolePortTemplateBulkEditForm',
|
||||
'ConsoleServerPortBulkEditForm',
|
||||
@@ -61,6 +62,7 @@ __all__ = (
|
||||
'PowerPortBulkEditForm',
|
||||
'PowerPortTemplateBulkEditForm',
|
||||
'RackBulkEditForm',
|
||||
'RackGroupBulkEditForm',
|
||||
'RackReservationBulkEditForm',
|
||||
'RackRoleBulkEditForm',
|
||||
'RackTypeBulkEditForm',
|
||||
@@ -201,6 +203,14 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
|
||||
nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
|
||||
|
||||
|
||||
class RackGroupBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
model = RackGroup
|
||||
fieldsets = (
|
||||
FieldSet('description'),
|
||||
)
|
||||
nullable_fields = ('description', 'comments')
|
||||
|
||||
|
||||
class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
@@ -336,6 +346,11 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
label=_('Tenant'),
|
||||
queryset=Tenant.objects.all(),
|
||||
@@ -435,14 +450,16 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
|
||||
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
|
||||
FieldSet(
|
||||
'status', 'group', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')
|
||||
),
|
||||
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
|
||||
FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
|
||||
FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
|
||||
'location', 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
|
||||
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
|
||||
)
|
||||
|
||||
@@ -770,6 +787,24 @@ class ModuleBulkEditForm(PrimaryModelBulkEditForm):
|
||||
nullable_fields = ('serial', 'description', 'comments')
|
||||
|
||||
|
||||
class CableBundleBulkEditForm(PrimaryModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=CableBundle.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False,
|
||||
)
|
||||
|
||||
model = CableBundle
|
||||
fieldsets = (
|
||||
FieldSet('description',),
|
||||
)
|
||||
nullable_fields = ('description', 'comments')
|
||||
|
||||
|
||||
class CableBulkEditForm(PrimaryModelBulkEditForm):
|
||||
type = forms.ChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -794,6 +829,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
bundle = DynamicModelChoiceField(
|
||||
label=_('Bundle'),
|
||||
queryset=CableBundle.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
label = forms.CharField(
|
||||
label=_('Label'),
|
||||
max_length=100,
|
||||
@@ -817,11 +857,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
|
||||
|
||||
model = Cable
|
||||
fieldsets = (
|
||||
FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
|
||||
FieldSet('type', 'status', 'profile', 'tenant', 'bundle', 'label', 'description'),
|
||||
FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
|
||||
'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'description', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@@ -1205,6 +1245,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||
label=_('Description'),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
)
|
||||
|
||||
nullable_fields = ('label', 'position', 'description')
|
||||
|
||||
@@ -1223,6 +1268,11 @@ class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
|
||||
label=_('Description'),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
)
|
||||
|
||||
nullable_fields = ('label', 'description')
|
||||
|
||||
@@ -1647,23 +1697,23 @@ class RearPortBulkEditForm(
|
||||
|
||||
|
||||
class ModuleBayBulkEditForm(
|
||||
form_from_model(ModuleBay, ['label', 'position', 'description']),
|
||||
form_from_model(ModuleBay, ['label', 'position', 'enabled', 'description']),
|
||||
NetBoxModelBulkEditForm
|
||||
):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
FieldSet('label', 'position', 'description'),
|
||||
FieldSet('label', 'position', 'enabled', 'description'),
|
||||
)
|
||||
nullable_fields = ('label', 'position', 'description')
|
||||
|
||||
|
||||
class DeviceBayBulkEditForm(
|
||||
form_from_model(DeviceBay, ['label', 'description']),
|
||||
form_from_model(DeviceBay, ['label', 'enabled', 'description']),
|
||||
NetBoxModelBulkEditForm
|
||||
):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
FieldSet('label', 'description'),
|
||||
FieldSet('label', 'enabled', 'description'),
|
||||
)
|
||||
nullable_fields = ('label', 'description')
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableBundleImportForm',
|
||||
'CableImportForm',
|
||||
'ConsolePortImportForm',
|
||||
'ConsoleServerPortImportForm',
|
||||
@@ -57,6 +58,7 @@ __all__ = (
|
||||
'PowerOutletImportForm',
|
||||
'PowerPanelImportForm',
|
||||
'PowerPortImportForm',
|
||||
'RackGroupImportForm',
|
||||
'RackImportForm',
|
||||
'RackReservationImportForm',
|
||||
'RackRoleImportForm',
|
||||
@@ -187,6 +189,13 @@ class LocationImportForm(NestedGroupModelImportForm):
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
|
||||
class RackGroupImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class RackRoleImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
@@ -261,6 +270,13 @@ class RackImportForm(PrimaryModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Name of assigned tenant')
|
||||
)
|
||||
group = CSVModelChoiceField(
|
||||
label=_('Rack group'),
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Name of assigned group')
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
label=_('Status'),
|
||||
choices=RackStatusChoices,
|
||||
@@ -318,10 +334,10 @@ class RackImportForm(PrimaryModelImportForm):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = (
|
||||
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
|
||||
'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
|
||||
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner', 'comments',
|
||||
'tags',
|
||||
'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
|
||||
'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner',
|
||||
'comments', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -1138,7 +1154,13 @@ class ModuleBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = ('device', 'name', 'label', 'position', 'description', 'owner', 'tags')
|
||||
fields = ('device', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags')
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
if 'enabled' not in self.data:
|
||||
return True
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
|
||||
class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
@@ -1160,7 +1182,7 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ('device', 'name', 'label', 'installed_device', 'description', 'owner', 'tags')
|
||||
fields = ('device', 'name', 'label', 'enabled', 'installed_device', 'description', 'owner', 'tags')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1188,6 +1210,12 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
else:
|
||||
self.fields['installed_device'].queryset = Device.objects.none()
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
if 'enabled' not in self.data:
|
||||
return True
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
|
||||
class InventoryItemImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
@@ -1397,6 +1425,12 @@ class MACAddressImportForm(PrimaryModelImportForm):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableBundleImportForm(PrimaryModelImportForm):
|
||||
class Meta:
|
||||
model = CableBundle
|
||||
fields = ('name', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class CableImportForm(PrimaryModelImportForm):
|
||||
# Termination A
|
||||
side_a_site = CSVModelChoiceField(
|
||||
@@ -1474,6 +1508,13 @@ class CableImportForm(PrimaryModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned tenant')
|
||||
)
|
||||
bundle = CSVModelChoiceField(
|
||||
label=_('Bundle'),
|
||||
queryset=CableBundle.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Cable bundle name'),
|
||||
)
|
||||
length_unit = CSVChoiceField(
|
||||
label=_('Length unit'),
|
||||
choices=CableLengthUnitChoices,
|
||||
@@ -1491,7 +1532,7 @@ class CableImportForm(PrimaryModelImportForm):
|
||||
model = Cable
|
||||
fields = [
|
||||
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
|
||||
'side_b_name', 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'side_b_name', 'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'length_unit',
|
||||
'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from vpn.models import L2VPN
|
||||
from wireless.choices import *
|
||||
|
||||
__all__ = (
|
||||
'CableBundleFilterForm',
|
||||
'CableFilterForm',
|
||||
'ConsoleConnectionFilterForm',
|
||||
'ConsolePortFilterForm',
|
||||
@@ -64,6 +65,7 @@ __all__ = (
|
||||
'PowerPortTemplateFilterForm',
|
||||
'RackElevationFilterForm',
|
||||
'RackFilterForm',
|
||||
'RackGroupFilterForm',
|
||||
'RackReservationFilterForm',
|
||||
'RackRoleFilterForm',
|
||||
'RackTypeFilterForm',
|
||||
@@ -276,6 +278,15 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackGroupFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = RackGroup
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = RackRole
|
||||
fieldsets = (
|
||||
@@ -355,7 +366,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', name=_('Location')),
|
||||
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
|
||||
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
@@ -392,6 +403,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
|
||||
},
|
||||
label=_('Location')
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Rack group')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
choices=RackStatusChoices,
|
||||
@@ -435,7 +452,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
|
||||
class RackElevationFilterForm(RackFilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'id', name=_('Location')),
|
||||
FieldSet('status', 'role_id', name=_('Function')),
|
||||
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
@@ -458,8 +475,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = RackReservation
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('status', 'user_id', name=_('Reservation')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
|
||||
FieldSet('status', 'user_id', 'unit_count_min', 'unit_count_max', name=_('Reservation')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'rack_id', name=_('Rack')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
@@ -491,10 +508,17 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
label=_('Location'),
|
||||
null_option='None'
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Rack group')
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$group_id',
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
},
|
||||
@@ -510,6 +534,14 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('User')
|
||||
)
|
||||
unit_count_min = forms.IntegerField(
|
||||
required=False,
|
||||
label=_("Minimum U's")
|
||||
)
|
||||
unit_count_max = forms.IntegerField(
|
||||
required=False,
|
||||
label=_("Maximum U's")
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -1149,12 +1181,24 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CableBundleFilterForm(PrimaryModelFilterSetForm):
|
||||
model = CableBundle
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', name=_('Attributes')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Cable
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
||||
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
|
||||
FieldSet(
|
||||
'type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', 'bundle_id',
|
||||
name=_('Attributes'),
|
||||
),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
@@ -1236,6 +1280,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
bundle_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CableBundle.objects.all(),
|
||||
required=False,
|
||||
label=_('Bundle'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -1829,7 +1878,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
@@ -1837,31 +1886,41 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
label=_('Position'),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ModuleBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
position = forms.CharField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('name', 'label', 'enabled', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
@@ -1869,6 +1928,11 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -1876,9 +1940,14 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = DeviceBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('name', 'label', 'enabled', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
|
||||
)
|
||||
|
||||
|
||||
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
|
||||
@@ -23,7 +23,7 @@ from utilities.forms.fields import (
|
||||
NumericArrayField,
|
||||
SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
|
||||
from utilities.forms.widgets import (
|
||||
APISelect,
|
||||
ClearableFileInput,
|
||||
@@ -39,6 +39,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableBundleForm',
|
||||
'CableForm',
|
||||
'ConsolePortForm',
|
||||
'ConsolePortTemplateForm',
|
||||
@@ -74,6 +75,7 @@ __all__ = (
|
||||
'PowerPortForm',
|
||||
'PowerPortTemplateForm',
|
||||
'RackForm',
|
||||
'RackGroupForm',
|
||||
'RackReservationForm',
|
||||
'RackRoleForm',
|
||||
'RackTypeForm',
|
||||
@@ -142,6 +144,16 @@ class SiteForm(TenancyForm, PrimaryModelForm):
|
||||
label=_('ASNs'),
|
||||
required=False
|
||||
)
|
||||
add_asns = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
label=_('Add ASNs'),
|
||||
required=False
|
||||
)
|
||||
remove_asns = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
label=_('Remove ASNs'),
|
||||
required=False
|
||||
)
|
||||
slug = SlugField()
|
||||
time_zone = TimeZoneFormField(
|
||||
label=_('Time zone'),
|
||||
@@ -151,7 +163,8 @@ class SiteForm(TenancyForm, PrimaryModelForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
|
||||
'name', 'slug', 'status', 'region', 'group', 'facility', M2MAddRemoveFields('asns'), 'time_zone',
|
||||
'description', 'tags',
|
||||
name=_('Site')
|
||||
),
|
||||
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
|
||||
@@ -161,7 +174,7 @@ class SiteForm(TenancyForm, PrimaryModelForm):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = (
|
||||
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
|
||||
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'time_zone',
|
||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags',
|
||||
)
|
||||
widgets = {
|
||||
@@ -177,6 +190,21 @@ class SiteForm(TenancyForm, PrimaryModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
|
||||
# Add/remove mode for large M2M sets
|
||||
self.fields.pop('asns')
|
||||
self.fields['add_asns'].widget.add_query_param('site_id__n', self.instance.pk)
|
||||
self.fields['remove_asns'].widget.add_query_param('site_id', self.instance.pk)
|
||||
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
|
||||
else:
|
||||
# Simple mode for new objects or small M2M sets
|
||||
self.fields.pop('add_asns')
|
||||
self.fields.pop('remove_asns')
|
||||
if self.instance.pk:
|
||||
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
|
||||
|
||||
|
||||
class LocationForm(TenancyForm, NestedGroupModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
@@ -206,6 +234,18 @@ class LocationForm(TenancyForm, NestedGroupModelForm):
|
||||
)
|
||||
|
||||
|
||||
class RackGroupForm(OrganizationalModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('name', 'slug', 'description', 'tags', name=_('Rack Group')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = [
|
||||
'name', 'slug', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class RackRoleForm(OrganizationalModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
|
||||
@@ -263,6 +303,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Rack Group'),
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
label=_('Role'),
|
||||
queryset=RackRole.objects.all(),
|
||||
@@ -278,7 +323,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
|
||||
'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
|
||||
name=_('Rack')
|
||||
),
|
||||
FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
|
||||
@@ -288,7 +333,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
||||
'site', 'location', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
|
||||
'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
|
||||
'weight_unit', 'description', 'owner', 'comments', 'tags',
|
||||
@@ -758,7 +803,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
|
||||
'device_id': '$device',
|
||||
},
|
||||
context={
|
||||
'disabled': 'installed_module',
|
||||
'disabled': '_occupied',
|
||||
},
|
||||
)
|
||||
module_type = DynamicModelChoiceField(
|
||||
@@ -812,6 +857,17 @@ def get_termination_type_choices():
|
||||
])
|
||||
|
||||
|
||||
class CableBundleForm(PrimaryModelForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'description', 'tags', name=_('Cable Bundle')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CableBundle
|
||||
fields = ['name', 'description', 'owner', 'comments', 'tags']
|
||||
|
||||
|
||||
class CableForm(TenancyForm, PrimaryModelForm):
|
||||
a_terminations_type = forms.ChoiceField(
|
||||
choices=get_termination_type_choices,
|
||||
@@ -825,12 +881,17 @@ class CableForm(TenancyForm, PrimaryModelForm):
|
||||
widget=HTMXSelect(),
|
||||
label=_('Type')
|
||||
)
|
||||
bundle = DynamicModelChoiceField(
|
||||
queryset=CableBundle.objects.all(),
|
||||
required=False,
|
||||
label=_('Bundle'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant',
|
||||
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
||||
'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -1037,7 +1098,9 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
|
||||
self.fields['name'].help_text = _(
|
||||
"Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
|
||||
"supported (example: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, if present, will be "
|
||||
"automatically replaced with the position value when creating a new module."
|
||||
"automatically replaced with the position value when creating a new module. "
|
||||
"The token <code>{vc_position}</code> will be replaced with the device's Virtual Chassis position "
|
||||
"(use <code>{vc_position:1}</code> to specify a fallback (default is 0))"
|
||||
)
|
||||
|
||||
|
||||
@@ -1198,26 +1261,26 @@ class ModuleBayTemplateForm(ModularComponentTemplateForm):
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'position', 'description',
|
||||
'name', 'label', 'position', 'enabled', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBayTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'position', 'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
|
||||
]
|
||||
|
||||
|
||||
class DeviceBayTemplateForm(ComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet('device_type', 'name', 'label', 'description'),
|
||||
FieldSet('device_type', 'name', 'label', 'enabled', 'description'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'label', 'description',
|
||||
'device_type', 'name', 'label', 'enabled', 'description',
|
||||
]
|
||||
|
||||
|
||||
@@ -1663,25 +1726,25 @@ class RearPortForm(ModularDeviceComponentForm):
|
||||
|
||||
class ModuleBayForm(ModularDeviceComponentForm):
|
||||
fieldsets = (
|
||||
FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',),
|
||||
FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'position', 'description', 'owner', 'tags',
|
||||
'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class DeviceBayForm(DeviceComponentForm):
|
||||
fieldsets = (
|
||||
FieldSet('device', 'name', 'label', 'description', 'tags',),
|
||||
FieldSet('device', 'name', 'label', 'enabled', 'description', 'tags',),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'device', 'name', 'label', 'description', 'owner', 'tags',
|
||||
'device', 'name', 'label', 'enabled', 'description', 'owner', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ if TYPE_CHECKING:
|
||||
from .enums import *
|
||||
|
||||
__all__ = (
|
||||
'CableBundleFilter',
|
||||
'CableFilter',
|
||||
'CableTerminationFilter',
|
||||
'ConsolePortFilter',
|
||||
@@ -93,6 +94,7 @@ __all__ = (
|
||||
'PowerPortFilter',
|
||||
'PowerPortTemplateFilter',
|
||||
'RackFilter',
|
||||
'RackGroupFilter',
|
||||
'RackReservationFilter',
|
||||
'RackRoleFilter',
|
||||
'RackTypeFilter',
|
||||
@@ -106,6 +108,11 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.CableBundle, lookups=True)
|
||||
class CableBundleFilter(PrimaryModelFilter):
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.Cable, lookups=True)
|
||||
class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
@@ -311,6 +318,7 @@ class DeviceFilter(
|
||||
|
||||
@strawberry_django.filter_type(models.DeviceBay, lookups=True)
|
||||
class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -319,7 +327,7 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
|
||||
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
|
||||
pass
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
|
||||
@@ -735,11 +743,13 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
|
||||
)
|
||||
parent_id: ID | None = strawberry_django.filter_field()
|
||||
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
|
||||
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
|
||||
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
|
||||
@@ -956,6 +966,10 @@ class RackFilter(
|
||||
location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
group: Annotated['RackGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
group_id: ID | None = strawberry_django.filter_field()
|
||||
status: BaseFilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -971,6 +985,11 @@ class RackFilter(
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.RackGroup, lookups=True)
|
||||
class RackGroupFilter(OrganizationalModelFilter):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.RackReservation, lookups=True)
|
||||
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
@@ -978,6 +997,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
unit_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
user_id: ID | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -9,6 +9,9 @@ class DCIMQuery:
|
||||
cable: CableType = strawberry_django.field()
|
||||
cable_list: list[CableType] = strawberry_django.field()
|
||||
|
||||
cable_bundle: CableBundleType = strawberry_django.field()
|
||||
cable_bundle_list: list[CableBundleType] = strawberry_django.field()
|
||||
|
||||
console_port: ConsolePortType = strawberry_django.field()
|
||||
console_port_list: list[ConsolePortType] = strawberry_django.field()
|
||||
|
||||
@@ -102,6 +105,9 @@ class DCIMQuery:
|
||||
power_port_template: PowerPortTemplateType = strawberry_django.field()
|
||||
power_port_template_list: list[PowerPortTemplateType] = strawberry_django.field()
|
||||
|
||||
rack_group: RackGroupType = strawberry_django.field()
|
||||
rack_group_list: list[RackGroupType] = strawberry_django.field()
|
||||
|
||||
rack_type: RackTypeType = strawberry_django.field()
|
||||
rack_type_list: list[RackTypeType] = strawberry_django.field()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from django.db.models import Func, IntegerField
|
||||
|
||||
from core.graphql.mixins import ChangelogMixin
|
||||
from dcim import models
|
||||
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
|
||||
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
||||
|
||||
__all__ = (
|
||||
'CableBundleType',
|
||||
'CableType',
|
||||
'ComponentType',
|
||||
'ConsolePortTemplateType',
|
||||
@@ -73,6 +75,7 @@ __all__ = (
|
||||
'PowerPanelType',
|
||||
'PowerPortTemplateType',
|
||||
'PowerPortType',
|
||||
'RackGroupType',
|
||||
'RackReservationType',
|
||||
'RackRoleType',
|
||||
'RackType',
|
||||
@@ -126,6 +129,16 @@ class ModularComponentTemplateType(ComponentTemplateType):
|
||||
#
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.CableBundle,
|
||||
fields='__all__',
|
||||
filters=CableBundleFilter,
|
||||
pagination=True
|
||||
)
|
||||
class CableBundleType(PrimaryObjectType):
|
||||
cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.CableTermination,
|
||||
exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
|
||||
@@ -157,6 +170,7 @@ class CableTerminationType(NetBoxObjectType):
|
||||
class CableType(PrimaryObjectType):
|
||||
color: str
|
||||
tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
|
||||
bundle: Annotated['CableBundleType', strawberry.lazy('dcim.graphql.types')] | None
|
||||
|
||||
terminations: list[CableTerminationType]
|
||||
|
||||
@@ -736,6 +750,17 @@ class PowerPortTemplateType(ModularComponentTemplateType):
|
||||
poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.RackGroup,
|
||||
fields='__all__',
|
||||
filters=RackGroupFilter,
|
||||
pagination=True
|
||||
)
|
||||
class RackGroupType(OrganizationalObjectType):
|
||||
|
||||
racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.RackType,
|
||||
fields='__all__',
|
||||
@@ -756,6 +781,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
|
||||
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
|
||||
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
|
||||
location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
group: Annotated["RackGroupType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
|
||||
@@ -778,6 +804,17 @@ class RackReservationType(PrimaryObjectType):
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
user: Annotated["UserType", strawberry.lazy('users.graphql.types')]
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info, **kwargs):
|
||||
queryset = super().get_queryset(queryset, info, **kwargs)
|
||||
return queryset.annotate(
|
||||
unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
|
||||
)
|
||||
|
||||
@strawberry.field
|
||||
def unit_count(self) -> int:
|
||||
return len(self.units)
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.RackRole,
|
||||
|
||||
@@ -22,17 +22,21 @@ def load_initial_data(apps, schema_editor):
|
||||
'power_supply',
|
||||
'expansion_card'
|
||||
)
|
||||
profile_objects = []
|
||||
|
||||
for name in initial_profiles:
|
||||
file_path = DATA_FILES_PATH / f'{name}.json'
|
||||
with file_path.open('r') as f:
|
||||
data = json.load(f)
|
||||
try:
|
||||
ModuleTypeProfile.objects.using(db_alias).create(**data)
|
||||
profile = ModuleTypeProfile(**data)
|
||||
profile_objects.append(profile)
|
||||
except Exception as e:
|
||||
print(f"Error loading data from {file_path}")
|
||||
raise e
|
||||
|
||||
ModuleTypeProfile.objects.using(db_alias).bulk_create(profile_objects)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
||||
57
netbox/dcim/migrations/0227_rack_group.py
Normal file
57
netbox/dcim/migrations/0227_rack_group.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
from django.db import migrations, models
|
||||
|
||||
import netbox.models.deletion
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0226_modulebay_rebuild_tree'),
|
||||
('extras', '0134_owner'),
|
||||
('users', '0015_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RackGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
(
|
||||
'custom_field_data',
|
||||
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
||||
),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
(
|
||||
'owner',
|
||||
models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
|
||||
),
|
||||
),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'rack group',
|
||||
'verbose_name_plural': 'rack groups',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='group',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='racks',
|
||||
to='dcim.rackgroup',
|
||||
),
|
||||
),
|
||||
]
|
||||
54
netbox/dcim/migrations/0228_cable_bundle.py
Normal file
54
netbox/dcim/migrations/0228_cable_bundle.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
from django.db import migrations, models
|
||||
|
||||
import netbox.models.deletion
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0227_rack_group'),
|
||||
('extras', '0134_owner'),
|
||||
('users', '0015_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CableBundle',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(
|
||||
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)
|
||||
),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('owner', models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner')
|
||||
),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'cable bundle',
|
||||
'verbose_name_plural': 'cable bundles',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cable',
|
||||
name='bundle',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='cables',
|
||||
to='dcim.cablebundle',
|
||||
verbose_name='bundle',
|
||||
),
|
||||
),
|
||||
]
|
||||
30
netbox/dcim/migrations/0229_devicebay_modulebay_enabled.py
Normal file
30
netbox/dcim/migrations/0229_devicebay_modulebay_enabled.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0228_cable_bundle'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebaytemplate',
|
||||
name='enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebaytemplate',
|
||||
name='enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0229_devicebay_modulebay_enabled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='rf_channel_frequency',
|
||||
field=models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=3,
|
||||
help_text='Populated by selected channel (if set)',
|
||||
max_digits=8,
|
||||
null=True,
|
||||
verbose_name='channel frequency (MHz)',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.dispatch import Signal
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ObjectType
|
||||
@@ -29,6 +30,7 @@ from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
'CableBundle',
|
||||
'CablePath',
|
||||
'CableTermination',
|
||||
)
|
||||
@@ -38,6 +40,32 @@ logger = logging.getLogger(f'netbox.{__name__}')
|
||||
trace_paths = Signal()
|
||||
|
||||
|
||||
#
|
||||
# Cable bundles
|
||||
#
|
||||
|
||||
class CableBundle(PrimaryModel):
|
||||
"""
|
||||
A logical grouping of individual cables.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('cable bundle')
|
||||
verbose_name_plural = _('cable bundles')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cablebundle', args=[self.pk])
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
@@ -102,8 +130,16 @@ class Cable(PrimaryModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
bundle = models.ForeignKey(
|
||||
to='dcim.CableBundle',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='cables',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('bundle'),
|
||||
)
|
||||
|
||||
clone_fields = ('tenant', 'type', 'profile')
|
||||
clone_fields = ('tenant', 'type', 'profile', 'bundle')
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
@@ -165,6 +165,26 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
_("A component template must be associated with either a device type or a module type.")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_vc_position(value: str, device) -> str:
|
||||
"""
|
||||
Resolves {vc_position} and {vc_position:X} tokens.
|
||||
|
||||
If the device has a vc_position, replaces the token with that value.
|
||||
Otherwise uses the explicit fallback X if given, else '0'.
|
||||
"""
|
||||
def replacer(match):
|
||||
explicit_fallback = match.group(1)
|
||||
if (
|
||||
device is not None
|
||||
and device.virtual_chassis is not None
|
||||
and device.vc_position is not None
|
||||
):
|
||||
return str(device.vc_position)
|
||||
return explicit_fallback if explicit_fallback is not None else '0'
|
||||
|
||||
return VC_POSITION_RE.sub(replacer, value)
|
||||
|
||||
def _get_module_tree(self, module):
|
||||
modules = []
|
||||
while module:
|
||||
@@ -177,29 +197,21 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
modules.reverse()
|
||||
return modules
|
||||
|
||||
def resolve_name(self, module):
|
||||
if MODULE_TOKEN not in self.name:
|
||||
return self.name
|
||||
|
||||
if module:
|
||||
def _resolve_module_placeholder(self, value, module=None, device=None):
|
||||
if MODULE_TOKEN in value and module:
|
||||
modules = self._get_module_tree(module)
|
||||
name = self.name
|
||||
for module in modules:
|
||||
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||
return name
|
||||
return self.name
|
||||
for m in modules:
|
||||
value = value.replace(MODULE_TOKEN, m.module_bay.position, 1)
|
||||
if VC_POSITION_RE.search(value) is not None:
|
||||
resolved_device = (module.device if module else None) or device
|
||||
value = self._resolve_vc_position(value, resolved_device)
|
||||
return value
|
||||
|
||||
def resolve_label(self, module):
|
||||
if MODULE_TOKEN not in self.label:
|
||||
return self.label
|
||||
def resolve_name(self, module=None, device=None):
|
||||
return self._resolve_module_placeholder(self.name, module, device)
|
||||
|
||||
if module:
|
||||
modules = self._get_module_tree(module)
|
||||
label = self.label
|
||||
for module in modules:
|
||||
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
|
||||
return label
|
||||
return self.label
|
||||
def resolve_label(self, module=None, device=None):
|
||||
return self._resolve_module_placeholder(self.label, module, device)
|
||||
|
||||
|
||||
class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
@@ -222,8 +234,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
**kwargs
|
||||
)
|
||||
@@ -257,8 +269,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
**kwargs
|
||||
)
|
||||
@@ -307,8 +319,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
maximum_draw=self.maximum_draw,
|
||||
allocated_draw=self.allocated_draw,
|
||||
@@ -395,13 +407,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
if self.power_port:
|
||||
power_port_name = self.power_port.resolve_name(kwargs.get('module'))
|
||||
power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device'))
|
||||
power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
|
||||
else:
|
||||
power_port = None
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
color=self.color,
|
||||
power_port=power_port,
|
||||
@@ -501,8 +513,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
enabled=self.enabled,
|
||||
mgmt_only=self.mgmt_only,
|
||||
@@ -628,8 +640,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
color=self.color,
|
||||
positions=self.positions,
|
||||
@@ -692,8 +704,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
type=self.type,
|
||||
color=self.color,
|
||||
positions=self.positions,
|
||||
@@ -722,6 +734,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
blank=True,
|
||||
help_text=_('Identifier to reference when renaming installed components')
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
component_model = ModuleBay
|
||||
|
||||
@@ -729,11 +745,15 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
verbose_name = _('module bay template')
|
||||
verbose_name_plural = _('module bay templates')
|
||||
|
||||
def resolve_position(self, module):
|
||||
return self._resolve_module_placeholder(self.position, module)
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
|
||||
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
|
||||
position=self.resolve_position(kwargs.get('module')),
|
||||
enabled=self.enabled,
|
||||
**kwargs
|
||||
)
|
||||
instantiate.do_not_call_in_templates = True
|
||||
@@ -743,6 +763,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'position': self.position,
|
||||
'enabled': self.enabled,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
@@ -751,6 +772,11 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a DeviceBay to be created for a new parent Device.
|
||||
"""
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
component_model = DeviceBay
|
||||
|
||||
class Meta(ComponentTemplateModel.Meta):
|
||||
@@ -761,7 +787,8 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
return self.component_model(
|
||||
device=device,
|
||||
name=self.name,
|
||||
label=self.label
|
||||
label=self.label,
|
||||
enabled=self.enabled,
|
||||
)
|
||||
instantiate.do_not_call_in_templates = True
|
||||
|
||||
@@ -777,6 +804,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
return {
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'enabled': self.enabled,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
@@ -807,8 +807,8 @@ class Interface(
|
||||
verbose_name=_('wireless channel')
|
||||
)
|
||||
rf_channel_frequency = models.DecimalField(
|
||||
max_digits=7,
|
||||
decimal_places=2,
|
||||
max_digits=8,
|
||||
decimal_places=3,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('channel frequency (MHz)'),
|
||||
@@ -1257,10 +1257,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
|
||||
blank=True,
|
||||
help_text=_('Identifier to reference when renaming installed components')
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
clone_fields = ('device',)
|
||||
clone_fields = ('device', 'enabled')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
# Empty tuple triggers Django migration detection for MPTT indexes
|
||||
@@ -1299,6 +1303,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
|
||||
self.parent = None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
"""
|
||||
Indicates whether the module bay is occupied by a module.
|
||||
"""
|
||||
return bool(not self.enabled or hasattr(self, 'installed_module'))
|
||||
|
||||
|
||||
class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
"""
|
||||
@@ -1311,8 +1322,12 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True,
|
||||
)
|
||||
|
||||
clone_fields = ('device',)
|
||||
clone_fields = ('device', 'enabled')
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('device bay')
|
||||
@@ -1327,6 +1342,16 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
device_type=self.device.device_type
|
||||
))
|
||||
|
||||
# Prevent installing a device into a disabled bay
|
||||
if self.installed_device and not self.enabled:
|
||||
current_installed_device_id = (
|
||||
DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
|
||||
)
|
||||
if self.pk is None or current_installed_device_id != self.installed_device_id:
|
||||
raise ValidationError({
|
||||
'installed_device': _("Cannot install a device in a disabled device bay.")
|
||||
})
|
||||
|
||||
# Cannot install a device into itself, obviously
|
||||
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
|
||||
raise ValidationError(_("Cannot install a device into itself."))
|
||||
@@ -1341,6 +1366,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
).format(bay=current_bay)
|
||||
})
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
"""
|
||||
Indicates whether the device bay is occupied by a child device.
|
||||
"""
|
||||
return bool(not self.enabled or self.installed_device_id)
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
|
||||
@@ -26,6 +26,7 @@ from netbox.config import ConfigItem
|
||||
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from netbox.models.mixins import WeightMixin
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.fields import ColorField, CounterCacheField
|
||||
from utilities.prefetch import get_prefetchable_fields
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
@@ -948,6 +949,20 @@ class Device(
|
||||
).format(virtual_chassis=self.vc_master_for)
|
||||
})
|
||||
|
||||
def _check_duplicate_component_names(self, components):
|
||||
"""
|
||||
Check for duplicate component names after resolving {vc_position} placeholders.
|
||||
Raises AbortRequest if duplicates are found.
|
||||
"""
|
||||
names = [c.name for c in components]
|
||||
duplicates = {n for n in names if names.count(n) > 1}
|
||||
if duplicates:
|
||||
raise AbortRequest(
|
||||
_("Component name conflict after resolving {{vc_position}}: {names}").format(
|
||||
names=', '.join(duplicates)
|
||||
)
|
||||
)
|
||||
|
||||
def _instantiate_components(self, queryset, bulk_create=True):
|
||||
"""
|
||||
Instantiate components for the device from the specified component templates.
|
||||
@@ -962,6 +977,10 @@ class Device(
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
|
||||
# Check for duplicate names after resolution {vc_position}
|
||||
self._check_duplicate_component_names(components)
|
||||
|
||||
# Set default values for any applicable custom fields
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
@@ -986,8 +1005,14 @@ class Device(
|
||||
update_fields=None
|
||||
)
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
|
||||
# Check for duplicate names after resolution {vc_position}
|
||||
self._check_duplicate_component_names(components)
|
||||
|
||||
for component in components:
|
||||
# Set default values for any applicable custom fields
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
@@ -258,6 +258,14 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||
)
|
||||
)
|
||||
|
||||
# Prevent module from being installed in a disabled bay
|
||||
if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
|
||||
current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first()
|
||||
if self.pk is None or current_module_bay_id != self.module_bay_id:
|
||||
raise ValidationError({
|
||||
'module_bay': _("Cannot install a module in a disabled module bay.")
|
||||
})
|
||||
|
||||
# Check for recursion
|
||||
module = self
|
||||
module_bays = []
|
||||
|
||||
@@ -29,14 +29,30 @@ from .power import PowerFeed
|
||||
|
||||
__all__ = (
|
||||
'Rack',
|
||||
'RackGroup',
|
||||
'RackReservation',
|
||||
'RackRole',
|
||||
'RackType',
|
||||
)
|
||||
|
||||
#
|
||||
# Rack Organization
|
||||
#
|
||||
|
||||
|
||||
class RackGroup(OrganizationalModel):
|
||||
"""
|
||||
Racks can be grouped by physical placement within a Location.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('rack group')
|
||||
verbose_name_plural = _('rack groups')
|
||||
|
||||
|
||||
#
|
||||
# Rack Types
|
||||
# Rack Base
|
||||
#
|
||||
|
||||
class RackBase(WeightMixin, PrimaryModel):
|
||||
@@ -123,6 +139,10 @@ class RackBase(WeightMixin, PrimaryModel):
|
||||
abstract = True
|
||||
|
||||
|
||||
#
|
||||
# Rack Types
|
||||
#
|
||||
|
||||
class RackType(ImageAttachmentsMixin, RackBase):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
@@ -290,6 +310,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
to='dcim.RackGroup',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='racks',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('physical grouping')
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
|
||||
@@ -3,6 +3,17 @@ from netbox.search import SearchIndex, register_search
|
||||
from . import models
|
||||
|
||||
|
||||
@register_search
|
||||
class CableBundleIndex(SearchIndex):
|
||||
model = models.CableBundle
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class CableIndex(SearchIndex):
|
||||
model = models.Cable
|
||||
@@ -315,6 +326,18 @@ class RackReservationIndex(SearchIndex):
|
||||
display_attrs = ('rack', 'tenant', 'user', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
class RackGroupIndex(SearchIndex):
|
||||
model = models.RackGroup
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class RackRoleIndex(SearchIndex):
|
||||
model = models.RackRole
|
||||
|
||||
@@ -4,13 +4,14 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Cable
|
||||
from dcim.models import Cable, CableBundle
|
||||
from netbox.tables import PrimaryModelTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .template_code import CABLE_LENGTH
|
||||
|
||||
__all__ = (
|
||||
'CableBundleTable',
|
||||
'CableTable',
|
||||
)
|
||||
|
||||
@@ -119,6 +120,10 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
verbose_name=_('Color Name'),
|
||||
orderable=False
|
||||
)
|
||||
bundle = tables.Column(
|
||||
verbose_name=_('Bundle'),
|
||||
linkify=True,
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:cable_list'
|
||||
)
|
||||
@@ -128,8 +133,30 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
|
||||
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group',
|
||||
'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'color', 'color_name', 'bundle', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
|
||||
)
|
||||
|
||||
|
||||
class CableBundleTable(PrimaryModelTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True,
|
||||
)
|
||||
cable_count = tables.Column(
|
||||
verbose_name=_('Cables'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:cablebundle_list'
|
||||
)
|
||||
|
||||
class Meta(PrimaryModelTable.Meta):
|
||||
model = CableBundle
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'cable_count', 'description', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'name', 'cable_count', 'description',
|
||||
)
|
||||
|
||||
@@ -888,6 +888,9 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
verbose_name=_('Status'),
|
||||
template_code=DEVICEBAY_STATUS,
|
||||
@@ -925,12 +928,12 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.DeviceBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
|
||||
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
|
||||
'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'device', 'label', 'enabled', 'status', 'description', 'installed_device',
|
||||
'installed_role', 'installed_device_type', 'installed_description', 'installed_serial',
|
||||
'installed_asset_tag', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
class DeviceDeviceBayTable(DeviceBayTable):
|
||||
@@ -940,6 +943,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
|
||||
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
extra_buttons=DEVICEBAY_BUTTONS
|
||||
)
|
||||
@@ -947,9 +953,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.DeviceBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
|
||||
'pk', 'id', 'name', 'label', 'enabled', 'status', 'installed_device', 'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
|
||||
default_columns = ('pk', 'name', 'label', 'enabled', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
class ModuleBayTable(ModularDeviceComponentTable):
|
||||
@@ -960,6 +966,9 @@ class ModuleBayTable(ModularDeviceComponentTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Parent'),
|
||||
@@ -988,11 +997,11 @@ class ModuleBayTable(ModularDeviceComponentTable):
|
||||
class Meta(ModularDeviceComponentTable.Meta):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status',
|
||||
'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status',
|
||||
'module_serial', 'module_asset_tag', 'description', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description',
|
||||
'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',
|
||||
)
|
||||
|
||||
def render_parent_bay(self, value):
|
||||
@@ -1007,6 +1016,9 @@ class DeviceModuleBayTable(ModuleBayTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True,
|
||||
)
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
extra_buttons=MODULEBAY_BUTTONS
|
||||
)
|
||||
@@ -1014,10 +1026,10 @@ class DeviceModuleBayTable(ModuleBayTable):
|
||||
class Meta(ModuleBayTable.Meta):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
|
||||
'module_asset_tag', 'description', 'tags', 'actions',
|
||||
'pk', 'id', 'parent', 'name', 'label', 'enabled', 'position', 'installed_module', 'module_status',
|
||||
'module_serial', 'module_asset_tag', 'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
|
||||
default_columns = ('pk', 'name', 'label', 'enabled', 'installed_module', 'module_status', 'description')
|
||||
|
||||
|
||||
class InventoryItemTable(DeviceComponentTable):
|
||||
|
||||
@@ -289,24 +289,30 @@ class RearPortTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class ModuleBayTemplateTable(ComponentTemplateTable):
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = models.ModuleBayTemplate
|
||||
fields = ('pk', 'name', 'label', 'position', 'description', 'actions')
|
||||
fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
enabled = columns.BooleanColumn(
|
||||
verbose_name=_('Enabled'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = models.DeviceBayTemplate
|
||||
fields = ('pk', 'name', 'label', 'description', 'actions')
|
||||
fields = ('pk', 'name', 'label', 'enabled', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Rack, RackReservation, RackRole, RackType
|
||||
from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
|
||||
from netbox.tables import OrganizationalModelTable, PrimaryModelTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from .template_code import OUTER_UNIT, WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'RackGroupTable',
|
||||
'RackReservationTable',
|
||||
'RackRoleTable',
|
||||
'RackTable',
|
||||
@@ -16,6 +17,29 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class RackGroupTable(OrganizationalModelTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True,
|
||||
)
|
||||
rack_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:rack_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name=_('Racks'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:rackgroup_list',
|
||||
)
|
||||
|
||||
class Meta(OrganizationalModelTable.Meta):
|
||||
model = RackGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'rack_count', 'description', 'slug', 'comments', 'tags', 'actions', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rack_count', 'description')
|
||||
|
||||
|
||||
class RackRoleTable(OrganizationalModelTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
@@ -111,6 +135,10 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
|
||||
verbose_name=_('Site'),
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
verbose_name=_('Group'),
|
||||
linkify=True,
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
)
|
||||
@@ -172,15 +200,15 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
|
||||
class Meta(PrimaryModelTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
|
||||
'pk', 'id', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
|
||||
'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
|
||||
'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
|
||||
'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height',
|
||||
'device_count', 'get_utilization',
|
||||
'pk', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'role', 'rack_type',
|
||||
'u_height', 'device_count', 'get_utilization',
|
||||
)
|
||||
|
||||
|
||||
@@ -200,6 +228,11 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
accessor=Accessor('rack__location'),
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
verbose_name=_('Group'),
|
||||
accessor=Accessor('rack__group'),
|
||||
linkify=True
|
||||
)
|
||||
rack = tables.Column(
|
||||
verbose_name=_('Rack'),
|
||||
linkify=True
|
||||
@@ -208,6 +241,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Units')
|
||||
)
|
||||
unit_count = tables.Column(
|
||||
verbose_name=_("Total U's")
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
)
|
||||
@@ -218,7 +254,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
class Meta(PrimaryModelTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'unit_count', 'status',
|
||||
'user', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'unit_count', 'status', 'user', 'description',
|
||||
)
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
|
||||
|
||||
@@ -565,7 +565,7 @@ DEVICEBAY_BUTTONS = """
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove device"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
{% elif record.enabled %}
|
||||
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install device"></i>
|
||||
</a>
|
||||
@@ -579,7 +579,7 @@ MODULEBAY_BUTTONS = """
|
||||
<a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
{% elif record.enabled %}
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device_id }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||
</a>
|
||||
|
||||
@@ -5,16 +5,17 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.models import ConfigTemplate
|
||||
from extras.models import ConfigTemplate, Tag
|
||||
from ipam.choices import VLANQinQRoleChoices
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.api.serializers import GenericObjectSerializer
|
||||
from tenancy.models import Tenant
|
||||
from users.constants import TOKEN_PREFIX
|
||||
from users.models import Token, User
|
||||
from users.models import ObjectPermission, Token, User
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
from wireless.choices import WirelessChannelChoices
|
||||
@@ -195,6 +196,222 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_add_tags(self):
|
||||
"""
|
||||
Add tags to an existing object via the add_tags field.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
tags = Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
Tag(name='Charlie', slug='charlie'),
|
||||
))
|
||||
site.tags.set([tags[0], tags[1]])
|
||||
|
||||
# Grant change permission
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
url = self._get_detail_url(site)
|
||||
data = {
|
||||
'add_tags': [{'name': 'Charlie'}],
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Verify all three tags are now assigned
|
||||
tag_names = sorted(site.tags.values_list('name', flat=True))
|
||||
self.assertEqual(tag_names, ['Alpha', 'Bravo', 'Charlie'])
|
||||
|
||||
# Verify add_tags and remove_tags are not in the response
|
||||
self.assertNotIn('add_tags', response.data)
|
||||
self.assertNotIn('remove_tags', response.data)
|
||||
self.assertIn('tags', response.data)
|
||||
|
||||
def test_remove_tags(self):
|
||||
"""
|
||||
Remove tags from an existing object via the remove_tags field.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
tags = Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
Tag(name='Charlie', slug='charlie'),
|
||||
))
|
||||
site.tags.set(tags)
|
||||
|
||||
# Grant change permission
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
url = self._get_detail_url(site)
|
||||
data = {
|
||||
'remove_tags': [{'name': 'Charlie'}],
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Verify only Alpha and Bravo remain
|
||||
tag_names = sorted(site.tags.values_list('name', flat=True))
|
||||
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
|
||||
|
||||
def test_remove_tags_not_assigned(self):
|
||||
"""
|
||||
Removing a tag that is not assigned should not raise an error.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
tags = Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
Tag(name='Charlie', slug='charlie'),
|
||||
))
|
||||
site.tags.set([tags[0], tags[1]])
|
||||
|
||||
# Grant change permission
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
url = self._get_detail_url(site)
|
||||
data = {
|
||||
'remove_tags': [{'name': 'Charlie'}],
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Tags should be unchanged
|
||||
tag_names = sorted(site.tags.values_list('name', flat=True))
|
||||
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
|
||||
|
||||
def test_add_and_remove_tags(self):
|
||||
"""
|
||||
Add and remove tags in the same request.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
tags = Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
Tag(name='Charlie', slug='charlie'),
|
||||
))
|
||||
site.tags.set([tags[0], tags[1]])
|
||||
|
||||
# Grant change permission
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
url = self._get_detail_url(site)
|
||||
data = {
|
||||
'add_tags': [{'name': 'Charlie'}],
|
||||
'remove_tags': [{'name': 'Alpha'}],
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Verify Bravo and Charlie remain
|
||||
tag_names = sorted(site.tags.values_list('name', flat=True))
|
||||
self.assertEqual(tag_names, ['Bravo', 'Charlie'])
|
||||
|
||||
def test_tags_with_add_tags_error(self):
|
||||
"""
|
||||
Specifying tags together with add_tags or remove_tags should raise a validation error.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
))
|
||||
|
||||
# Grant change permission
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
url = self._get_detail_url(site)
|
||||
data = {
|
||||
'tags': [{'name': 'Alpha'}],
|
||||
'add_tags': [{'name': 'Bravo'}],
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_create_with_add_tags(self):
|
||||
"""
|
||||
Create a new object using add_tags.
|
||||
"""
|
||||
Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
))
|
||||
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
data = {
|
||||
'name': 'Site 10',
|
||||
'slug': 'site-10',
|
||||
'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
|
||||
}
|
||||
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
tag_names = sorted(site.tags.values_list('name', flat=True))
|
||||
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
|
||||
|
||||
def test_create_with_remove_tags_error(self):
|
||||
"""
|
||||
Using remove_tags when creating a new object should raise a validation error.
|
||||
"""
|
||||
Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
))
|
||||
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
data = {
|
||||
'name': 'Site 10',
|
||||
'slug': 'site-10',
|
||||
'remove_tags': [{'name': 'Alpha'}],
|
||||
}
|
||||
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_add_and_remove_same_tag_error(self):
|
||||
"""
|
||||
Including the same tag in both add_tags and remove_tags should raise a validation error.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
Tag.objects.bulk_create((
|
||||
Tag(name='Alpha', slug='alpha'),
|
||||
Tag(name='Bravo', slug='bravo'),
|
||||
))
|
||||
|
||||
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
url = self._get_detail_url(site)
|
||||
data = {
|
||||
'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
|
||||
'remove_tags': [{'name': 'Alpha'}],
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class LocationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Location
|
||||
@@ -280,6 +497,38 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
|
||||
|
||||
class RackGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackGroup
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Rack Group 4',
|
||||
'slug': 'rack-group-4',
|
||||
},
|
||||
{
|
||||
'name': 'Rack Group 5',
|
||||
'slug': 'rack-group-5',
|
||||
},
|
||||
{
|
||||
'name': 'Rack Group 6',
|
||||
'slug': 'rack-group-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2'),
|
||||
RackGroup(name='Rack Group 3', slug='rack-group-3'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
|
||||
class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackRole
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
@@ -397,6 +646,12 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
|
||||
)
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
rack_roles = (
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
|
||||
@@ -404,9 +659,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
RackRole.objects.bulk_create(rack_roles)
|
||||
|
||||
racks = (
|
||||
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'),
|
||||
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'),
|
||||
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'),
|
||||
Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
|
||||
Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
|
||||
Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
@@ -415,18 +670,21 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Test Rack 4',
|
||||
'site': sites[1].pk,
|
||||
'location': locations[1].pk,
|
||||
'group': rack_groups[1].pk,
|
||||
'role': rack_roles[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Rack 5',
|
||||
'site': sites[1].pk,
|
||||
'location': locations[1].pk,
|
||||
'group': rack_groups[1].pk,
|
||||
'role': rack_roles[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Rack 6',
|
||||
'site': sites[1].pk,
|
||||
'location': locations[1].pk,
|
||||
'group': rack_groups[1].pk,
|
||||
'role': rack_roles[1].pk,
|
||||
},
|
||||
]
|
||||
@@ -529,6 +787,15 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_unit_count(self):
|
||||
"""unit_count should reflect the number of units in the reservation."""
|
||||
url = reverse('dcim-api:rackreservation-list')
|
||||
self.add_permissions('dcim.view_rackreservation')
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertHttpStatus(response, 200)
|
||||
for result in response.data['results']:
|
||||
self.assertEqual(result['unit_count'], len(result['units']))
|
||||
|
||||
|
||||
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Manufacturer
|
||||
@@ -1185,7 +1452,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBayTemplate
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
@@ -1202,9 +1469,9 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
|
||||
module_bay_templates = (
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1', enabled=True),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2', enabled=False),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3', enabled=True),
|
||||
)
|
||||
ModuleBayTemplate.objects.bulk_create(module_bay_templates)
|
||||
|
||||
@@ -1212,6 +1479,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Module Bay Template 4',
|
||||
'enabled': False,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
@@ -1226,7 +1494,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBayTemplate
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
@@ -1243,9 +1511,9 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
|
||||
device_bay_templates = (
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1', enabled=True),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2', enabled=False),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3', enabled=True),
|
||||
)
|
||||
DeviceBayTemplate.objects.bulk_create(device_bay_templates)
|
||||
|
||||
@@ -1253,6 +1521,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Device Bay Template 4',
|
||||
'enabled': False,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
@@ -1699,6 +1968,238 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_replicate_components(self):
|
||||
"""
|
||||
Installing a module with replicate_components=True (the default) should create
|
||||
components from the module type's templates on the parent device.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Replication Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Replication Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Replication Bay')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
'replicate_components': True,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertTrue(device.interfaces.filter(name='eth0').exists())
|
||||
|
||||
def test_no_replicate_components(self):
|
||||
"""
|
||||
Installing a module with replicate_components=False should NOT create components
|
||||
from the module type's templates.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for No Replication Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='No Replication Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
module_bay = ModuleBay.objects.create(device=device, name='No Replication Bay')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
'replicate_components': False,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertFalse(device.interfaces.filter(name='eth0').exists())
|
||||
|
||||
def test_adopt_components(self):
|
||||
"""
|
||||
Installing a module with adopt_components=True should assign existing unattached
|
||||
device components to the new module.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Adopt Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Adopt Bay')
|
||||
existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
'adopt_components': True,
|
||||
'replicate_components': False,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
existing_iface.refresh_from_db()
|
||||
self.assertIsNotNone(existing_iface.module)
|
||||
|
||||
def test_replicate_components_conflict(self):
|
||||
"""
|
||||
Installing a module with replicate_components=True when a component with the same name
|
||||
already exists should return a validation error.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Conflict Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Conflict Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Conflict Bay')
|
||||
Interface.objects.create(device=device, name='eth0', type='1000base-t')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
'replicate_components': True,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_adopt_components_already_owned(self):
|
||||
"""
|
||||
Installing a module with adopt_components=True when an existing component already
|
||||
belongs to another module should return a validation error.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Adopt Owned Test')
|
||||
owner_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Owner Module Type')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Owned Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
owner_bay = ModuleBay.objects.create(device=device, name='Owner Bay')
|
||||
target_bay = ModuleBay.objects.create(device=device, name='Adopt Owned Bay')
|
||||
|
||||
# Install a module that owns the interface
|
||||
owner_module = Module.objects.create(device=device, module_bay=owner_bay, module_type=owner_module_type)
|
||||
Interface.objects.create(device=device, name='eth0', type='1000base-t', module=owner_module)
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': target_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
'adopt_components': True,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_patch_ignores_replicate_and_adopt(self):
|
||||
"""
|
||||
PATCH requests that include replicate_components or adopt_components should not
|
||||
trigger component replication or adoption (these fields are create-only).
|
||||
"""
|
||||
self.add_permissions('dcim.change_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for PATCH Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='PATCH Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
module_bay = ModuleBay.objects.create(device=device, name='PATCH Bay')
|
||||
# Create the module without replication so we can verify PATCH doesn't trigger it
|
||||
module = Module(device=device, module_bay=module_bay, module_type=module_type)
|
||||
module._disable_replication = True
|
||||
module.save()
|
||||
|
||||
url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
|
||||
data = {
|
||||
'replicate_components': True,
|
||||
'adopt_components': True,
|
||||
'serial': 'PATCHED',
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['serial'], 'PATCHED')
|
||||
# No interfaces should have been created by the PATCH
|
||||
self.assertFalse(device.interfaces.exists())
|
||||
|
||||
def test_adopt_and_replicate_components(self):
|
||||
"""
|
||||
Installing a module with both adopt_components=True and replicate_components=True
|
||||
should adopt existing unowned components and create new components for templates
|
||||
that have no matching existing component.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Adopt+Replicate Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt+Replicate Test Module Type')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
|
||||
InterfaceTemplate.objects.create(module_type=module_type, name='eth1', type='1000base-t')
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Adopt+Replicate Bay')
|
||||
# eth0 already exists (unowned); eth1 does not
|
||||
existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
'adopt_components': True,
|
||||
'replicate_components': True,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
# eth0 should have been adopted (now owned by the new module)
|
||||
existing_iface.refresh_from_db()
|
||||
self.assertIsNotNone(existing_iface.module)
|
||||
# eth1 should have been created
|
||||
self.assertTrue(device.interfaces.filter(name='eth1').exists())
|
||||
|
||||
def test_module_token_no_position(self):
|
||||
"""
|
||||
Installing a module whose type has a template with a MODULE_TOKEN placeholder into a
|
||||
module bay with no position defined should return a validation error.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Token No-Position Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token No-Position Module Type')
|
||||
# Template name contains the MODULE_TOKEN placeholder
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type, name=f'{MODULE_TOKEN}-eth0', type='1000base-t'
|
||||
)
|
||||
# Module bay has no position
|
||||
module_bay = ModuleBay.objects.create(device=device, name='No-Position Bay')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_module_token_depth_mismatch(self):
|
||||
"""
|
||||
Installing a module whose template name has more MODULE_TOKEN placeholders than the
|
||||
depth of the module bay tree should return a validation error.
|
||||
"""
|
||||
self.add_permissions('dcim.add_module')
|
||||
manufacturer = Manufacturer.objects.get(name='Generic')
|
||||
device = create_test_device('Device for Token Depth Mismatch Test')
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Depth Mismatch Module Type')
|
||||
# Template name has two placeholders but the bay is at depth 1
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type, name=f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', type='1000base-t'
|
||||
)
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Depth 1 Bay', position='1')
|
||||
|
||||
url = reverse('dcim-api:module-list')
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'module_bay': module_bay.pk,
|
||||
'module_type': module_type.pk,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePort
|
||||
@@ -2321,7 +2822,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
@@ -2337,9 +2838,9 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=device, name='Device Bay 1'),
|
||||
ModuleBay(device=device, name='Device Bay 2'),
|
||||
ModuleBay(device=device, name='Device Bay 3'),
|
||||
ModuleBay(device=device, name='Device Bay 1', enabled=True),
|
||||
ModuleBay(device=device, name='Device Bay 2', enabled=False),
|
||||
ModuleBay(device=device, name='Device Bay 3', enabled=True),
|
||||
)
|
||||
for module_bay in module_bays:
|
||||
module_bay.save()
|
||||
@@ -2348,6 +2849,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Device Bay 4',
|
||||
'enabled': False,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
@@ -2362,7 +2864,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBay
|
||||
brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
|
||||
brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
@@ -2399,9 +2901,9 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
device_bays = (
|
||||
DeviceBay(device=devices[0], name='Device Bay 1'),
|
||||
DeviceBay(device=devices[0], name='Device Bay 2'),
|
||||
DeviceBay(device=devices[0], name='Device Bay 3'),
|
||||
DeviceBay(device=devices[0], name='Device Bay 1', enabled=True),
|
||||
DeviceBay(device=devices[0], name='Device Bay 2', enabled=False),
|
||||
DeviceBay(device=devices[0], name='Device Bay 3', enabled=True),
|
||||
)
|
||||
DeviceBay.objects.bulk_create(device_bays)
|
||||
|
||||
@@ -2526,6 +3028,60 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
InventoryItemRole.objects.bulk_create(roles)
|
||||
|
||||
|
||||
class CableBundleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CableBundle
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{'name': 'Cable Bundle 4'},
|
||||
{'name': 'Cable Bundle 5'},
|
||||
{'name': 'Cable Bundle 6'},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cable_bundles = (
|
||||
CableBundle(name='Cable Bundle 1'),
|
||||
CableBundle(name='Cable Bundle 2'),
|
||||
CableBundle(name='Cable Bundle 3'),
|
||||
)
|
||||
CableBundle.objects.bulk_create(cable_bundles)
|
||||
|
||||
def test_cable_count(self):
|
||||
"""cable_count annotation is returned correctly in the API response."""
|
||||
self.add_permissions('dcim.view_cablebundle')
|
||||
bundle = CableBundle.objects.first()
|
||||
|
||||
site = Site.objects.create(name='CB Test Site', slug='cb-test-site')
|
||||
manufacturer = Manufacturer.objects.create(name='CB Manufacturer', slug='cb-manufacturer')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='CB Device Type', slug='cb-device-type'
|
||||
)
|
||||
role = DeviceRole.objects.create(name='CB Role', slug='cb-role', color='ff0000')
|
||||
devices = (
|
||||
Device(device_type=device_type, role=role, name='CB Device 1', site=site),
|
||||
Device(device_type=device_type, role=role, name='CB Device 2', site=site),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[0], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[1], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
for a, b in [(interfaces[0], interfaces[2]), (interfaces[1], interfaces[3])]:
|
||||
cable = Cable(a_terminations=[a], b_terminations=[b], bundle=bundle)
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:cablebundle-detail', kwargs={'pk': bundle.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['cable_count'], 2)
|
||||
|
||||
|
||||
class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Cable
|
||||
brief_fields = ['description', 'display', 'id', 'label', 'url']
|
||||
|
||||
@@ -534,6 +534,37 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class RackGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = RackGroup.objects.all()
|
||||
filterset = RackGroupFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1', description='foobar1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2', description='foobar2'),
|
||||
RackGroup(name='Rack Group 3', slug='rack-group-3'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Rack Group 1', 'Rack Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['rack-group-1', 'rack-group-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = RackRole.objects.all()
|
||||
filterset = RackRoleFilterSet
|
||||
@@ -738,18 +769,18 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
for region in regions:
|
||||
region.save()
|
||||
|
||||
groups = (
|
||||
site_groups = (
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
SiteGroup(name='Site Group 2', slug='site-group-2'),
|
||||
SiteGroup(name='Site Group 3', slug='site-group-3'),
|
||||
)
|
||||
for group in groups:
|
||||
group.save()
|
||||
for site_group in site_groups:
|
||||
site_group.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
@@ -810,6 +841,13 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
RackType.objects.bulk_create(rack_types)
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2'),
|
||||
RackGroup(name='Rack Group 3', slug='rack-group-3'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
rack_roles = (
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2'),
|
||||
@@ -838,6 +876,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
facility_id='rack-1',
|
||||
site=sites[0],
|
||||
location=locations[0],
|
||||
group=rack_groups[0],
|
||||
tenant=tenants[0],
|
||||
status=RackStatusChoices.STATUS_ACTIVE,
|
||||
role=rack_roles[0],
|
||||
@@ -862,6 +901,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
facility_id='rack-2',
|
||||
site=sites[1],
|
||||
location=locations[1],
|
||||
group=rack_groups[1],
|
||||
tenant=tenants[1],
|
||||
status=RackStatusChoices.STATUS_PLANNED,
|
||||
role=rack_roles[1],
|
||||
@@ -886,6 +926,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
facility_id='rack-3',
|
||||
site=sites[2],
|
||||
location=locations[2],
|
||||
group=rack_groups[2],
|
||||
tenant=tenants[2],
|
||||
status=RackStatusChoices.STATUS_RESERVED,
|
||||
role=rack_roles[2],
|
||||
@@ -1017,6 +1058,13 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rack_group(self):
|
||||
rack_groups = RackGroup.objects.all()[:2]
|
||||
params = {'group_id': [rack_groups[0].pk, rack_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [rack_groups[0].slug, rack_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [RackStatusChoices.STATUS_ACTIVE, RackStatusChoices.STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
@@ -1095,18 +1143,18 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
for region in regions:
|
||||
region.save()
|
||||
|
||||
groups = (
|
||||
site_groups = (
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
SiteGroup(name='Site Group 2', slug='site-group-2'),
|
||||
SiteGroup(name='Site Group 3', slug='site-group-3'),
|
||||
)
|
||||
for group in groups:
|
||||
group.save()
|
||||
for site_group in site_groups:
|
||||
site_group.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
@@ -1118,10 +1166,17 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2'),
|
||||
RackGroup(name='Rack Group 3', slug='rack-group-3'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', site=sites[0], location=locations[0]),
|
||||
Rack(name='Rack 2', site=sites[1], location=locations[1]),
|
||||
Rack(name='Rack 3', site=sites[2], location=locations[2]),
|
||||
Rack(name='Rack 1', site=sites[0], location=locations[0], group=rack_groups[0]),
|
||||
Rack(name='Rack 2', site=sites[1], location=locations[1], group=rack_groups[1]),
|
||||
Rack(name='Rack 3', site=sites[2], location=locations[2], group=rack_groups[2]),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
@@ -1150,7 +1205,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
reservations = (
|
||||
RackReservation(
|
||||
rack=racks[0],
|
||||
units=[1, 2, 3],
|
||||
units=[1, 2],
|
||||
status=RackReservationStatusChoices.STATUS_ACTIVE,
|
||||
user=users[0],
|
||||
tenant=tenants[0],
|
||||
@@ -1158,7 +1213,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
),
|
||||
RackReservation(
|
||||
rack=racks[1],
|
||||
units=[4, 5, 6],
|
||||
units=[1, 2, 3],
|
||||
status=RackReservationStatusChoices.STATUS_PENDING,
|
||||
user=users[1],
|
||||
tenant=tenants[1],
|
||||
@@ -1166,7 +1221,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
),
|
||||
RackReservation(
|
||||
rack=racks[2],
|
||||
units=[7, 8, 9],
|
||||
units=[1, 2, 3, 4],
|
||||
status=RackReservationStatusChoices.STATUS_STALE,
|
||||
user=users[2],
|
||||
tenant=tenants[2],
|
||||
@@ -1207,6 +1262,13 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rack_group(self):
|
||||
rack_groups = RackGroup.objects.all()[:2]
|
||||
params = {'group_id': [rack_groups[0].pk, rack_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [rack_groups[0].slug, rack_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1229,6 +1291,14 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_unit_count(self):
|
||||
params = {'unit_count_min': 3}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'unit_count_max': 3}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'unit_count_min': 3, 'unit_count_max': 3}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_tenant_group(self):
|
||||
tenant_groups = TenantGroup.objects.all()[:2]
|
||||
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
|
||||
@@ -2185,13 +2255,21 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
|
||||
ModuleBayTemplate.objects.bulk_create(
|
||||
(
|
||||
ModuleBayTemplate(
|
||||
device_type=device_types[0], name='Module Bay 1', description='foobar1'
|
||||
device_type=device_types[0], name='Module Bay 1', enabled=True, description='foobar1'
|
||||
),
|
||||
ModuleBayTemplate(
|
||||
device_type=device_types[1], name='Module Bay 2', description='foobar2', module_type=module_types[0]
|
||||
device_type=device_types[1],
|
||||
name='Module Bay 2',
|
||||
enabled=False,
|
||||
description='foobar2',
|
||||
module_type=module_types[0],
|
||||
),
|
||||
ModuleBayTemplate(
|
||||
device_type=device_types[2], name='Module Bay 3', description='foobar3', module_type=module_types[1]
|
||||
device_type=device_types[2],
|
||||
name='Module Bay 3',
|
||||
enabled=True,
|
||||
description='foobar3',
|
||||
module_type=module_types[1],
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -2200,6 +2278,12 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
|
||||
params = {'name': ['Module Bay 1', 'Module Bay 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_module_type(self):
|
||||
module_types = ModuleType.objects.all()[:2]
|
||||
params = {'module_type_id': [module_types[0].pk, module_types[1].pk]}
|
||||
@@ -2222,16 +2306,30 @@ class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
DeviceBayTemplate.objects.bulk_create((
|
||||
DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1', description='foobar1'),
|
||||
DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2', description='foobar2'),
|
||||
DeviceBayTemplate(device_type=device_types[2], name='Device Bay 3', description='foobar3'),
|
||||
))
|
||||
DeviceBayTemplate.objects.bulk_create(
|
||||
(
|
||||
DeviceBayTemplate(
|
||||
device_type=device_types[0], name='Device Bay 1', enabled=True, description='foobar1'
|
||||
),
|
||||
DeviceBayTemplate(
|
||||
device_type=device_types[1], name='Device Bay 2', enabled=False, description='foobar2'
|
||||
),
|
||||
DeviceBayTemplate(
|
||||
device_type=device_types[2], name='Device Bay 3', enabled=True, description='foobar3'
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Device Bay 1', 'Device Bay 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class InventoryItemTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = InventoryItemTemplate.objects.all()
|
||||
@@ -5716,11 +5814,11 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
module_bays = (
|
||||
ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 4', label='D', description='Fourth'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 5', label='E', description='Fifth'),
|
||||
ModuleBay(device=devices[0], name='Module Bay 1', label='A', enabled=True, description='First'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2', label='B', enabled=False, description='Second'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3', label='C', enabled=True, description='Third'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 4', label='D', enabled=False, description='Fourth'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 5', label='E', enabled=True, description='Fifth'),
|
||||
)
|
||||
for module_bay in module_bays:
|
||||
module_bay.save()
|
||||
@@ -5744,6 +5842,12 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'enabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -5903,6 +6007,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
device=devices[0],
|
||||
name='Device Bay 1',
|
||||
label='A',
|
||||
enabled=True,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
@@ -5912,6 +6017,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
device=devices[1],
|
||||
name='Device Bay 2',
|
||||
label='B',
|
||||
enabled=False,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
@@ -5921,6 +6027,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
device=devices[2],
|
||||
name='Device Bay 3',
|
||||
label='C',
|
||||
enabled=True,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
@@ -5937,6 +6044,12 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -6409,6 +6522,32 @@ class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CableBundleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = CableBundle.objects.all()
|
||||
filterset = CableBundleFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cable_bundles = (
|
||||
CableBundle(name='Cable Bundle 1', description='foobar1'),
|
||||
CableBundle(name='Cable Bundle 2', description='foobar2'),
|
||||
CableBundle(name='Cable Bundle 3'),
|
||||
)
|
||||
CableBundle.objects.bulk_create(cable_bundles)
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Cable Bundle 1', 'Cable Bundle 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Cable.objects.all()
|
||||
filterset = CableFilterSet
|
||||
|
||||
@@ -10,7 +10,9 @@ from dcim.choices import (
|
||||
)
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
from ipam.models import VLAN
|
||||
from ipam.models import ASN, RIR, VLAN
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.forms.rendering import M2MAddRemoveFields
|
||||
from utilities.testing import create_test_device
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@@ -175,6 +177,88 @@ class DeviceTestCase(TestCase):
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
class VCPositionTokenFormTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
Site.objects.create(name='Site VC 1', slug='site-vc-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer VC 1', slug='manufacturer-vc-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type VC 1', slug='device-type-vc-1'
|
||||
)
|
||||
DeviceRole.objects.create(name='Device Role VC 1', slug='device-role-vc-1', color='ff0000')
|
||||
InterfaceTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='ge-{vc_position:0}/0/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
VirtualChassis.objects.create(name='VC 1')
|
||||
|
||||
def test_device_creation_in_vc_resolves_vc_position(self):
|
||||
form = DeviceForm(data={
|
||||
'name': 'Device VC Form 1',
|
||||
'role': DeviceRole.objects.first().pk,
|
||||
'tenant': None,
|
||||
'manufacturer': Manufacturer.objects.first().pk,
|
||||
'device_type': DeviceType.objects.first().pk,
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
'virtual_chassis': VirtualChassis.objects.first().pk,
|
||||
'vc_position': 2,
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
device = form.save()
|
||||
self.assertTrue(device.interfaces.filter(name='ge-2/0/0').exists())
|
||||
|
||||
def test_device_creation_not_in_vc_uses_fallback(self):
|
||||
form = DeviceForm(data={
|
||||
'name': 'Device VC Form 2',
|
||||
'role': DeviceRole.objects.first().pk,
|
||||
'tenant': None,
|
||||
'manufacturer': Manufacturer.objects.first().pk,
|
||||
'device_type': DeviceType.objects.first().pk,
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
device = form.save()
|
||||
self.assertTrue(device.interfaces.filter(name='ge-0/0/0').exists())
|
||||
|
||||
def test_device_creation_duplicate_name_conflict(self):
|
||||
# With conflict
|
||||
device_type = DeviceType.objects.first()
|
||||
# to generate conflicts create an interface that will exist
|
||||
InterfaceTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='ge-0/0/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
form = DeviceForm(data={
|
||||
'name': 'Device VC Form 3',
|
||||
'role': DeviceRole.objects.first().pk,
|
||||
'tenant': None,
|
||||
'manufacturer': Manufacturer.objects.first().pk,
|
||||
'device_type': device_type.pk,
|
||||
'site': Site.objects.first().pk,
|
||||
'rack': None,
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
with self.assertRaises(AbortRequest):
|
||||
form.save()
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
@@ -417,3 +501,111 @@ class InterfaceTestCase(TestCase):
|
||||
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
|
||||
class SiteFormTestCase(TestCase):
|
||||
"""
|
||||
Tests for M2MAddRemoveFields using Site ASN assignments as the test case.
|
||||
Covers both simple mode (single multi-select field) and add/remove mode (dual fields).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
# Create 110 ASNs: 100 to pre-assign (triggering add/remove mode) plus 10 extras
|
||||
ASN.objects.bulk_create([ASN(asn=i, rir=cls.rir) for i in range(1, 111)])
|
||||
cls.asns = list(ASN.objects.order_by('asn'))
|
||||
|
||||
def _site_data(self, **kwargs):
|
||||
data = {'name': 'Test Site', 'slug': 'test-site', 'status': 'active'}
|
||||
data.update(kwargs)
|
||||
return data
|
||||
|
||||
def test_new_site_uses_simple_mode(self):
|
||||
"""A form for a new site uses the single 'asns' field (simple mode)."""
|
||||
form = SiteForm(data=self._site_data())
|
||||
self.assertIn('asns', form.fields)
|
||||
self.assertNotIn('add_asns', form.fields)
|
||||
self.assertNotIn('remove_asns', form.fields)
|
||||
|
||||
def test_existing_site_below_threshold_uses_simple_mode(self):
|
||||
"""A form for an existing site with fewer than THRESHOLD ASNs uses simple mode."""
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site.asns.set(self.asns[:5])
|
||||
form = SiteForm(instance=site)
|
||||
self.assertIn('asns', form.fields)
|
||||
self.assertNotIn('add_asns', form.fields)
|
||||
self.assertNotIn('remove_asns', form.fields)
|
||||
|
||||
def test_existing_site_at_threshold_uses_add_remove_mode(self):
|
||||
"""A form for an existing site with THRESHOLD or more ASNs uses add/remove mode."""
|
||||
site = Site.objects.create(name='Site 2', slug='site-2')
|
||||
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
|
||||
form = SiteForm(instance=site)
|
||||
self.assertNotIn('asns', form.fields)
|
||||
self.assertIn('add_asns', form.fields)
|
||||
self.assertIn('remove_asns', form.fields)
|
||||
|
||||
def test_simple_mode_assigns_asns_on_create(self):
|
||||
"""Saving a new site via simple mode assigns the selected ASNs."""
|
||||
asn_pks = [asn.pk for asn in self.asns[:3]]
|
||||
form = SiteForm(data=self._site_data(asns=asn_pks))
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
site = form.save()
|
||||
self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(asn_pks))
|
||||
|
||||
def test_simple_mode_replaces_asns_on_edit(self):
|
||||
"""Saving an existing site via simple mode replaces the current ASN assignments."""
|
||||
site = Site.objects.create(name='Site 3', slug='site-3')
|
||||
site.asns.set(self.asns[:3])
|
||||
new_asn_pks = [asn.pk for asn in self.asns[3:6]]
|
||||
form = SiteForm(
|
||||
data=self._site_data(name='Site 3', slug='site-3', asns=new_asn_pks),
|
||||
instance=site
|
||||
)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
site = form.save()
|
||||
self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(new_asn_pks))
|
||||
|
||||
def test_add_remove_mode_adds_asns(self):
|
||||
"""In add/remove mode, specifying 'add_asns' appends to current assignments."""
|
||||
site = Site.objects.create(name='Site 4', slug='site-4')
|
||||
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
|
||||
new_asn_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:]]
|
||||
form = SiteForm(
|
||||
data=self._site_data(name='Site 4', slug='site-4', add_asns=new_asn_pks),
|
||||
instance=site
|
||||
)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
site = form.save()
|
||||
self.assertEqual(site.asns.count(), len(self.asns))
|
||||
|
||||
def test_add_remove_mode_removes_asns(self):
|
||||
"""In add/remove mode, specifying 'remove_asns' drops those assignments."""
|
||||
site = Site.objects.create(name='Site 5', slug='site-5')
|
||||
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
|
||||
remove_pks = [asn.pk for asn in self.asns[:5]]
|
||||
form = SiteForm(
|
||||
data=self._site_data(name='Site 5', slug='site-5', remove_asns=remove_pks),
|
||||
instance=site
|
||||
)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
site = form.save()
|
||||
self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD - 5)
|
||||
self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())
|
||||
|
||||
def test_add_remove_mode_simultaneous_add_and_remove(self):
|
||||
"""In add/remove mode, add and remove operations are applied together."""
|
||||
site = Site.objects.create(name='Site 6', slug='site-6')
|
||||
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
|
||||
add_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:M2MAddRemoveFields.THRESHOLD + 3]]
|
||||
remove_pks = [asn.pk for asn in self.asns[:3]]
|
||||
form = SiteForm(
|
||||
data=self._site_data(name='Site 6', slug='site-6', add_asns=add_pks, remove_asns=remove_pks),
|
||||
instance=site
|
||||
)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
site = form.save()
|
||||
self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD)
|
||||
self.assertTrue(site.asns.filter(pk__in=add_pks).count() == 3)
|
||||
self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())
|
||||
|
||||
@@ -712,6 +712,112 @@ class DeviceTestCase(TestCase):
|
||||
).full_clean()
|
||||
|
||||
|
||||
class DeviceBayTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
|
||||
# Parent device type must support device bays (is_parent_device=True)
|
||||
parent_device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Parent Device Type',
|
||||
slug='parent-device-type',
|
||||
subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
|
||||
)
|
||||
# Child device type for installation
|
||||
child_device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Child Device Type',
|
||||
slug='child-device-type',
|
||||
u_height=0,
|
||||
subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
)
|
||||
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
|
||||
cls.parent_device = Device.objects.create(
|
||||
name='Parent Device',
|
||||
device_type=parent_device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
cls.child_device = Device.objects.create(
|
||||
name='Child Device',
|
||||
device_type=child_device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
cls.child_device_2 = Device.objects.create(
|
||||
name='Child Device 2',
|
||||
device_type=child_device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
def test_cannot_install_device_in_disabled_bay(self):
|
||||
"""
|
||||
Test that a device cannot be installed into a disabled DeviceBay.
|
||||
"""
|
||||
# Create a disabled device bay with a device being installed
|
||||
device_bay = DeviceBay(
|
||||
device=self.parent_device,
|
||||
name='Disabled Bay',
|
||||
enabled=False,
|
||||
installed_device=self.child_device
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
device_bay.clean()
|
||||
|
||||
self.assertIn('installed_device', cm.exception.message_dict)
|
||||
self.assertIn('disabled device bay', str(cm.exception.message_dict['installed_device']))
|
||||
|
||||
def test_can_disable_bay_with_existing_device(self):
|
||||
"""
|
||||
Test that disabling a bay that already has a device installed does NOT raise an error
|
||||
(same installed_device_id).
|
||||
"""
|
||||
# First, create an enabled device bay with a device installed
|
||||
device_bay = DeviceBay.objects.create(
|
||||
device=self.parent_device,
|
||||
name='Bay To Disable',
|
||||
enabled=True,
|
||||
installed_device=self.child_device
|
||||
)
|
||||
|
||||
# Now disable the bay while keeping the same installed device
|
||||
device_bay.enabled = False
|
||||
# This should NOT raise a ValidationError
|
||||
device_bay.clean()
|
||||
device_bay.save()
|
||||
|
||||
device_bay.refresh_from_db()
|
||||
self.assertFalse(device_bay.enabled)
|
||||
self.assertEqual(device_bay.installed_device, self.child_device)
|
||||
|
||||
def test_cannot_change_installed_device_in_disabled_bay(self):
|
||||
"""
|
||||
Test that changing the installed device in a disabled bay raises a ValidationError.
|
||||
"""
|
||||
# Create an enabled device bay with a device installed
|
||||
device_bay = DeviceBay.objects.create(
|
||||
device=self.parent_device,
|
||||
name='Bay With Device',
|
||||
enabled=True,
|
||||
installed_device=self.child_device
|
||||
)
|
||||
|
||||
# Disable the bay and try to change the installed device
|
||||
device_bay.enabled = False
|
||||
device_bay.installed_device = self.child_device_2
|
||||
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
device_bay.clean()
|
||||
|
||||
self.assertIn('installed_device', cm.exception.message_dict)
|
||||
|
||||
|
||||
class ModuleBayTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
@@ -849,6 +955,50 @@ class ModuleBayTestCase(TestCase):
|
||||
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||
self.assertEqual(nested_bay.label, 'A-21')
|
||||
|
||||
@tag('regression') # #20467
|
||||
def test_nested_module_bay_position_resolution(self):
|
||||
"""Test that {module} in a module bay template's position field is resolved when the module is installed."""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Device with Position Test',
|
||||
slug='device-with-position-test'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Slot 1',
|
||||
position='1'
|
||||
)
|
||||
|
||||
module_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Module with Position Placeholder'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='Sub-bay {module}-1',
|
||||
position='{module}-1'
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Position Test Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
module_bay = device.modulebays.get(name='Slot 1')
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=module_bay,
|
||||
module_type=module_type
|
||||
)
|
||||
|
||||
nested_bay = module.modulebays.get(name='Sub-bay 1-1')
|
||||
self.assertEqual(nested_bay.position, '1-1')
|
||||
|
||||
@tag('regression') # #20912
|
||||
def test_module_bay_parent_cleared_when_module_removed(self):
|
||||
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
|
||||
@@ -1011,6 +1161,25 @@ class ModuleBayTestCase(TestCase):
|
||||
self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
|
||||
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
|
||||
|
||||
def test_cannot_install_module_in_disabled_bay(self):
|
||||
"""
|
||||
Test that a Module cannot be installed into a disabled ModuleBay.
|
||||
"""
|
||||
device = Device.objects.first()
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type Disabled')
|
||||
|
||||
# Create a disabled module bay
|
||||
disabled_bay = ModuleBay.objects.create(device=device, name='Disabled Bay', enabled=False)
|
||||
|
||||
# Attempt to install a module into the disabled bay
|
||||
module = Module(device=device, module_bay=disabled_bay, module_type=module_type)
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
module.clean()
|
||||
|
||||
self.assertIn('module_bay', cm.exception.message_dict)
|
||||
self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay']))
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
@@ -1373,6 +1542,167 @@ class VirtualChassisTestCase(TestCase):
|
||||
device2.full_clean()
|
||||
|
||||
|
||||
class VCPositionTokenTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
ModuleType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Module Type 1'
|
||||
)
|
||||
DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
|
||||
def test_vc_position_token_in_vc(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
module_type = ModuleType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='ge-{vc_position}/{module}/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
vc = VirtualChassis.objects.create(name='Test VC 1')
|
||||
device = Device.objects.create(
|
||||
name='Device VC 1', device_type=device_type, role=device_role,
|
||||
site=site, virtual_chassis=vc, vc_position=8,
|
||||
)
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
|
||||
Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
|
||||
|
||||
interface = device.interfaces.get(name='ge-8/1/0')
|
||||
self.assertEqual(interface.name, 'ge-8/1/0')
|
||||
|
||||
def test_vc_position_token_not_in_vc_default_fallback(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
module_type = ModuleType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='ge-{vc_position}/{module}/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
device = Device.objects.create(
|
||||
name='Device NoVC 1', device_type=device_type, role=device_role,
|
||||
site=site,
|
||||
)
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
|
||||
Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
|
||||
|
||||
interface = device.interfaces.get(name='ge-0/1/0')
|
||||
self.assertEqual(interface.name, 'ge-0/1/0')
|
||||
|
||||
def test_vc_position_token_explicit_fallback(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
module_type = ModuleType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='ge-{vc_position:18}/{module}/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
device = Device.objects.create(
|
||||
name='Device NoVC 2', device_type=device_type, role=device_role,
|
||||
site=site,
|
||||
)
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
|
||||
Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
|
||||
|
||||
interface = device.interfaces.get(name='ge-18/1/0')
|
||||
self.assertEqual(interface.name, 'ge-18/1/0')
|
||||
|
||||
def test_vc_position_token_explicit_fallback_ignored_when_in_vc(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
module_type = ModuleType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='ge-{vc_position:99}/{module}/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
vc = VirtualChassis.objects.create(name='Test VC 2')
|
||||
device = Device.objects.create(
|
||||
name='Device VC 2', device_type=device_type, role=device_role,
|
||||
site=site, virtual_chassis=vc, vc_position=2,
|
||||
)
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
|
||||
Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
|
||||
|
||||
interface = device.interfaces.get(name='ge-2/1/0')
|
||||
self.assertEqual(interface.name, 'ge-2/1/0')
|
||||
|
||||
def test_vc_position_token_device_type_template(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='ge-{vc_position:0}/0/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
vc = VirtualChassis.objects.create(name='Test VC 3')
|
||||
device = Device.objects.create(
|
||||
name='Device VC 3', device_type=device_type, role=device_role,
|
||||
site=site, virtual_chassis=vc, vc_position=3,
|
||||
)
|
||||
|
||||
interface = device.interfaces.get(name='ge-3/0/0')
|
||||
self.assertEqual(interface.name, 'ge-3/0/0')
|
||||
|
||||
def test_vc_position_token_device_type_template_not_in_vc(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='ge-{vc_position:0}/0/0',
|
||||
type='1000base-t',
|
||||
)
|
||||
device = Device.objects.create(
|
||||
name='Device NoVC 3', device_type=device_type, role=device_role,
|
||||
site=site,
|
||||
)
|
||||
|
||||
interface = device.interfaces.get(name='ge-0/0/0')
|
||||
self.assertEqual(interface.name, 'ge-0/0/0')
|
||||
|
||||
def test_vc_position_token_label_resolution(self):
|
||||
site = Site.objects.first()
|
||||
device_type = DeviceType.objects.first()
|
||||
module_type = ModuleType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='ge-{vc_position}/{module}/0',
|
||||
label='Member {vc_position:0} / Slot {module}',
|
||||
type='1000base-t',
|
||||
)
|
||||
vc = VirtualChassis.objects.create(name='Test VC 4')
|
||||
device = Device.objects.create(
|
||||
name='Device VC 4', device_type=device_type, role=device_role,
|
||||
site=site, virtual_chassis=vc, vc_position=2,
|
||||
)
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
|
||||
Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
|
||||
|
||||
interface = device.interfaces.get(name='ge-2/1/0')
|
||||
self.assertEqual(interface.label, 'Member 2 / Slot 1')
|
||||
|
||||
|
||||
class SiteSignalTestCase(TestCase):
|
||||
|
||||
@tag('regression')
|
||||
|
||||
@@ -3,11 +3,13 @@ from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
from core.models import ObjectType
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
@@ -267,6 +269,47 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
}
|
||||
|
||||
|
||||
class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = RackGroup
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2'),
|
||||
RackGroup(name='Rack Group 3', slug='rack-group-3'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Rack Group X',
|
||||
'slug': 'rack-group-x',
|
||||
'description': 'New group',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,description",
|
||||
"Rack Group 4,rack-group-4,Fourth group",
|
||||
"Rack Group 5,rack-group-5,Fifth group",
|
||||
"Rack Group 6,rack-group-6,",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
f"{rack_groups[0].pk},Rack Group 7,New description7",
|
||||
f"{rack_groups[1].pk},Rack Group 8,New description8",
|
||||
f"{rack_groups[2].pk},Rack Group 9,New description9",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
|
||||
class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = RackRole
|
||||
|
||||
@@ -472,6 +515,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
for location in locations:
|
||||
location.save()
|
||||
|
||||
rack_groups = (
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1'),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2'),
|
||||
)
|
||||
RackGroup.objects.bulk_create(rack_groups)
|
||||
|
||||
rackroles = (
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2'),
|
||||
@@ -479,8 +528,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
RackRole.objects.bulk_create(rackroles)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', site=sites[0]),
|
||||
Rack(name='Rack 2', site=sites[0]),
|
||||
Rack(name='Rack 1', site=sites[0], group=rack_groups[0], role=rackroles[0]),
|
||||
Rack(name='Rack 2', site=sites[0], group=rack_groups[1]),
|
||||
Rack(name='Rack 3', site=sites[0]),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
@@ -492,6 +541,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'facility_id': 'Facility X',
|
||||
'site': sites[1].pk,
|
||||
'location': locations[1].pk,
|
||||
'group': rack_groups[1].pk,
|
||||
'tenant': None,
|
||||
'status': RackStatusChoices.STATUS_PLANNED,
|
||||
'role': rackroles[1].pk,
|
||||
@@ -513,10 +563,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"site,location,name,status,width,u_height,weight,max_weight,weight_unit",
|
||||
"Site 1,,Rack 4,active,19,42,100,2000,kg",
|
||||
"Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
|
||||
"Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
|
||||
"site,location,group,name,status,width,u_height,weight,max_weight,weight_unit",
|
||||
"Site 1,,,Rack 4,active,19,42,100,2000,kg",
|
||||
"Site 1,Location 1,Rack Group 1,Rack 5,active,19,42,100,2000,kg",
|
||||
"Site 2,Location 2,Rack Group 2,Rack 6,active,19,42,100,2000,kg",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -529,6 +579,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
cls.bulk_edit_data = {
|
||||
'site': sites[1].pk,
|
||||
'location': locations[1].pk,
|
||||
'group': rack_groups[1].pk,
|
||||
'tenant': None,
|
||||
'status': RackStatusChoices.STATUS_DEPRECATED,
|
||||
'role': rackroles[1].pk,
|
||||
@@ -2692,6 +2743,50 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
f"{console_ports[2].pk},Console Port 9,New description9",
|
||||
)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_bulk_add_components_with_changelog_message(self):
|
||||
device1 = Device.objects.get(name='Device 1')
|
||||
device2 = create_test_device('Device 2')
|
||||
changelog_message = 'Bulk-created console ports'
|
||||
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add'],
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
request = {
|
||||
'path': reverse('dcim:device_bulk_add_consoleport'),
|
||||
'data': post_data({
|
||||
'pk': [device1.pk, device2.pk],
|
||||
'name': 'Console Port Bulk',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'Bulk-created console port',
|
||||
'changelog_message': changelog_message,
|
||||
'_create': True,
|
||||
}),
|
||||
}
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertEqual(initial_count + 2, self._get_queryset().count())
|
||||
|
||||
created_ports = list(ConsolePort.objects.filter(name='Console Port Bulk').order_by('device_id'))
|
||||
self.assertEqual(len(created_ports), 2)
|
||||
self.assertEqual([port.device_id for port in created_ports], [device1.pk, device2.pk])
|
||||
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
action=ObjectChangeActionChoices.ACTION_CREATE,
|
||||
changed_object_type=ContentType.objects.get_for_model(ConsolePort),
|
||||
changed_object_id__in=[port.pk for port in created_ports],
|
||||
)
|
||||
self.assertEqual(objectchanges.count(), 2)
|
||||
for objectchange in objectchanges:
|
||||
self.assertEqual(objectchange.message, changelog_message)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_trace(self):
|
||||
consoleport = ConsolePort.objects.first()
|
||||
@@ -3498,6 +3593,45 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
}
|
||||
|
||||
|
||||
class CableBundleTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = CableBundle
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cable_bundles = (
|
||||
CableBundle(name='Cable Bundle 1'),
|
||||
CableBundle(name='Cable Bundle 2'),
|
||||
CableBundle(name='Cable Bundle 3'),
|
||||
)
|
||||
CableBundle.objects.bulk_create(cable_bundles)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Cable Bundle X',
|
||||
'description': 'A test bundle',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,description",
|
||||
"Cable Bundle 4,Fourth bundle",
|
||||
"Cable Bundle 5,Fifth bundle",
|
||||
"Cable Bundle 6,",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
f"{cable_bundles[0].pk},Cable Bundle 7,New description7",
|
||||
f"{cable_bundles[1].pk},Cable Bundle 8,New description8",
|
||||
f"{cable_bundles[2].pk},Cable Bundle 9,New description9",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by lack of common creation view for cables (termination A must be initialized)
|
||||
class CableTestCase(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user