mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-07 01:47:15 +02:00
Compare commits
129 Commits
21025-pre-
...
v4.5.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0651f6474 | ||
|
|
fecd4e2f97 | ||
|
|
f058ee3d60 | ||
|
|
49ba0dd495 | ||
|
|
b4ee2cf447 | ||
|
|
34098bb20a | ||
|
|
a19daa5466 | ||
|
|
40eec679d9 | ||
|
|
57556e3fdb | ||
|
|
f2d8ae29c2 | ||
|
|
f6eb5dda0f | ||
|
|
c7bbfb24c5 | ||
|
|
e98e5e11a7 | ||
|
|
3ce2bf75b4 | ||
|
|
b1af9a7218 | ||
|
|
b73f7f7d00 | ||
|
|
9492b55f4b | ||
|
|
2563122352 | ||
|
|
0455e14c29 | ||
|
|
b8b12f3f90 | ||
|
|
05059f4a86 | ||
|
|
e4e4c1c56d | ||
|
|
c99d8481b2 | ||
|
|
0923a3dec8 | ||
|
|
80b9c25674 | ||
|
|
6d13bc8b96 | ||
|
|
ee17e83da6 | ||
|
|
5ab9608e38 | ||
|
|
e54ed87863 | ||
|
|
55daf4c52f | ||
|
|
a45e8571da | ||
|
|
0154a09856 | ||
|
|
757c4f69d2 | ||
|
|
d5f37d7a87 | ||
|
|
f30786d8fe | ||
|
|
bb73601d80 | ||
|
|
99e9d96787 | ||
|
|
f5c97e367c | ||
|
|
ea756b29e9 | ||
|
|
b929e1aa1b | ||
|
|
91d5382a61 | ||
|
|
e76203238d | ||
|
|
3f58648115 | ||
|
|
b904dc5c75 | ||
|
|
bf27ff9593 | ||
|
|
981f31304d | ||
|
|
2a39ab47d6 | ||
|
|
aa01c16db0 | ||
|
|
e04986617c | ||
|
|
83cf193cdc | ||
|
|
d497198f49 | ||
|
|
4e479c547f | ||
|
|
e44c0a2119 | ||
|
|
3ab0613708 | ||
|
|
9f16734266 | ||
|
|
c3c7cf15b2 | ||
|
|
2b7049c39c | ||
|
|
3ededeb0e7 | ||
|
|
66f6b2b6f9 | ||
|
|
61cef9400d | ||
|
|
d57f230f37 | ||
|
|
472dc3882e | ||
|
|
268ef4f59f | ||
|
|
671b1cd470 | ||
|
|
21f78049bc | ||
|
|
e28ed7446c | ||
|
|
9b57512b12 | ||
|
|
da79cc775d | ||
|
|
6f5fd26183 | ||
|
|
10157394ae | ||
|
|
ae0907fb37 | ||
|
|
fea6ad61fd | ||
|
|
675e68f276 | ||
|
|
20b907a8c9 | ||
|
|
8ccb0f7b63 | ||
|
|
068fce4d7c | ||
|
|
2e4bce2dad | ||
|
|
dad96c525f | ||
|
|
cac3c1221c | ||
|
|
3a9d00a537 | ||
|
|
4040e4f266 | ||
|
|
f938309ed9 | ||
|
|
86f6de40d2 | ||
|
|
83c6149e49 | ||
|
|
98d898aba9 | ||
|
|
07bb6aa365 | ||
|
|
f3c34b30ec | ||
|
|
2281889e9d | ||
|
|
b19d0d61f4 | ||
|
|
d64c4d75f8 | ||
|
|
b5bd8905ca | ||
|
|
cb5521f818 | ||
|
|
3cb854b7d5 | ||
|
|
d980837da0 | ||
|
|
5c19afc07c | ||
|
|
67defb3228 | ||
|
|
cca4cc61b6 | ||
|
|
9b0c6110bb | ||
|
|
758b230403 | ||
|
|
8ea33df148 | ||
|
|
c86210f024 | ||
|
|
685c1afdcf | ||
|
|
d62a0d7d8d | ||
|
|
1c527366c9 | ||
|
|
e1684fb645 | ||
|
|
969ae81574 | ||
|
|
baec71fcaf | ||
|
|
44abeeff5a | ||
|
|
93e01d5b07 | ||
|
|
fa5f9430fc | ||
|
|
351066c73f | ||
|
|
e6db3f75ea | ||
|
|
04244e188f | ||
|
|
eaad5cc26f | ||
|
|
a1d82e45a0 | ||
|
|
e4f7f080b3 | ||
|
|
983ba4fda8 | ||
|
|
54462595a6 | ||
|
|
8ab752b9ad | ||
|
|
b11cc31f9d | ||
|
|
3f02309538 | ||
|
|
53345f194a | ||
|
|
139557b8dd | ||
|
|
fcf02bd8bb | ||
|
|
7d6989ff34 | ||
|
|
1be917fb90 | ||
|
|
3b0b95c265 | ||
|
|
cdc2fb2f06 | ||
|
|
951d856c3c |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.5.3
|
||||
placeholder: v4.5.7
|
||||
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.3
|
||||
placeholder: v4.5.7
|
||||
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.3
|
||||
placeholder: v4.5.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.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
|
||||
|
||||
21
.github/workflows/claude-code-review.yml
vendored
21
.github/workflows/claude-code-review.yml
vendored
@@ -3,20 +3,14 @@ name: Claude Code Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
# Only run for PRs submitted by organization members or owners
|
||||
if: |
|
||||
github.repository == 'netbox-community/netbox' &&
|
||||
(github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'OWNER')
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -27,13 +21,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
@@ -41,4 +35,3 @@ jobs:
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
|
||||
34
.github/workflows/claude.yml
vendored
34
.github/workflows/claude.yml
vendored
@@ -26,13 +26,43 @@ 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
|
||||
|
||||
# Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
|
||||
# (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
|
||||
# Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
|
||||
- name: Configure git remote for fork PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Determine PR number based on event type
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
PR_NUMBER="${{ github.event.issue.number }}"
|
||||
elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
else
|
||||
exit 0 # issues event — no PR branch to worry about
|
||||
fi
|
||||
|
||||
# Fetch fork info in one API call; silently skip if this is not a PR
|
||||
PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
|
||||
if [ -z "$PR_INFO" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
|
||||
FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
|
||||
echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
|
||||
git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
|
||||
fi
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
8
.github/workflows/lock-threads.yml
vendored
8
.github/workflows/lock-threads.yml
vendored
@@ -11,14 +11,14 @@ permissions:
|
||||
pull-requests: write
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
discussion-inactive-days: 180
|
||||
issue-lock-reason: 'resolved'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# NetBox
|
||||
|
||||
Network source-of-truth and infrastructure resource modeling (IRM) tool combining DCIM and IPAM. Built on Django + PostgreSQL + Redis.
|
||||
|
||||
## Tech Stack
|
||||
- Python 3.12+ / Django / Django REST Framework
|
||||
- PostgreSQL (required), Redis (required for caching/queuing)
|
||||
- GraphQL via Strawberry, background jobs via RQ
|
||||
- Docs: MkDocs (in `docs/`)
|
||||
|
||||
## Repository Layout
|
||||
- `netbox/` — Django project root; run all `manage.py` commands from here
|
||||
- `netbox/netbox/` — Core settings, URLs, WSGI entrypoint
|
||||
- `netbox/<app>/` — Django apps: `circuits`, `core`, `dcim`, `ipam`, `extras`, `tenancy`, `virtualization`, `wireless`, `users`, `vpn`
|
||||
- `docs/` — MkDocs documentation source
|
||||
- `contrib/` — Example configs (systemd, nginx, etc.) and other resources
|
||||
|
||||
## Development Setup
|
||||
```bash
|
||||
python -m venv ~/.venv/netbox
|
||||
source ~/.venv/netbox/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy and configure
|
||||
cp netbox/netbox/configuration.example.py netbox/netbox/configuration.py
|
||||
# Edit configuration.py: set DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS
|
||||
|
||||
cd netbox/
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
All commands run from the `netbox/` subdirectory with venv active.
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
python manage.py runserver
|
||||
|
||||
# Run full test suite
|
||||
export NETBOX_CONFIGURATION=netbox.configuration_testing
|
||||
python manage.py test
|
||||
|
||||
# Faster test runs (no DB rebuild, parallel)
|
||||
python manage.py test --keepdb --parallel 4
|
||||
|
||||
# Migrations
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
|
||||
# Shell
|
||||
python manage.py nbshell # NetBox-enhanced shell
|
||||
```
|
||||
|
||||
## Architecture Conventions
|
||||
- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
|
||||
- **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`).
|
||||
- **Templates**: Django templates in `netbox/templates/<app>/`.
|
||||
- **Tests**: Mirror the app structure in `<app>/tests/`. Use `netbox.configuration_testing` for test config.
|
||||
|
||||
## Coding Standards
|
||||
- Follow existing Django conventions; don't reinvent patterns already present in the codebase.
|
||||
- New models must include `created`, `last_updated` fields (inherit from `NetBoxModel` where appropriate).
|
||||
- Every model exposed in the UI needs: model, serializer, filterset, form, table, views, URL route, and tests.
|
||||
- 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`)
|
||||
- Use the `main` branch for patch releases; `feature` tracks work for the upcoming minor/major release.
|
||||
- Every PR must reference an approved GitHub issue.
|
||||
- PRs must include tests for new functionality.
|
||||
|
||||
## Gotchas
|
||||
- `configuration.py` is gitignored — never commit it.
|
||||
- `manage.py` lives in `netbox/`, NOT the repo root. Running from the wrong directory is a common mistake.
|
||||
- `NETBOX_CONFIGURATION` env var controls which settings module loads; set to `netbox.configuration_testing` for tests.
|
||||
- The `extras` app is a catch-all for cross-cutting features (custom fields, tags, webhooks, scripts).
|
||||
- Plugins API: only documented public APIs are stable. Internal NetBox code is subject to change without notice.
|
||||
- See `docs/development/` for the full contributing guide and code style details.
|
||||
@@ -84,6 +84,8 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
|
||||
|
||||
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
|
||||
|
||||
* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
|
||||
|
||||
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
|
||||
|
||||
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
|
||||
@@ -96,10 +98,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
|
||||
greater than 80 characters in length
|
||||
|
||||
> [!CAUTION]
|
||||
> Any contributions which include AI-generated or reproduced content will be rejected.
|
||||
> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human.
|
||||
|
||||
* Some other tips to keep in mind:
|
||||
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
|
||||
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
|
||||
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
|
||||
* All new functionality must include relevant tests where applicable.
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ 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
|
||||
@@ -157,8 +161,7 @@ strawberry-graphql
|
||||
|
||||
# Strawberry GraphQL Django extension
|
||||
# https://github.com/strawberry-graphql/strawberry-django/releases
|
||||
# Blocked by #21450
|
||||
strawberry-graphql-django==0.75.0
|
||||
strawberry-graphql-django
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
"5gbase-t",
|
||||
"10gbase-br-d",
|
||||
"10gbase-br-u",
|
||||
"10gbase-cu",
|
||||
"10gbase-cx4",
|
||||
"10gbase-er",
|
||||
"10gbase-lr",
|
||||
@@ -367,6 +368,7 @@
|
||||
"40gbase-fr4",
|
||||
"40gbase-lr4",
|
||||
"40gbase-sr4",
|
||||
"40gbase-sr4-bd",
|
||||
"50gbase-cr",
|
||||
"50gbase-er",
|
||||
"50gbase-fr",
|
||||
@@ -414,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",
|
||||
@@ -446,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",
|
||||
@@ -457,6 +466,7 @@
|
||||
"100gbase-kp4",
|
||||
"100gbase-kr2",
|
||||
"100gbase-kr4",
|
||||
"1.6tbase-kr8",
|
||||
"ieee802.11a",
|
||||
"ieee802.11g",
|
||||
"ieee802.11n",
|
||||
|
||||
1000
contrib/openapi.json
1000
contrib/openapi.json
File diff suppressed because one or more lines are too long
@@ -220,6 +220,14 @@ This parameter defines the URL of the repository that will be checked for new Ne
|
||||
|
||||
---
|
||||
|
||||
## RQ
|
||||
|
||||
Default: `{}` (Empty)
|
||||
|
||||
This is a wrapper for passing global configuration parameters to [Django RQ](https://github.com/rq/django-rq) to customize its behavior. It is employed within NetBox primarily to alter conditions during testing.
|
||||
|
||||
---
|
||||
|
||||
## RQ_DEFAULT_TIMEOUT
|
||||
|
||||
Default: `300`
|
||||
|
||||
@@ -215,6 +215,7 @@ if obj.pk and hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
|
||||
obj.property = "New Value"
|
||||
obj._changelog_message = 'Example Message Text' # Optional
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
```
|
||||
@@ -383,6 +384,18 @@ A calendar date. Returns a `datetime.date` object.
|
||||
|
||||
A complete date & time. Returns a `datetime.datetime` object.
|
||||
|
||||
## Uploading Scripts via the API
|
||||
|
||||
Script modules can be uploaded to NetBox via the REST API by sending a `multipart/form-data` POST request to `/api/extras/scripts/upload/`. The caller must have the `extras.add_scriptmodule` and `core.add_managedfile` permissions.
|
||||
|
||||
```no-highlight
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-F "file=@/path/to/myscript.py" \
|
||||
http://netbox/api/extras/scripts/upload/
|
||||
```
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -23,9 +23,9 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
|
||||
|
||||
The following data is available as context for Jinja2 templates:
|
||||
|
||||
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
|
||||
* `model` - The NetBox model which triggered the change.
|
||||
* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
|
||||
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
|
||||
* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
|
||||
* `username` - The name of the user account associated with the change.
|
||||
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
|
||||
* `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.
|
||||
@@ -38,18 +38,20 @@ If no body template is specified, the request body will be populated with a JSON
|
||||
```json
|
||||
{
|
||||
"event": "created",
|
||||
"timestamp": "2021-03-09 17:55:33.968016+00:00",
|
||||
"model": "site",
|
||||
"timestamp": "2026-03-06T15:11:23.503186+00:00",
|
||||
"object_type": "dcim.site",
|
||||
"username": "jstretch",
|
||||
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
|
||||
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
|
||||
"data": {
|
||||
"id": 19,
|
||||
"id": 4,
|
||||
"url": "/api/dcim/sites/4/",
|
||||
"display_url": "/dcim/sites/4/",
|
||||
"display": "Site 1",
|
||||
"name": "Site 1",
|
||||
"slug": "site-1",
|
||||
"status":
|
||||
"status": {
|
||||
"value": "active",
|
||||
"label": "Active",
|
||||
"id": 1
|
||||
"label": "Active"
|
||||
},
|
||||
"region": null,
|
||||
...
|
||||
@@ -57,8 +59,10 @@ If no body template is specified, the request body will be populated with a JSON
|
||||
"snapshots": {
|
||||
"prechange": null,
|
||||
"postchange": {
|
||||
"created": "2021-03-09",
|
||||
"last_updated": "2021-03-09T17:55:33.851Z",
|
||||
"created": "2026-03-06T15:11:23.484Z",
|
||||
"owner": null,
|
||||
"description": "",
|
||||
"comments": "",
|
||||
"name": "Site 1",
|
||||
"slug": "site-1",
|
||||
"status": "active",
|
||||
|
||||
@@ -36,13 +36,16 @@ If false, synchronization will be disabled.
|
||||
|
||||
### Ignore Rules
|
||||
|
||||
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
|
||||
A set of rules (one per line) identifying files or paths to ignore during synchronization. Rules are matched against both the full relative path (e.g. `subdir/file.txt`) and the bare filename, so path-based patterns can be used to exclude entire directories. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
|
||||
|
||||
| Rule | Description |
|
||||
|----------------|------------------------------------------|
|
||||
| `README` | Ignore any files named `README` |
|
||||
| `*.txt` | Ignore any files with a `.txt` extension |
|
||||
| `data???.json` | Ignore e.g. `data123.json` |
|
||||
| Rule | Description |
|
||||
|-----------------------|------------------------------------------------------|
|
||||
| `README` | Ignore any files named `README` |
|
||||
| `*.txt` | Ignore any files with a `.txt` extension |
|
||||
| `data???.json` | Ignore e.g. `data123.json` |
|
||||
| `subdir/*` | Ignore all files within `subdir/` |
|
||||
| `subdir/*/*` | Ignore all files one level deep within `subdir/` |
|
||||
| `*/dev/*` | Ignore files inside any directory named `dev/` |
|
||||
|
||||
### Sync Interval
|
||||
|
||||
|
||||
@@ -77,14 +77,14 @@ The file path to a particular certificate authority (CA) file to use when valida
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context variables are available in to the text and link templates.
|
||||
The following context variables are available to the text and link templates.
|
||||
|
||||
| Variable | Description |
|
||||
|--------------|----------------------------------------------------|
|
||||
| `event` | The event type (`create`, `update`, or `delete`) |
|
||||
| `timestamp` | The time at which the event occured |
|
||||
| `model` | The type of object impacted |
|
||||
| `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 |
|
||||
| Variable | Description |
|
||||
|---------------|------------------------------------------------------|
|
||||
| `event` | The event type (`created`, `updated`, or `deleted`) |
|
||||
| `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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,119 @@
|
||||
# NetBox v4.5
|
||||
|
||||
## v4.5.7 (2026-04-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#21095](https://github.com/netbox-community/netbox/issues/21095) - Adopt IEC unit labels (e.g. GiB) for virtual machine resources
|
||||
* [#21696](https://github.com/netbox-community/netbox/issues/21696) - Add support for django-rq 4.0 and introduce `RQ` configuration parameter
|
||||
* [#21701](https://github.com/netbox-community/netbox/issues/21701) - Support uploading custom scripts via the REST API (`/api/extras/scripts/upload/`)
|
||||
* [#21760](https://github.com/netbox-community/netbox/issues/21760) - Add a 1C2P:2C1P breakout cable profile
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* [#21655](https://github.com/netbox-community/netbox/issues/21655) - Optimize queries for object and multi-object type custom fields
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#20474](https://github.com/netbox-community/netbox/issues/20474) - Fix installation of modules with placeholder values in component names
|
||||
* [#21498](https://github.com/netbox-community/netbox/issues/21498) - Fix server error triggered by event rules referencing deleted objects
|
||||
* [#21533](https://github.com/netbox-community/netbox/issues/21533) - Ensure read-only fields are included in REST API responses upon object creation
|
||||
* [#21535](https://github.com/netbox-community/netbox/issues/21535) - Fix filtering of object-type custom fields when "is empty" is selected
|
||||
* [#21784](https://github.com/netbox-community/netbox/issues/21784) - Fix `AttributeError` exception when sorting a table as an anonymous user
|
||||
* [#21808](https://github.com/netbox-community/netbox/issues/21808) - Fix `RelatedObjectDoesNotExist` exception when viewing an interface with a virtual circuit termination
|
||||
* [#21810](https://github.com/netbox-community/netbox/issues/21810) - Fix `AttributeError` exception when viewing virtual chassis member
|
||||
* [#21825](https://github.com/netbox-community/netbox/issues/21825) - Fix sorting by broken columns in several object lists
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
* [#21114](https://github.com/netbox-community/netbox/issues/21114) - Support path exclusions for data source synchronization
|
||||
* [#21578](https://github.com/netbox-community/netbox/issues/21578) - Support identifying scope object by name or slug when bulk importing scoped objects
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* [#21330](https://github.com/netbox-community/netbox/issues/21330) - Optimize the assignment of tags when saving objects
|
||||
* [#21402](https://github.com/netbox-community/netbox/issues/21402) - Avoid excessive database queries when rendering unnamed devices via the REST API
|
||||
* [#21611](https://github.com/netbox-community/netbox/issues/21611) - Replace inefficient calls to `.count()` with `.exists()`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19867](https://github.com/netbox-community/netbox/issues/19867) - Preserve the "per page" pagination setting when returning from object edit forms
|
||||
* [#20077](https://github.com/netbox-community/netbox/issues/20077) - Fix form field focus bug in Microsoft Edge
|
||||
* [#20385](https://github.com/netbox-community/netbox/issues/20385) - Enforce `MAX_PAGE_SIZE` limit for GraphQL API requests
|
||||
* [#20468](https://github.com/netbox-community/netbox/issues/20468) - Fix range-based filter lookups for integer fields in GraphQL API
|
||||
* [#20915](https://github.com/netbox-community/netbox/issues/20915) - Restore user language preference after login via social authentication
|
||||
* [#20934](https://github.com/netbox-community/netbox/issues/20934) - Fix dark mode flicker on page load
|
||||
* [#21012](https://github.com/netbox-community/netbox/issues/21012) - Add pagination for VLAN table on interface view to prevent silent truncation at 100 entries
|
||||
* [#21380](https://github.com/netbox-community/netbox/issues/21380) - Fix display of the background tasks table on mobile
|
||||
* [#21440](https://github.com/netbox-community/netbox/issues/21440) - Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
|
||||
* [#21468](https://github.com/netbox-community/netbox/issues/21468) - Preserve safe custom HTTP headers when copying requests for background job processing
|
||||
* [#21486](https://github.com/netbox-community/netbox/issues/21486) - Fix `AttributeError` exception caused by missing `COOKIES` attribute on `NetBoxFakeRequest`
|
||||
* [#21512](https://github.com/netbox-community/netbox/issues/21512) - Fix GraphQL filter field name mismatch for device component types (e.g. `console_ports`)
|
||||
* [#21531](https://github.com/netbox-community/netbox/issues/21531) - Fix search functionality for location when combined with other filters
|
||||
* [#21556](https://github.com/netbox-community/netbox/issues/21556) - Avoid clearing the platform field when changing device type in the device edit form
|
||||
* [#21579](https://github.com/netbox-community/netbox/issues/21579) - Hide the script "Add" button for users lacking the required permission
|
||||
* [#21580](https://github.com/netbox-community/netbox/issues/21580) - Hide the virtual machine "Add components" dropdown for users lacking change permission
|
||||
* [#21586](https://github.com/netbox-community/netbox/issues/21586) - Fix broken "Add child group" link in site group view (was pointing to the region endpoint)
|
||||
* [#21618](https://github.com/netbox-community/netbox/issues/21618) - Fix cable termination points being lost when bulk-editing the cable profile
|
||||
* [#21651](https://github.com/netbox-community/netbox/issues/21651) - Disable sorting by the `is_primary` column in the MAC address list view
|
||||
* [#21653](https://github.com/netbox-community/netbox/issues/21653) - Fix profile-based cable tracing when a single origin carries multiple positions
|
||||
* [#21673](https://github.com/netbox-community/netbox/issues/21673) - Fix display of primary IP address with associated NAT IP on virtual machine view
|
||||
* [#21686](https://github.com/netbox-community/netbox/issues/21686) - Clean up cached circuit attributes when reassigning a circuit termination
|
||||
|
||||
---
|
||||
|
||||
## v4.5.4 (2026-03-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#21369](https://github.com/netbox-community/netbox/issues/21369) - Support lazy-loading of image attachments
|
||||
* [#21385](https://github.com/netbox-community/netbox/issues/21385) - Add contact assignment support for virtual circuits
|
||||
* [#21394](https://github.com/netbox-community/netbox/issues/21394) - Add 10GBASE-CU and 40GBASE-SR4 BiDi interface types
|
||||
* [#21477](https://github.com/netbox-community/netbox/issues/21477) - Extend GraphQL API filters for cables
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* [#21456](https://github.com/netbox-community/netbox/issues/21456) - Improve performance of config context resolution via GraphQL API
|
||||
* [#21459](https://github.com/netbox-community/netbox/issues/21459) - Avoid prefetching data for hidden table columns
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#20490](https://github.com/netbox-community/netbox/issues/20490) - Restrict visibility of scripts in list view to users with view permission
|
||||
* [#20911](https://github.com/netbox-community/netbox/issues/20911) - Sort module bay options alphabetically when installing a module
|
||||
* [#21347](https://github.com/netbox-community/netbox/issues/21347) - The allocation of IPv6 addresses from a non-pool prefix should start at one, not zero
|
||||
* [#21429](https://github.com/netbox-community/netbox/issues/21429) - Termination type should persist when employing "create & add another" workflow for cables
|
||||
* [#21478](https://github.com/netbox-community/netbox/issues/21478) - Fix GraphQL union type resolution for connected console ports
|
||||
* [#21481](https://github.com/netbox-community/netbox/issues/21481) - Fix display of facility ID on rack view
|
||||
* [#21518](https://github.com/netbox-community/netbox/issues/21518) - Fix decimal custom field displaying as unset when value is zero
|
||||
* [#21524](https://github.com/netbox-community/netbox/issues/21524) - Avoid `IndexError` exception when encountering stale cable paths
|
||||
* [#21527](https://github.com/netbox-community/netbox/issues/21527) - Fix display of primary IP address with associated NAT IP on device view
|
||||
* [#21550](https://github.com/netbox-community/netbox/issues/21550) - Ensure pre-change snapshots are recorded for related objects
|
||||
|
||||
---
|
||||
|
||||
## v4.5.3 (2026-02-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from strawberry.scalars import ID
|
||||
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, DateFilterLookup, StrFilterLookup
|
||||
|
||||
from circuits import models
|
||||
from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
|
||||
@@ -62,9 +62,9 @@ class CircuitTerminationFilter(
|
||||
upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
pp_info: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
xconnect_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
pp_info: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
# Cached relations
|
||||
_provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||
@@ -92,7 +92,7 @@ class CircuitFilter(
|
||||
TenancyFilterMixin,
|
||||
PrimaryModelFilter
|
||||
):
|
||||
cid: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -145,8 +145,8 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
|
||||
|
||||
@strawberry_django.filter_type(models.Provider, lookups=True)
|
||||
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
@@ -159,18 +159,18 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
provider_id: ID | None = strawberry_django.filter_field()
|
||||
account: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
account: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
|
||||
class ProviderNetworkFilter(PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
provider_id: ID | None = strawberry_django.filter_field()
|
||||
service_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
service_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
|
||||
@@ -180,7 +180,7 @@ class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter
|
||||
|
||||
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
|
||||
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
cid: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -218,4 +218,4 @@ class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin,
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
interface_id: ID | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -347,6 +347,13 @@ class CircuitTermination(
|
||||
verbose_name = _('circuit termination')
|
||||
verbose_name_plural = _('circuit terminations')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache original values to detect changes
|
||||
self._orig_circuit_id = self.__dict__.get('circuit_id')
|
||||
self._orig_term_side = self.__dict__.get('term_side')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.circuit}: Termination {self.term_side}'
|
||||
|
||||
@@ -360,11 +367,39 @@ class CircuitTermination(
|
||||
raise ValidationError(_("A circuit termination must attach to a terminating object."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self._state.adding
|
||||
update_fields = kwargs.get('update_fields')
|
||||
|
||||
# Only consider circuit/term_side changes if those fields
|
||||
# are actually being persisted
|
||||
if update_fields is not None:
|
||||
tracking_relevant = 'circuit' in update_fields or 'term_side' in update_fields
|
||||
else:
|
||||
tracking_relevant = True
|
||||
|
||||
circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
|
||||
term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
|
||||
|
||||
# Cache objects associated with the terminating object (for filtering)
|
||||
self.cache_related_objects()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Clear the old termination reference if circuit or term_side changed
|
||||
if circuit_changed or term_side_changed:
|
||||
old_termination_name = f'termination_{self._orig_term_side.lower()}'
|
||||
Circuit.objects.filter(pk=self._orig_circuit_id).update(**{old_termination_name: None})
|
||||
|
||||
# Update the cache if this is a new termination or circuit/term_side changed
|
||||
if is_new or circuit_changed or term_side_changed:
|
||||
# Update the new circuit's termination reference
|
||||
termination_name = f'termination_{self.term_side.lower()}'
|
||||
Circuit.objects.filter(pk=self.circuit_id).update(**{termination_name: self.pk})
|
||||
|
||||
# Update cached values for subsequent saves
|
||||
self._orig_circuit_id = self.circuit_id
|
||||
self._orig_term_side = self.term_side
|
||||
|
||||
def cache_related_objects(self):
|
||||
self._provider_network = self._region = self._site_group = self._site = self._location = None
|
||||
if self.termination_type:
|
||||
|
||||
@@ -6,17 +6,6 @@ from dcim.signals import rebuild_paths
|
||||
from .models import CircuitTermination
|
||||
|
||||
|
||||
@receiver(post_save, sender=CircuitTermination)
|
||||
def update_circuit(instance, **kwargs):
|
||||
"""
|
||||
When a CircuitTermination has been modified, update its parent Circuit.
|
||||
"""
|
||||
termination_name = f'termination_{instance.term_side.lower()}'
|
||||
instance.circuit.refresh_from_db()
|
||||
setattr(instance.circuit, termination_name, instance)
|
||||
instance.circuit.save()
|
||||
|
||||
|
||||
@receiver((post_save, post_delete), sender=CircuitTermination)
|
||||
def rebuild_cablepaths(instance, raw=False, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -95,6 +95,7 @@ class VirtualCircuitTerminationTable(NetBoxTable):
|
||||
verbose_name=_('Provider network')
|
||||
)
|
||||
provider_account = tables.Column(
|
||||
accessor=tables.A('virtual_circuit__provider_account'),
|
||||
linkify=True,
|
||||
verbose_name=_('Account')
|
||||
)
|
||||
@@ -112,7 +113,7 @@ class VirtualCircuitTerminationTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VirtualCircuitTermination
|
||||
fields = (
|
||||
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
|
||||
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interface',
|
||||
'description', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
148
netbox/circuits/tests/test_models.py
Normal file
148
netbox/circuits/tests/test_models.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
|
||||
from dcim.models import Site
|
||||
|
||||
|
||||
class CircuitTerminationTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
|
||||
cls.sites = (
|
||||
Site.objects.create(name='Site 1', slug='site-1'),
|
||||
Site.objects.create(name='Site 2', slug='site-2'),
|
||||
)
|
||||
|
||||
cls.circuits = (
|
||||
Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type),
|
||||
Circuit.objects.create(cid='Circuit 2', provider=provider, type=circuit_type),
|
||||
)
|
||||
|
||||
cls.provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
|
||||
|
||||
def test_circuit_termination_creation_populates_circuit_cache(self):
|
||||
"""
|
||||
When a CircuitTermination is created, the parent Circuit's termination_a or termination_z
|
||||
cache field should be populated.
|
||||
"""
|
||||
# Create A termination
|
||||
termination_a = CircuitTermination.objects.create(
|
||||
circuit=self.circuits[0],
|
||||
term_side='A',
|
||||
termination=self.sites[0],
|
||||
)
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.assertEqual(self.circuits[0].termination_a, termination_a)
|
||||
self.assertIsNone(self.circuits[0].termination_z)
|
||||
|
||||
# Create Z termination
|
||||
termination_z = CircuitTermination.objects.create(
|
||||
circuit=self.circuits[0],
|
||||
term_side='Z',
|
||||
termination=self.sites[1],
|
||||
)
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.assertEqual(self.circuits[0].termination_a, termination_a)
|
||||
self.assertEqual(self.circuits[0].termination_z, termination_z)
|
||||
|
||||
def test_circuit_termination_circuit_change_clears_old_cache(self):
|
||||
"""
|
||||
When a CircuitTermination's circuit is changed, the old Circuit's cache should be cleared
|
||||
and the new Circuit's cache should be populated.
|
||||
"""
|
||||
# Create termination on self.circuits[0]
|
||||
termination = CircuitTermination.objects.create(
|
||||
circuit=self.circuits[0],
|
||||
term_side='A',
|
||||
termination=self.sites[0],
|
||||
)
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.assertEqual(self.circuits[0].termination_a, termination)
|
||||
|
||||
# Move termination to self.circuits[1]
|
||||
termination.circuit = self.circuits[1]
|
||||
termination.save()
|
||||
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.circuits[1].refresh_from_db()
|
||||
|
||||
# Old circuit's cache should be cleared
|
||||
self.assertIsNone(self.circuits[0].termination_a)
|
||||
# New circuit's cache should be populated
|
||||
self.assertEqual(self.circuits[1].termination_a, termination)
|
||||
|
||||
def test_circuit_termination_term_side_change_clears_old_cache(self):
|
||||
"""
|
||||
When a CircuitTermination's term_side is changed, the old side's cache should be cleared
|
||||
and the new side's cache should be populated.
|
||||
"""
|
||||
# Create A termination
|
||||
termination = CircuitTermination.objects.create(
|
||||
circuit=self.circuits[0],
|
||||
term_side='A',
|
||||
termination=self.sites[0],
|
||||
)
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.assertEqual(self.circuits[0].termination_a, termination)
|
||||
self.assertIsNone(self.circuits[0].termination_z)
|
||||
|
||||
# Change from A to Z
|
||||
termination.term_side = 'Z'
|
||||
termination.save()
|
||||
|
||||
self.circuits[0].refresh_from_db()
|
||||
|
||||
# A side should be cleared, Z side should be populated
|
||||
self.assertIsNone(self.circuits[0].termination_a)
|
||||
self.assertEqual(self.circuits[0].termination_z, termination)
|
||||
|
||||
def test_circuit_termination_circuit_and_term_side_change(self):
|
||||
"""
|
||||
When both circuit and term_side are changed, the old Circuit's old side cache should be
|
||||
cleared and the new Circuit's new side cache should be populated.
|
||||
"""
|
||||
# Create A termination on self.circuits[0]
|
||||
termination = CircuitTermination.objects.create(
|
||||
circuit=self.circuits[0],
|
||||
term_side='A',
|
||||
termination=self.sites[0],
|
||||
)
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.assertEqual(self.circuits[0].termination_a, termination)
|
||||
|
||||
# Change to self.circuits[1] Z side
|
||||
termination.circuit = self.circuits[1]
|
||||
termination.term_side = 'Z'
|
||||
termination.save()
|
||||
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.circuits[1].refresh_from_db()
|
||||
|
||||
# Old circuit's A side should be cleared
|
||||
self.assertIsNone(self.circuits[0].termination_a)
|
||||
self.assertIsNone(self.circuits[0].termination_z)
|
||||
# New circuit's Z side should be populated
|
||||
self.assertIsNone(self.circuits[1].termination_a)
|
||||
self.assertEqual(self.circuits[1].termination_z, termination)
|
||||
|
||||
def test_circuit_termination_deletion_clears_cache(self):
|
||||
"""
|
||||
When a CircuitTermination is deleted, the parent Circuit's cache should be cleared.
|
||||
"""
|
||||
termination = CircuitTermination.objects.create(
|
||||
circuit=self.circuits[0],
|
||||
term_side='A',
|
||||
termination=self.sites[0],
|
||||
)
|
||||
self.circuits[0].refresh_from_db()
|
||||
self.assertEqual(self.circuits[0].termination_a, termination)
|
||||
|
||||
# Delete the termination
|
||||
termination.delete()
|
||||
self.circuits[0].refresh_from_db()
|
||||
|
||||
# Cache should be cleared (SET_NULL behavior)
|
||||
self.assertIsNone(self.circuits[0].termination_a)
|
||||
@@ -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,35 @@ 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},
|
||||
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},
|
||||
actions=[
|
||||
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -44,7 +83,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'provider_id',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +147,32 @@ 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},
|
||||
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 +239,32 @@ 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},
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -251,6 +342,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 +420,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 +506,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 +574,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 +647,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 +708,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 +786,30 @@ 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},
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'circuits.VirtualCircuitTermination',
|
||||
url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'add', detail=False)
|
||||
@@ -698,6 +881,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')
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_rq.queues import get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST
|
||||
from django_rq.settings import get_queues_list
|
||||
from django_rq.utils import get_statistics
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
@@ -195,7 +195,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
|
||||
return 'Background Workers'
|
||||
|
||||
def get_data(self):
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
return Worker.all(get_redis_connection(config['connection_config']))
|
||||
|
||||
@extend_schema(
|
||||
@@ -205,7 +205,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
|
||||
)
|
||||
def retrieve(self, request, name):
|
||||
# all the RQ queues should use the same connection
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
workers = Worker.all(get_redis_connection(config['connection_config']))
|
||||
worker = next((item for item in workers if item.name == name), None)
|
||||
if not worker:
|
||||
@@ -229,7 +229,7 @@ class BackgroundTaskViewSet(BaseRQViewSet):
|
||||
return get_rq_jobs()
|
||||
|
||||
def get_task_from_id(self, task_id):
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
task = RQ_Job.fetch(task_id, connection=get_redis_connection(config['connection_config']))
|
||||
if not task:
|
||||
raise Http404
|
||||
|
||||
@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
|
||||
attrs={
|
||||
'rows': 5,
|
||||
'class': 'font-monospace',
|
||||
'placeholder': '.cache\n*.txt'
|
||||
'placeholder': '.cache\n*.txt\nsubdir/*'
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import strawberry
|
||||
import strawberry_django
|
||||
from django.contrib.contenttypes.models import ContentType as DjangoContentType
|
||||
from strawberry.scalars import ID
|
||||
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
|
||||
|
||||
from core import models
|
||||
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
|
||||
@@ -32,23 +32,23 @@ class DataFileFilter(BaseModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
source_id: ID | None = strawberry_django.filter_field()
|
||||
path: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
path: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
hash: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
hash: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.DataSource, lookups=True)
|
||||
class DataSourceFilter(PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
type: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
source_url: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
type: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
source_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
status: (
|
||||
BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
|
||||
) = strawberry_django.filter_field()
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
ignore_rules: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -62,8 +62,8 @@ class DataSourceFilter(PrimaryModelFilter):
|
||||
class ObjectChangeFilter(BaseModelFilter):
|
||||
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
|
||||
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
user_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
request_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
user_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
request_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
action: (
|
||||
BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
|
||||
) = strawberry_django.filter_field()
|
||||
@@ -76,7 +76,7 @@ class ObjectChangeFilter(BaseModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
related_object_id: ID | None = strawberry_django.filter_field()
|
||||
object_repr: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
object_repr: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -87,5 +87,5 @@ class ObjectChangeFilter(BaseModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(DjangoContentType, lookups=True)
|
||||
class ContentTypeFilter(BaseModelFilter):
|
||||
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
app_label: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -69,7 +69,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
ignore_rules = models.TextField(
|
||||
verbose_name=_('ignore rules'),
|
||||
blank=True,
|
||||
help_text=_("Patterns (one per line) matching files to ignore when syncing")
|
||||
help_text=_("Patterns (one per line) matching files or paths to ignore when syncing")
|
||||
)
|
||||
parameters = models.JSONField(
|
||||
verbose_name=_('parameters'),
|
||||
@@ -258,21 +258,22 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
if path.startswith('.'):
|
||||
continue
|
||||
for file_name in file_names:
|
||||
if not self._ignore(file_name):
|
||||
paths.add(os.path.join(path, file_name))
|
||||
file_path = os.path.join(path, file_name)
|
||||
if not self._ignore(file_path):
|
||||
paths.add(file_path)
|
||||
|
||||
logger.debug(f"Found {len(paths)} files")
|
||||
return paths
|
||||
|
||||
def _ignore(self, filename):
|
||||
def _ignore(self, file_path):
|
||||
"""
|
||||
Returns a boolean indicating whether the file should be ignored per the DataSource's configured
|
||||
ignore rules.
|
||||
ignore rules. file_path is the full relative path (e.g. "subdir/file.txt").
|
||||
"""
|
||||
if filename.startswith('.'):
|
||||
if os.path.basename(file_path).startswith('.'):
|
||||
return True
|
||||
for rule in self.ignore_rules.splitlines():
|
||||
if fnmatchcase(filename, rule):
|
||||
if fnmatchcase(file_path, rule) or fnmatchcase(os.path.basename(file_path), rule):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ REVISION_BUTTONS = """
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
accessor='active',
|
||||
false_mark=None
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
|
||||
@@ -10,6 +10,26 @@ from dcim.models import Device, Location, Site
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
|
||||
|
||||
class DataSourceIgnoreRulesTestCase(TestCase):
|
||||
|
||||
def test_no_ignore_rules(self):
|
||||
ds = DataSource(ignore_rules='')
|
||||
self.assertFalse(ds._ignore('README.md'))
|
||||
self.assertFalse(ds._ignore('subdir/file.py'))
|
||||
|
||||
def test_ignore_by_filename(self):
|
||||
ds = DataSource(ignore_rules='*.txt')
|
||||
self.assertTrue(ds._ignore('notes.txt'))
|
||||
self.assertTrue(ds._ignore('subdir/notes.txt'))
|
||||
self.assertFalse(ds._ignore('notes.py'))
|
||||
|
||||
def test_ignore_by_subdirectory(self):
|
||||
ds = DataSource(ignore_rules='dev/*')
|
||||
self.assertTrue(ds._ignore('dev/README.md'))
|
||||
self.assertTrue(ds._ignore('dev/script.py'))
|
||||
self.assertFalse(ds._ignore('prod/script.py'))
|
||||
|
||||
|
||||
class DataSourceChangeLoggingTestCase(TestCase):
|
||||
|
||||
def test_password_added_on_create(self):
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_rq import get_queue
|
||||
from django_rq.settings import QUEUES_MAP
|
||||
from django_rq.settings import get_queues_map
|
||||
from django_rq.workers import get_worker
|
||||
from rq.job import Job as RQ_Job
|
||||
from rq.job import JobStatus
|
||||
@@ -189,7 +189,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_default(self):
|
||||
queue = get_queue('default')
|
||||
queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -198,7 +198,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_high(self):
|
||||
queue = get_queue('high')
|
||||
queue.enqueue(self.dummy_job_high)
|
||||
queue_index = QUEUES_MAP['high']
|
||||
queue_index = get_queues_map()['high']
|
||||
|
||||
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -207,7 +207,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_finished(self):
|
||||
queue = get_queue('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
registry = FinishedJobRegistry(queue.name, queue.connection)
|
||||
registry.add(job, 2)
|
||||
@@ -218,7 +218,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_failed(self):
|
||||
queue = get_queue('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
registry = FailedJobRegistry(queue.name, queue.connection)
|
||||
registry.add(job, 2)
|
||||
@@ -229,7 +229,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_scheduled(self):
|
||||
queue = get_queue('default')
|
||||
queue.enqueue_at(datetime.now(), self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'scheduled']))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -238,7 +238,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_deferred(self):
|
||||
queue = get_queue('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
registry = DeferredJobRegistry(queue.name, queue.connection)
|
||||
registry.add(job, 2)
|
||||
@@ -335,7 +335,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
worker2 = get_worker('high')
|
||||
worker2.register_birth()
|
||||
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
response = self.client.get(reverse('core:worker_list', args=[queue_index]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(str(worker1.name), str(response.content))
|
||||
|
||||
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',
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
|
||||
from django_rq.settings import get_queues_list, get_queues_map
|
||||
from django_rq.utils import get_jobs, stop_jobs
|
||||
from rq import requeue_job
|
||||
from rq.exceptions import NoSuchJobError
|
||||
@@ -31,7 +31,7 @@ def get_rq_jobs():
|
||||
"""
|
||||
jobs = set()
|
||||
|
||||
for queue in QUEUES_LIST:
|
||||
for queue in get_queues_list():
|
||||
queue = get_queue(queue['name'])
|
||||
jobs.update(queue.get_jobs())
|
||||
|
||||
@@ -78,13 +78,13 @@ def delete_rq_job(job_id):
|
||||
"""
|
||||
Delete the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
# Remove job id from queue and delete the actual job
|
||||
@@ -96,13 +96,13 @@ def requeue_rq_job(job_id):
|
||||
"""
|
||||
Requeue the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {id} not found.").format(id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
|
||||
@@ -112,13 +112,13 @@ def enqueue_rq_job(job_id):
|
||||
"""
|
||||
Enqueue the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {id} not found.").format(id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
try:
|
||||
@@ -144,13 +144,13 @@ def stop_rq_job(job_id):
|
||||
"""
|
||||
Stop the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
return stop_jobs(queue, job_id)[0]
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
|
||||
from django_rq.settings import get_queues_list, get_queues_map
|
||||
from django_rq.utils import get_statistics
|
||||
from rq.exceptions import NoSuchJobError
|
||||
from rq.job import Job as RQ_Job
|
||||
@@ -23,9 +23,20 @@ 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
|
||||
@@ -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,24 @@ 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},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -157,6 +187,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 +226,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 +249,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 +297,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()
|
||||
@@ -312,6 +388,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):
|
||||
"""
|
||||
@@ -440,13 +524,13 @@ class BackgroundTaskView(BaseRQView):
|
||||
|
||||
def get(self, request, job_id):
|
||||
# all the RQ queues should use the same connection
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
try:
|
||||
@@ -556,7 +640,7 @@ class WorkerView(BaseRQView):
|
||||
|
||||
def get(self, request, key):
|
||||
# all the RQ queues should use the same connection
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
|
||||
# Convert microseconds to milliseconds
|
||||
worker.total_working_time = worker.total_working_time / 1000
|
||||
|
||||
@@ -38,7 +38,15 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
def get_connected_endpoints_reachable(self, obj):
|
||||
return obj._path and obj._path.is_complete and obj._path.is_active
|
||||
"""
|
||||
Return whether the connected endpoints are reachable via a complete, active cable path.
|
||||
"""
|
||||
# Use the public `path` accessor rather than dereferencing `_path`
|
||||
# directly. `path` already handles the stale in-memory relation case
|
||||
# that can occur while CablePath rows are rebuilt during cable edits.
|
||||
if path := obj.path:
|
||||
return path.is_complete and path.is_active
|
||||
return False
|
||||
|
||||
|
||||
class PortSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -84,6 +84,9 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
def get_path(self, obj):
|
||||
ret = []
|
||||
for nodes in obj.path_objects:
|
||||
if not nodes:
|
||||
# The path contains an invalid object
|
||||
return []
|
||||
serializer = get_serializer_for_model(nodes[0])
|
||||
context = {'request': self.context['request']}
|
||||
ret.append(serializer(nodes, nested=True, many=True, context=context).data)
|
||||
|
||||
@@ -6,8 +6,9 @@ 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 dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||
from ipam.api.serializers_.ip import IPAddressSerializer
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
|
||||
@@ -159,6 +160,60 @@ class ModuleSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
if self.nested:
|
||||
return data
|
||||
|
||||
# Skip validation for existing modules (updates)
|
||||
if self.instance is not None:
|
||||
return data
|
||||
|
||||
module_bay = data.get('module_bay')
|
||||
module_type = data.get('module_type')
|
||||
device = data.get('device')
|
||||
|
||||
if not all((module_bay, module_type, device)):
|
||||
return data
|
||||
|
||||
positions = get_module_bay_positions(module_bay)
|
||||
|
||||
for templates, component_attribute 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_attribute).all()
|
||||
}
|
||||
|
||||
for template in getattr(module_type, templates).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.")
|
||||
)
|
||||
try:
|
||||
resolved_name = resolve_module_placeholder(template.name, positions)
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(str(e))
|
||||
|
||||
if 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
|
||||
|
||||
|
||||
class MACAddressSerializer(PrimaryModelSerializer):
|
||||
assigned_object_type = ContentTypeField(
|
||||
|
||||
@@ -405,6 +405,7 @@ class DeviceViewSet(
|
||||
NetBoxModelViewSet
|
||||
):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', # Referenced by Device.__str__() for unnamed devices
|
||||
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
|
||||
)
|
||||
filterset_class = filtersets.DeviceFilterSet
|
||||
|
||||
@@ -254,6 +254,21 @@ class Trunk8C4PCableProfile(BaseCableProfile):
|
||||
b_connectors = a_connectors
|
||||
|
||||
|
||||
class Breakout1C2Px2C1PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
1: 2,
|
||||
}
|
||||
b_connectors = {
|
||||
1: 1,
|
||||
2: 1,
|
||||
}
|
||||
_mapping = {
|
||||
(1, 1): (1, 1),
|
||||
(1, 2): (2, 1),
|
||||
(2, 1): (1, 2),
|
||||
}
|
||||
|
||||
|
||||
class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
1: 4,
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1750,6 +1776,7 @@ class CableProfileChoices(ChoiceSet):
|
||||
TRUNK_4C8P = 'trunk-4c8p'
|
||||
TRUNK_8C4P = 'trunk-8c4p'
|
||||
# Breakouts
|
||||
BREAKOUT_1C2P_2C1P = 'breakout-1c2p-2c1p'
|
||||
BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
|
||||
BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
|
||||
BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
|
||||
@@ -1789,6 +1816,7 @@ class CableProfileChoices(ChoiceSet):
|
||||
(
|
||||
_('Breakout'),
|
||||
(
|
||||
(BREAKOUT_1C2P_2C1P, _('1C2P:2C1P breakout')),
|
||||
(BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
|
||||
(BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
|
||||
(BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),
|
||||
|
||||
@@ -306,12 +306,9 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
|
||||
fields = ('id', 'name', 'slug', 'facility', 'description')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
# extended in order to include querying on Location.facility
|
||||
queryset = super().search(queryset, name, value)
|
||||
|
||||
# Extend `search()` to include querying on Location.facility
|
||||
if value.strip():
|
||||
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
|
||||
|
||||
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
@@ -1386,6 +1386,7 @@ class MACAddressImportForm(PrimaryModelImportForm):
|
||||
|
||||
# Assign the MAC address as primary for its interface, if designated as such
|
||||
if interface and self.cleaned_data['is_primary'] and self.instance.pk:
|
||||
interface.snapshot()
|
||||
interface.primary_mac_address = self.instance
|
||||
interface.save()
|
||||
|
||||
@@ -1528,8 +1529,11 @@ class CableImportForm(PrimaryModelImportForm):
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
if device.virtual_chassis and device.virtual_chassis.master == device and \
|
||||
model.objects.filter(device=device, name=name).count() == 0:
|
||||
if (
|
||||
device.virtual_chassis and
|
||||
device.virtual_chassis.master == device and
|
||||
not model.objects.filter(device=device, name=name).exists()
|
||||
):
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
||||
from utilities.forms import get_field_value
|
||||
|
||||
__all__ = (
|
||||
@@ -70,18 +71,6 @@ class InterfaceCommonForm(forms.Form):
|
||||
|
||||
class ModuleCommonForm(forms.Form):
|
||||
|
||||
def _get_module_bay_tree(self, module_bay):
|
||||
module_bays = []
|
||||
while module_bay:
|
||||
module_bays.append(module_bay)
|
||||
if module_bay.module:
|
||||
module_bay = module_bay.module.module_bay
|
||||
else:
|
||||
module_bay = None
|
||||
|
||||
module_bays.reverse()
|
||||
return module_bays
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -100,7 +89,7 @@ class ModuleCommonForm(forms.Form):
|
||||
self.instance._disable_replication = True
|
||||
return
|
||||
|
||||
module_bays = self._get_module_bay_tree(module_bay)
|
||||
positions = get_module_bay_positions(module_bay)
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
@@ -119,25 +108,16 @@ class ModuleCommonForm(forms.Form):
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
resolved_name = template.name
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name:
|
||||
if not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||
)
|
||||
|
||||
if len(module_bays) != template.name.count(MODULE_TOKEN):
|
||||
raise forms.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 module_bay in module_bays:
|
||||
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
|
||||
try:
|
||||
resolved_name = resolve_module_placeholder(template.name, positions)
|
||||
except ValueError as e:
|
||||
raise forms.ValidationError(str(e))
|
||||
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ def get_cable_form(a_type, b_type):
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
# NOTE: Cable.clone() mirrors the parent selector mapping below:
|
||||
# termination_{end}_device / termination_{end}_powerpanel / termination_{end}_circuit
|
||||
# This supports both the "Clone" and "Create & Add Another" workflows.
|
||||
# If you change the mapping here, update Cable.clone() accordingly.
|
||||
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
|
||||
|
||||
# Device component
|
||||
|
||||
@@ -121,13 +121,52 @@ class ScopedImportForm(forms.Form):
|
||||
required=False,
|
||||
label=_('Scope type (app & model)')
|
||||
)
|
||||
scope_name = forms.CharField(
|
||||
required=False,
|
||||
label=_('Scope name'),
|
||||
help_text=_('Name of the assigned scope object (if not using ID)')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
scope_id = self.cleaned_data.get('scope_id')
|
||||
scope_name = self.cleaned_data.get('scope_name')
|
||||
scope_type = self.cleaned_data.get('scope_type')
|
||||
if scope_type and not scope_id:
|
||||
|
||||
# Cannot specify both scope_name and scope_id
|
||||
if scope_name and scope_id:
|
||||
raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
|
||||
|
||||
# Must specify scope_type with scope_name or scope_id
|
||||
if scope_name and not scope_type:
|
||||
raise ValidationError(_("scope_type must be specified when using scope_name"))
|
||||
if scope_id and not scope_type:
|
||||
raise ValidationError(_("scope_type must be specified when using scope_id"))
|
||||
|
||||
# Look up the scope object by name
|
||||
if scope_type and scope_name:
|
||||
model = scope_type.model_class()
|
||||
try:
|
||||
scope_obj = model.objects.get(name=scope_name)
|
||||
except model.DoesNotExist:
|
||||
raise ValidationError({
|
||||
'scope_name': _('{scope_type} "{name}" not found.').format(
|
||||
scope_type=bettertitle(model._meta.verbose_name),
|
||||
name=scope_name
|
||||
)
|
||||
})
|
||||
except model.MultipleObjectsReturned:
|
||||
raise ValidationError({
|
||||
'scope_name': _(
|
||||
'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
|
||||
).format(
|
||||
scope_type=bettertitle(model._meta.verbose_name),
|
||||
name=scope_name,
|
||||
)
|
||||
})
|
||||
self.cleaned_data['scope_id'] = scope_obj.pk
|
||||
elif scope_type and not scope_id:
|
||||
raise ValidationError({
|
||||
'scope_id': _(
|
||||
"Please select a {scope_type}."
|
||||
|
||||
@@ -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,
|
||||
@@ -142,6 +142,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 +161,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 +172,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 +188,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(
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from strawberry import ID
|
||||
from strawberry_django import BaseFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
|
||||
|
||||
from core.graphql.filters import ContentTypeFilter
|
||||
|
||||
@@ -66,9 +66,9 @@ class ComponentModelFilterMixin:
|
||||
)
|
||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
device_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -96,9 +96,9 @@ class ComponentTemplateFilterMixin:
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
device_type_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -4,7 +4,7 @@ import strawberry
|
||||
import strawberry_django
|
||||
from django.db.models import Q
|
||||
from strawberry.scalars import ID
|
||||
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup, StrFilterLookup
|
||||
|
||||
from dcim import models
|
||||
from dcim.constants import *
|
||||
@@ -114,7 +114,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
label: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -141,6 +141,20 @@ class CableTerminationFilter(ChangeLoggedModelFilter):
|
||||
)
|
||||
termination_id: ID | None = strawberry_django.filter_field()
|
||||
|
||||
# Cached relations
|
||||
_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
|
||||
name='device'
|
||||
)
|
||||
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
|
||||
name='rack'
|
||||
)
|
||||
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='location')
|
||||
)
|
||||
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
|
||||
name='site'
|
||||
)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
|
||||
class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
|
||||
@@ -196,9 +210,9 @@ class DeviceFilter(
|
||||
platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
serial: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
site_id: ID | None = strawberry_django.filter_field()
|
||||
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
@@ -253,32 +267,32 @@ class DeviceFilter(
|
||||
longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_ports')
|
||||
)
|
||||
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_server_ports')
|
||||
)
|
||||
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlets')
|
||||
)
|
||||
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_ports')
|
||||
)
|
||||
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_ports')
|
||||
)
|
||||
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_ports')
|
||||
)
|
||||
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bays')
|
||||
)
|
||||
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bays')
|
||||
)
|
||||
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
@@ -325,7 +339,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
manufacturer_id: ID | None = strawberry_django.filter_field()
|
||||
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
|
||||
@@ -342,13 +356,13 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
manufacturer_id: ID | None = strawberry_django.filter_field()
|
||||
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
default_platform_id: ID | None = strawberry_django.filter_field()
|
||||
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -369,36 +383,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
|
||||
rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
console_port_templates: (
|
||||
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
console_server_port_templates: (
|
||||
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_port_templates')
|
||||
)
|
||||
consoleserverporttemplates: (
|
||||
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
power_port_templates: (
|
||||
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
power_outlet_templates: (
|
||||
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
interface_templates: (
|
||||
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
front_port_templates: (
|
||||
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
rear_port_templates: (
|
||||
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
device_bay_templates: (
|
||||
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
module_bay_templates: (
|
||||
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
inventory_item_templates: (
|
||||
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
) = strawberry_django.filter_field(name='console_server_port_templates')
|
||||
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_port_templates')
|
||||
)
|
||||
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlet_templates')
|
||||
)
|
||||
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='interface_templates')
|
||||
)
|
||||
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_port_templates')
|
||||
)
|
||||
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_port_templates')
|
||||
)
|
||||
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bay_templates')
|
||||
)
|
||||
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bay_templates')
|
||||
)
|
||||
inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='inventory_item_templates')
|
||||
)
|
||||
console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
@@ -465,7 +479,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.MACAddress, lookups=True)
|
||||
class MACAddressFilter(PrimaryModelFilter):
|
||||
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
mac_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -511,7 +525,7 @@ class InterfaceFilter(
|
||||
duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
wwn: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
wwn: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -631,9 +645,9 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
manufacturer_id: ID | None = strawberry_django.filter_field()
|
||||
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
serial: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@@ -651,7 +665,7 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
|
||||
status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
facility: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -680,34 +694,34 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
|
||||
status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
serial: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_ports')
|
||||
)
|
||||
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_server_ports')
|
||||
)
|
||||
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlets')
|
||||
)
|
||||
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_ports')
|
||||
)
|
||||
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_ports')
|
||||
)
|
||||
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_ports')
|
||||
)
|
||||
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bays')
|
||||
)
|
||||
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bays')
|
||||
)
|
||||
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
@@ -720,17 +734,17 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
parent_id: ID | None = strawberry_django.filter_field()
|
||||
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
|
||||
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
|
||||
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
|
||||
class ModuleTypeProfileFilter(PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ModuleType, lookups=True)
|
||||
@@ -743,44 +757,41 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
profile_id: ID | None = strawberry_django.filter_field()
|
||||
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
console_port_templates: (
|
||||
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
console_server_port_templates: (
|
||||
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_port_templates')
|
||||
)
|
||||
consoleserverporttemplates: (
|
||||
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
power_port_templates: (
|
||||
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
power_outlet_templates: (
|
||||
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
interface_templates: (
|
||||
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
front_port_templates: (
|
||||
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
rear_port_templates: (
|
||||
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
device_bay_templates: (
|
||||
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
module_bay_templates: (
|
||||
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
inventory_item_templates: (
|
||||
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
) = strawberry_django.filter_field(name='console_server_port_templates')
|
||||
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_port_templates')
|
||||
)
|
||||
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlet_templates')
|
||||
)
|
||||
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='interface_templates')
|
||||
)
|
||||
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_port_templates')
|
||||
)
|
||||
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_port_templates')
|
||||
)
|
||||
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bay_templates')
|
||||
)
|
||||
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bay_templates')
|
||||
)
|
||||
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@@ -804,7 +815,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
|
||||
power_panel_id: ID | None = strawberry_django.filter_field()
|
||||
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
rack_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -875,7 +886,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
|
||||
location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.PowerPort, lookups=True)
|
||||
@@ -913,8 +924,8 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
manufacturer_id: ID | None = strawberry_django.filter_field()
|
||||
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -935,8 +946,8 @@ class RackFilter(
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
rack_type_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
facility_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
facility_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
site_id: ID | None = strawberry_django.filter_field()
|
||||
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
@@ -950,8 +961,8 @@ class RackFilter(
|
||||
)
|
||||
role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
role_id: ID | None = strawberry_django.filter_field()
|
||||
serial: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -969,7 +980,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
)
|
||||
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
user_id: ID | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -1020,8 +1031,8 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.Site, lookups=True)
|
||||
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -1035,11 +1046,11 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
|
||||
group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
facility: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
time_zone: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
physical_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
shipping_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
time_zone: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
physical_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
shipping_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -1068,8 +1079,8 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
|
||||
class VirtualChassisFilter(PrimaryModelFilter):
|
||||
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
master_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
domain: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
domain: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
members: (
|
||||
Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
@@ -1080,7 +1091,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
|
||||
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
device_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
status: (
|
||||
BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None
|
||||
) = (
|
||||
@@ -1097,7 +1108,7 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
primary_ip6_id: ID | None = strawberry_django.filter_field()
|
||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
interfaces: (
|
||||
Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
|
||||
@@ -160,6 +160,7 @@ class Cable(PrimaryModel):
|
||||
CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
|
||||
CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,
|
||||
@@ -293,7 +294,6 @@ class Cable(PrimaryModel):
|
||||
self._pk = self.pk
|
||||
|
||||
if self._orig_profile != self.profile:
|
||||
print(f'profile changed from {self._orig_profile} to {self.profile}')
|
||||
self.update_terminations(force=True)
|
||||
elif self._terminations_modified:
|
||||
self.update_terminations()
|
||||
@@ -305,6 +305,50 @@ class Cable(PrimaryModel):
|
||||
except UnsupportedCablePath as e:
|
||||
raise AbortRequest(e)
|
||||
|
||||
def clone(self):
|
||||
"""
|
||||
Return attributes suitable for cloning this cable.
|
||||
|
||||
In addition to the fields defined in `clone_fields`, include the termination
|
||||
type and parent selector fields used by dcim.forms.connections.get_cable_form().
|
||||
"""
|
||||
attrs = super().clone()
|
||||
|
||||
# Mirror dcim.forms.connections.get_cable_form() parent-field logic
|
||||
for cable_end, terminations in (('a', self.a_terminations), ('b', self.b_terminations)):
|
||||
if not terminations:
|
||||
continue
|
||||
|
||||
term_cls = type(terminations[0])
|
||||
term_label = term_cls._meta.label_lower
|
||||
|
||||
# Matches CableForm choices: "<app_label>.<model>"
|
||||
attrs[f'{cable_end}_terminations_type'] = term_label
|
||||
|
||||
# Device component
|
||||
if hasattr(term_cls, 'device'):
|
||||
device_ids = sorted({t.device_id for t in terminations if t.device_id})
|
||||
if device_ids:
|
||||
attrs[f'termination_{cable_end}_device'] = device_ids
|
||||
|
||||
# PowerFeed
|
||||
elif term_label == 'dcim.powerfeed':
|
||||
powerpanel_ids = sorted({t.power_panel_id for t in terminations if t.power_panel_id})
|
||||
if powerpanel_ids:
|
||||
attrs[f'termination_{cable_end}_powerpanel'] = powerpanel_ids
|
||||
|
||||
# CircuitTermination
|
||||
elif term_label == 'circuits.circuittermination':
|
||||
circuit_ids = sorted({t.circuit_id for t in terminations if t.circuit_id})
|
||||
if circuit_ids:
|
||||
attrs[f'termination_{cable_end}_circuit'] = circuit_ids
|
||||
|
||||
# Never clone the actual terminations, as they are already occupied
|
||||
attrs.pop('a_terminations', None)
|
||||
attrs.pop('b_terminations', None)
|
||||
|
||||
return attrs
|
||||
|
||||
def serialize_object(self, exclude=None):
|
||||
data = serialize_object(self, exclude=exclude or [])
|
||||
|
||||
@@ -359,6 +403,15 @@ class Cable(PrimaryModel):
|
||||
"""
|
||||
a_terminations, b_terminations = self.get_terminations()
|
||||
|
||||
# When force-recreating terminations (e.g. after a profile change), cache the termination objects
|
||||
# from the database before deleting, so they are available for recreation. Without this, the
|
||||
# a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
|
||||
if force:
|
||||
if not hasattr(self, '_a_terminations'):
|
||||
self._a_terminations = list(a_terminations.keys())
|
||||
if not hasattr(self, '_b_terminations'):
|
||||
self._b_terminations = list(b_terminations.keys())
|
||||
|
||||
# Delete any stale CableTerminations
|
||||
for termination, ct in a_terminations.items():
|
||||
if force or (termination.pk and termination not in self.a_terminations):
|
||||
@@ -768,9 +821,9 @@ class CablePath(models.Model):
|
||||
path.append([
|
||||
object_to_path_node(t) for t in terminations
|
||||
])
|
||||
# If not null, push cable position onto the stack
|
||||
# If not null, push cable positions onto the stack
|
||||
if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
|
||||
position_stack.append([terminations[0].cable_positions[0]])
|
||||
position_stack.append(list(terminations[0].cable_positions))
|
||||
|
||||
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||
links = list(dict.fromkeys(
|
||||
@@ -811,10 +864,33 @@ class CablePath(models.Model):
|
||||
# Profile-based tracing
|
||||
if links[0].profile:
|
||||
cable_profile = links[0].profile_class()
|
||||
position = position_stack.pop()[0] if position_stack else None
|
||||
term, position = cable_profile.get_peer_termination(terminations[0], position)
|
||||
remote_terminations = [term]
|
||||
position_stack.append([position])
|
||||
positions = position_stack.pop() if position_stack else [None]
|
||||
remote_terminations = []
|
||||
new_positions = []
|
||||
|
||||
# Build (termination, position) pairs by matching stacked positions
|
||||
# to each termination's cable_positions. This correctly handles
|
||||
# multiple terminations on different connectors of the same cable.
|
||||
remaining = list(positions)
|
||||
term_position_pairs = []
|
||||
for term in terminations:
|
||||
if term.cable_positions:
|
||||
for cp in term.cable_positions:
|
||||
if cp in remaining:
|
||||
term_position_pairs.append((term, cp))
|
||||
remaining.remove(cp)
|
||||
|
||||
# Fallback for when positions don't match cable_positions
|
||||
# (e.g., empty position stack yielding [None])
|
||||
if not term_position_pairs:
|
||||
term_position_pairs = [(terminations[0], pos) for pos in positions]
|
||||
|
||||
for term, pos in term_position_pairs:
|
||||
peer, new_pos = cable_profile.get_peer_termination(term, pos)
|
||||
if peer not in remote_terminations:
|
||||
remote_terminations.append(peer)
|
||||
new_positions.append(new_pos)
|
||||
position_stack.append(new_positions)
|
||||
|
||||
# Legacy (positionless) behavior
|
||||
else:
|
||||
|
||||
@@ -9,6 +9,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models.base import PortMappingBase
|
||||
from dcim.models.mixins import InterfaceValidationMixin
|
||||
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
@@ -165,41 +166,15 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
_("A component template must be associated with either a device type or a module type.")
|
||||
)
|
||||
|
||||
def _get_module_tree(self, module):
|
||||
modules = []
|
||||
while module:
|
||||
modules.append(module)
|
||||
if module.module_bay:
|
||||
module = module.module_bay.module
|
||||
else:
|
||||
module = None
|
||||
|
||||
modules.reverse()
|
||||
return modules
|
||||
|
||||
def resolve_name(self, module):
|
||||
if MODULE_TOKEN not in self.name:
|
||||
if MODULE_TOKEN not in self.name or not module:
|
||||
return self.name
|
||||
|
||||
if 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
|
||||
return resolve_module_placeholder(self.name, get_module_bay_positions(module.module_bay))
|
||||
|
||||
def resolve_label(self, module):
|
||||
if MODULE_TOKEN not in self.label:
|
||||
if MODULE_TOKEN not in self.label or not module:
|
||||
return self.label
|
||||
|
||||
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
|
||||
return resolve_module_placeholder(self.label, get_module_bay_positions(module.module_bay))
|
||||
|
||||
|
||||
class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
@@ -729,11 +704,16 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
verbose_name = _('module bay template')
|
||||
verbose_name_plural = _('module bay templates')
|
||||
|
||||
def resolve_position(self, module):
|
||||
if MODULE_TOKEN not in self.position or not module:
|
||||
return self.position
|
||||
return resolve_module_placeholder(self.position, get_module_bay_positions(module.module_bay))
|
||||
|
||||
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,
|
||||
position=self.resolve_position(kwargs.get('module')),
|
||||
**kwargs
|
||||
)
|
||||
instantiate.do_not_call_in_templates = True
|
||||
|
||||
@@ -2,7 +2,7 @@ from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
@@ -307,11 +307,12 @@ class PathEndpoint(models.Model):
|
||||
|
||||
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -323,11 +324,14 @@ class PathEndpoint(models.Model):
|
||||
|
||||
# Construct the complete path (including e.g. bridged interfaces)
|
||||
while origin is not None:
|
||||
|
||||
if origin._path is None:
|
||||
# Go through the public accessor rather than dereferencing `_path`
|
||||
# directly. During cable edits, CablePath rows can be deleted and
|
||||
# recreated while this endpoint instance is still in memory.
|
||||
cable_path = origin.path
|
||||
if cable_path is None:
|
||||
break
|
||||
|
||||
path.extend(origin._path.path_objects)
|
||||
path.extend(cable_path.path_objects)
|
||||
|
||||
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
|
||||
if len(path) % 3 == 1:
|
||||
@@ -336,8 +340,8 @@ class PathEndpoint(models.Model):
|
||||
elif len(path) % 3 == 2:
|
||||
path.insert(-1, [])
|
||||
|
||||
# Check for a bridged relationship to continue the trace
|
||||
destinations = origin._path.destinations
|
||||
# Check for a bridged relationship to continue the trace.
|
||||
destinations = cable_path.destinations
|
||||
if len(destinations) == 1:
|
||||
origin = getattr(destinations[0], 'bridge', None)
|
||||
else:
|
||||
@@ -348,14 +352,42 @@ class PathEndpoint(models.Model):
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self._path
|
||||
"""
|
||||
Return this endpoint's current CablePath, if any.
|
||||
|
||||
`_path` is a denormalized reference that is updated from CablePath
|
||||
save/delete handlers, including queryset.update() calls on origin
|
||||
endpoints. That means an already-instantiated endpoint can briefly hold
|
||||
a stale in-memory `_path` relation while the database already points to
|
||||
a different CablePath (or to no path at all).
|
||||
|
||||
If the cached relation points to a CablePath that has just been
|
||||
deleted, refresh only the `_path` field from the database and retry.
|
||||
This keeps the fix cheap and narrowly scoped to the denormalized FK.
|
||||
"""
|
||||
if self._path_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._path
|
||||
except ObjectDoesNotExist:
|
||||
# Refresh only the denormalized FK instead of the whole model.
|
||||
# The expected problem here is in-memory staleness during path
|
||||
# rebuilds, not persistent database corruption.
|
||||
self.refresh_from_db(fields=['_path'])
|
||||
return self._path if self._path_id else None
|
||||
|
||||
@cached_property
|
||||
def connected_endpoints(self):
|
||||
"""
|
||||
Caching accessor for the attached CablePath's destination (if any)
|
||||
Caching accessor for the attached CablePath's destinations (if any).
|
||||
|
||||
Always route through `path` so stale in-memory `_path` references are
|
||||
repaired before we cache the result for the lifetime of this instance.
|
||||
"""
|
||||
return self._path.destinations if self._path else []
|
||||
if cable_path := self.path:
|
||||
return cable_path.destinations
|
||||
return []
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1149,7 +1149,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
)
|
||||
device = tables.Column(
|
||||
verbose_name=_('Device'),
|
||||
order_by=('device___name',),
|
||||
order_by=('device__name',),
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
@@ -1205,7 +1205,8 @@ class MACAddressTable(PrimaryModelTable):
|
||||
verbose_name=_('Parent')
|
||||
)
|
||||
is_primary = columns.BooleanColumn(
|
||||
verbose_name=_('Primary')
|
||||
verbose_name=_('Primary'),
|
||||
orderable=False,
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:macaddress_list'
|
||||
|
||||
@@ -56,7 +56,9 @@ class ModuleTypeTable(PrimaryModelTable):
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
attributes = columns.DictColumn()
|
||||
attributes = columns.DictColumn(
|
||||
orderable=False,
|
||||
)
|
||||
module_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:module_list',
|
||||
url_params={'module_type_id': 'pk'},
|
||||
|
||||
@@ -218,7 +218,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
class Meta(PrimaryModelTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
|
||||
|
||||
@@ -2614,6 +2614,126 @@ class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_graphql_cable_termination_cached_filters(self):
|
||||
"""
|
||||
Validate filtering cables by cached CableTermination relations via GraphQL:
|
||||
|
||||
cable_list(filters: { terminations: { <relation>: {...}, DISTINCT: true } })
|
||||
|
||||
Also asserts deduplication when both ends match (cable between two interfaces
|
||||
on the same device/rack/location/site).
|
||||
"""
|
||||
self.add_permissions(
|
||||
'dcim.view_cable',
|
||||
'dcim.view_device',
|
||||
'dcim.view_interface',
|
||||
'dcim.view_rack',
|
||||
'dcim.view_location',
|
||||
'dcim.view_site',
|
||||
)
|
||||
|
||||
# Reuse existing fixtures from setUpTestData()
|
||||
devicetype = DeviceType.objects.get(slug='device-type-1')
|
||||
role = DeviceRole.objects.get(slug='device-role-1')
|
||||
|
||||
# Create an isolated topology for this test
|
||||
site_a = Site.objects.create(name='GQL Site A', slug='gql-site-a')
|
||||
site_b = Site.objects.create(name='GQL Site B', slug='gql-site-b')
|
||||
|
||||
location_a = Location.objects.create(
|
||||
site=site_a,
|
||||
name='GQL Location A',
|
||||
slug='gql-location-a',
|
||||
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||
)
|
||||
location_b = Location.objects.create(
|
||||
site=site_b,
|
||||
name='GQL Location B',
|
||||
slug='gql-location-b',
|
||||
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
rack_a = Rack.objects.create(site=site_a, location=location_a, name='GQL Rack A', u_height=42)
|
||||
rack_b = Rack.objects.create(site=site_b, location=location_b, name='GQL Rack B', u_height=42)
|
||||
|
||||
device_a = Device.objects.create(
|
||||
device_type=devicetype,
|
||||
role=role,
|
||||
name='GQL Device A',
|
||||
site=site_a,
|
||||
location=location_a,
|
||||
rack=rack_a,
|
||||
)
|
||||
device_b = Device.objects.create(
|
||||
device_type=devicetype,
|
||||
role=role,
|
||||
name='GQL Device B',
|
||||
site=site_b,
|
||||
location=location_b,
|
||||
rack=rack_b,
|
||||
)
|
||||
|
||||
a0 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
|
||||
a1 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth1')
|
||||
a2 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth2')
|
||||
b0 = Interface.objects.create(device=device_b, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
|
||||
|
||||
# Both ends on Device A (duplication risk without DISTINCT)
|
||||
cable_same_device = Cable(a_terminations=[a0], b_terminations=[a1], label='GQL Cable Same Device')
|
||||
cable_same_device.save()
|
||||
|
||||
# Cross to Device B
|
||||
cable_cross = Cable(a_terminations=[a2], b_terminations=[b0], label='GQL Cable Cross')
|
||||
cable_cross.save()
|
||||
|
||||
expected_a = {str(cable_same_device.pk), str(cable_cross.pk)}
|
||||
expected_b = {str(cable_cross.pk)}
|
||||
|
||||
url = reverse('graphql')
|
||||
|
||||
test_cases = (
|
||||
# Device (ID + name)
|
||||
(f'device: {{ id: {{ exact: "{device_a.pk}" }} }}', expected_a),
|
||||
(f'device: {{ name: {{ exact: "{device_a.name}" }} }}', expected_a),
|
||||
(f'device: {{ id: {{ exact: "{device_b.pk}" }} }}', expected_b),
|
||||
(f'device: {{ name: {{ exact: "{device_b.name}" }} }}', expected_b),
|
||||
# Rack (ID + name)
|
||||
(f'rack: {{ id: {{ exact: "{rack_a.pk}" }} }}', expected_a),
|
||||
(f'rack: {{ name: {{ exact: "{rack_a.name}" }} }}', expected_a),
|
||||
(f'rack: {{ id: {{ exact: "{rack_b.pk}" }} }}', expected_b),
|
||||
(f'rack: {{ name: {{ exact: "{rack_b.name}" }} }}', expected_b),
|
||||
# Location (ID + name)
|
||||
(f'location: {{ id: {{ exact: "{location_a.pk}" }} }}', expected_a),
|
||||
(f'location: {{ name: {{ exact: "{location_a.name}" }} }}', expected_a),
|
||||
(f'location: {{ id: {{ exact: "{location_b.pk}" }} }}', expected_b),
|
||||
(f'location: {{ name: {{ exact: "{location_b.name}" }} }}', expected_b),
|
||||
# Site (ID + slug)
|
||||
(f'site: {{ id: {{ exact: "{site_a.pk}" }} }}', expected_a),
|
||||
(f'site: {{ slug: {{ exact: "{site_a.slug}" }} }}', expected_a),
|
||||
(f'site: {{ id: {{ exact: "{site_b.pk}" }} }}', expected_b),
|
||||
(f'site: {{ slug: {{ exact: "{site_b.slug}" }} }}', expected_b),
|
||||
)
|
||||
|
||||
for inner_filter, expected in test_cases:
|
||||
with self.subTest(filter=inner_filter):
|
||||
query = f"""{{
|
||||
cable_list(filters: {{ terminations: {{ {inner_filter} DISTINCT: true }} }})
|
||||
{{ id }}
|
||||
}}"""
|
||||
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertNotIn('errors', data)
|
||||
|
||||
rows = data['data']['cable_list']
|
||||
ids = [row['id'] for row in rows]
|
||||
|
||||
# Ensure DISTINCT is actually effective (no duplicate cables when both ends match)
|
||||
self.assertEqual(len(ids), len(set(ids)), f'Duplicate cables returned for: {inner_filter}')
|
||||
|
||||
self.assertSetEqual(set(ids), expected)
|
||||
|
||||
|
||||
class CableTerminationTest(
|
||||
APIViewTestCases.GetObjectViewTestCase,
|
||||
|
||||
@@ -797,6 +797,432 @@ class CablePathTests(CablePathTestCase):
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interfaces[0]).render()
|
||||
|
||||
def test_107_duplex_interface_profiled_patch_through_trunk_with_splices(self):
|
||||
"""
|
||||
Tests that a duplex interface (cable_positions=[1,2]) traces both positions through
|
||||
profiled cables and splice pass-throughs, producing a single CablePath with both
|
||||
strands visible.
|
||||
|
||||
[IF1] -C1(1C2P)- [FP1(p=2)][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
|
||||
[FP2] -C3- [FP4][RP3(p=2)] -C4(1C2P)- [RP4(p=2)][FP6(p=2)]
|
||||
-C5(1C2P)- [IF2] / [FP3] -C6- [FP5]
|
||||
|
||||
Cable profiles: C1=1C2P, C2=1C2P, C3/C6=unprofiled splices, C4=1C2P, C5=1C2P
|
||||
"""
|
||||
interfaces = [
|
||||
Interface.objects.create(device=self.device, name='Interface 1'),
|
||||
Interface.objects.create(device=self.device, name='Interface 2'),
|
||||
]
|
||||
rear_ports = [
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
|
||||
]
|
||||
front_ports = [
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2), # Panel A duplex
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 2'), # Splice A strand 1
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 3'), # Splice A strand 2
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 4'), # Splice B strand 1
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 5'), # Splice B strand 2
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 6', positions=2), # Panel B duplex
|
||||
]
|
||||
PortMapping.objects.bulk_create([
|
||||
# Panel A: duplex FP1(pos=2) -> RP1(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=1,
|
||||
rear_port=rear_ports[0], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=2,
|
||||
rear_port=rear_ports[0], rear_port_position=2,
|
||||
),
|
||||
# Splice A: FP2, FP3 -> RP2(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[1], front_port_position=1,
|
||||
rear_port=rear_ports[1], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[2], front_port_position=1,
|
||||
rear_port=rear_ports[1], rear_port_position=2,
|
||||
),
|
||||
# Splice B: FP4, FP5 -> RP3(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[3], front_port_position=1,
|
||||
rear_port=rear_ports[2], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[4], front_port_position=1,
|
||||
rear_port=rear_ports[2], rear_port_position=2,
|
||||
),
|
||||
# Panel B: duplex FP6(pos=2) -> RP4(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[5], front_port_position=1,
|
||||
rear_port=rear_ports[3], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[5], front_port_position=2,
|
||||
rear_port=rear_ports[3], rear_port_position=2,
|
||||
),
|
||||
])
|
||||
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C2P,
|
||||
a_terminations=[interfaces[0]],
|
||||
b_terminations=[front_ports[0]],
|
||||
)
|
||||
cable1.clean()
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C2P,
|
||||
a_terminations=[rear_ports[0]],
|
||||
b_terminations=[rear_ports[1]],
|
||||
)
|
||||
cable2.clean()
|
||||
cable2.save()
|
||||
cable3 = Cable(
|
||||
a_terminations=[front_ports[1]],
|
||||
b_terminations=[front_ports[3]],
|
||||
)
|
||||
cable3.clean()
|
||||
cable3.save()
|
||||
cable4 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C2P,
|
||||
a_terminations=[rear_ports[2]],
|
||||
b_terminations=[rear_ports[3]],
|
||||
)
|
||||
cable4.clean()
|
||||
cable4.save()
|
||||
cable5 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C2P,
|
||||
a_terminations=[front_ports[5]],
|
||||
b_terminations=[interfaces[1]],
|
||||
)
|
||||
cable5.clean()
|
||||
cable5.save()
|
||||
cable6 = Cable(
|
||||
a_terminations=[front_ports[2]],
|
||||
b_terminations=[front_ports[4]],
|
||||
)
|
||||
cable6.clean()
|
||||
cable6.save()
|
||||
|
||||
# Verify forward path: IF1 -> IF2 (both strands through splice)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interfaces[0], cable1, front_ports[0],
|
||||
rear_ports[0], cable2, rear_ports[1],
|
||||
[front_ports[1], front_ports[2]], [cable3, cable6], [front_ports[3], front_ports[4]],
|
||||
rear_ports[2], cable4, rear_ports[3],
|
||||
front_ports[5], cable5, interfaces[1],
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
# Verify reverse path: IF2 -> IF1
|
||||
self.assertPathExists(
|
||||
(
|
||||
interfaces[1], cable5, front_ports[5],
|
||||
rear_ports[3], cable4, rear_ports[2],
|
||||
[front_ports[3], front_ports[4]], [cable3, cable6], [front_ports[1], front_ports[2]],
|
||||
rear_ports[1], cable2, rear_ports[0],
|
||||
front_ports[0], cable1, interfaces[0],
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Verify cable positions on interfaces
|
||||
for iface in interfaces:
|
||||
iface.refresh_from_db()
|
||||
self.assertEqual(interfaces[0].cable_connector, 1)
|
||||
self.assertEqual(interfaces[0].cable_positions, [1, 2])
|
||||
self.assertEqual(interfaces[1].cable_connector, 1)
|
||||
self.assertEqual(interfaces[1].cable_positions, [1, 2])
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interfaces[0]).render()
|
||||
|
||||
def test_108_single_interface_two_frontports_unprofiled_through_trunk_with_splices(self):
|
||||
"""
|
||||
Tests that positions seeded by PortMapping (not cable_positions) are preserved
|
||||
when crossing profiled cables.
|
||||
|
||||
[IF1] -C1- [FP1,FP2][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
|
||||
[FP3] -C3- [FP5][RP3(p=2)] -C4(1C2P)- [RP4(p=2)]
|
||||
[FP7,FP8] -C5- [IF2] / [FP4] -C6- [FP6]
|
||||
|
||||
PortMappings: FP1->RP1p1, FP2->RP1p2, FP3->RP2p1, FP4->RP2p2,
|
||||
FP5->RP3p1, FP6->RP3p2, FP7->RP4p1, FP8->RP4p2
|
||||
|
||||
C1 is unprofiled (1 IF -> 2 FPs), C2/C4 are 1C2P trunks,
|
||||
C3/C6 are unprofiled splices, C5 is unprofiled (2 FPs -> 1 IF).
|
||||
"""
|
||||
interfaces = [
|
||||
Interface.objects.create(device=self.device, name='Interface 1'),
|
||||
Interface.objects.create(device=self.device, name='Interface 2'),
|
||||
]
|
||||
rear_ports = [
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
|
||||
]
|
||||
front_ports = [
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 1'), # Panel A strand 1
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 2'), # Panel A strand 2
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 3'), # Splice A strand 1
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 4'), # Splice A strand 2
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 5'), # Splice B strand 1
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 6'), # Splice B strand 2
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 7'), # Panel B strand 1
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 8'), # Panel B strand 2
|
||||
]
|
||||
PortMapping.objects.bulk_create([
|
||||
# Panel A: FP1, FP2 -> RP1(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=1,
|
||||
rear_port=rear_ports[0], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[1], front_port_position=1,
|
||||
rear_port=rear_ports[0], rear_port_position=2,
|
||||
),
|
||||
# Splice A: FP3, FP4 -> RP2(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[2], front_port_position=1,
|
||||
rear_port=rear_ports[1], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[3], front_port_position=1,
|
||||
rear_port=rear_ports[1], rear_port_position=2,
|
||||
),
|
||||
# Splice B: FP5, FP6 -> RP3(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[4], front_port_position=1,
|
||||
rear_port=rear_ports[2], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[5], front_port_position=1,
|
||||
rear_port=rear_ports[2], rear_port_position=2,
|
||||
),
|
||||
# Panel B: FP7, FP8 -> RP4(pos=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[6], front_port_position=1,
|
||||
rear_port=rear_ports[3], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[7], front_port_position=1,
|
||||
rear_port=rear_ports[3], rear_port_position=2,
|
||||
),
|
||||
])
|
||||
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
a_terminations=[interfaces[0]],
|
||||
b_terminations=[front_ports[0], front_ports[1]],
|
||||
)
|
||||
cable1.clean()
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C2P,
|
||||
a_terminations=[rear_ports[0]],
|
||||
b_terminations=[rear_ports[1]],
|
||||
)
|
||||
cable2.clean()
|
||||
cable2.save()
|
||||
cable3 = Cable(
|
||||
a_terminations=[front_ports[2]],
|
||||
b_terminations=[front_ports[4]],
|
||||
)
|
||||
cable3.clean()
|
||||
cable3.save()
|
||||
cable4 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C2P,
|
||||
a_terminations=[rear_ports[2]],
|
||||
b_terminations=[rear_ports[3]],
|
||||
)
|
||||
cable4.clean()
|
||||
cable4.save()
|
||||
cable5 = Cable(
|
||||
a_terminations=[front_ports[6], front_ports[7]],
|
||||
b_terminations=[interfaces[1]],
|
||||
)
|
||||
cable5.clean()
|
||||
cable5.save()
|
||||
cable6 = Cable(
|
||||
a_terminations=[front_ports[3]],
|
||||
b_terminations=[front_ports[5]],
|
||||
)
|
||||
cable6.clean()
|
||||
cable6.save()
|
||||
|
||||
# Verify forward path: IF1 -> IF2 (both strands through splice)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interfaces[0], cable1, [front_ports[0], front_ports[1]],
|
||||
rear_ports[0], cable2, rear_ports[1],
|
||||
[front_ports[2], front_ports[3]], [cable3, cable6], [front_ports[4], front_ports[5]],
|
||||
rear_ports[2], cable4, rear_ports[3],
|
||||
[front_ports[6], front_ports[7]], cable5, interfaces[1],
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
# Verify reverse path: IF2 -> IF1
|
||||
self.assertPathExists(
|
||||
(
|
||||
interfaces[1], cable5, [front_ports[6], front_ports[7]],
|
||||
rear_ports[3], cable4, rear_ports[2],
|
||||
[front_ports[4], front_ports[5]], [cable3, cable6], [front_ports[2], front_ports[3]],
|
||||
rear_ports[1], cable2, rear_ports[0],
|
||||
[front_ports[0], front_ports[1]], cable1, interfaces[0],
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Verify cable positions are not set (unprofiled patch cables)
|
||||
for iface in interfaces:
|
||||
iface.refresh_from_db()
|
||||
self.assertIsNone(interfaces[0].cable_connector)
|
||||
self.assertIsNone(interfaces[0].cable_positions)
|
||||
self.assertIsNone(interfaces[1].cable_connector)
|
||||
self.assertIsNone(interfaces[1].cable_positions)
|
||||
|
||||
def test_109_multiconnector_trunk_through_patch_panel(self):
|
||||
"""
|
||||
Tests that a 4-position interface traces correctly through a patch panel
|
||||
that fans out to both connectors of a Trunk2C2P cable.
|
||||
|
||||
[IF1] --C1(1C4P)-- [FP1(p=4)][RP1(p=2)] --C3(Trunk2C2P)-- [RP3(p=2)][FP5(p=4)] --C5(1C4P)-- [IF2]
|
||||
[RP2(p=2)] [RP4(p=2)]
|
||||
|
||||
PortMappings (Panel A): FP1p1->RP1p1, FP1p2->RP1p2, FP1p3->RP2p1, FP1p4->RP2p2
|
||||
PortMappings (Panel B): FP5p1->RP3p1, FP5p2->RP3p2, FP5p3->RP4p1, FP5p4->RP4p2
|
||||
"""
|
||||
interfaces = [
|
||||
Interface.objects.create(device=self.device, name='Interface 1'),
|
||||
Interface.objects.create(device=self.device, name='Interface 2'),
|
||||
]
|
||||
rear_ports = [
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
|
||||
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
|
||||
]
|
||||
front_ports = [
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 1', positions=4),
|
||||
FrontPort.objects.create(device=self.device, name='Front Port 5', positions=4),
|
||||
]
|
||||
PortMapping.objects.bulk_create([
|
||||
# Panel A: FP1(p=4) -> RP1(p=2) and RP2(p=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=1,
|
||||
rear_port=rear_ports[0], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=2,
|
||||
rear_port=rear_ports[0], rear_port_position=2,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=3,
|
||||
rear_port=rear_ports[1], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[0], front_port_position=4,
|
||||
rear_port=rear_ports[1], rear_port_position=2,
|
||||
),
|
||||
# Panel B: FP5(p=4) -> RP3(p=2) and RP4(p=2)
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[1], front_port_position=1,
|
||||
rear_port=rear_ports[2], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[1], front_port_position=2,
|
||||
rear_port=rear_ports[2], rear_port_position=2,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[1], front_port_position=3,
|
||||
rear_port=rear_ports[3], rear_port_position=1,
|
||||
),
|
||||
PortMapping(
|
||||
device=self.device, front_port=front_ports[1], front_port_position=4,
|
||||
rear_port=rear_ports[3], rear_port_position=2,
|
||||
),
|
||||
])
|
||||
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C4P,
|
||||
a_terminations=[interfaces[0]],
|
||||
b_terminations=[front_ports[0]],
|
||||
)
|
||||
cable1.clean()
|
||||
cable1.save()
|
||||
cable3 = Cable(
|
||||
profile=CableProfileChoices.TRUNK_2C2P,
|
||||
a_terminations=[rear_ports[0], rear_ports[1]],
|
||||
b_terminations=[rear_ports[2], rear_ports[3]],
|
||||
)
|
||||
cable3.clean()
|
||||
cable3.save()
|
||||
cable5 = Cable(
|
||||
profile=CableProfileChoices.SINGLE_1C4P,
|
||||
a_terminations=[front_ports[1]],
|
||||
b_terminations=[interfaces[1]],
|
||||
)
|
||||
cable5.clean()
|
||||
cable5.save()
|
||||
|
||||
# Verify forward path: IF1 -> IF2 (all 4 positions through trunk)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interfaces[0], cable1, front_ports[0],
|
||||
[rear_ports[0], rear_ports[1]], cable3, [rear_ports[2], rear_ports[3]],
|
||||
front_ports[1], cable5, interfaces[1],
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
# Verify reverse path: IF2 -> IF1
|
||||
self.assertPathExists(
|
||||
(
|
||||
interfaces[1], cable5, front_ports[1],
|
||||
[rear_ports[2], rear_ports[3]], cable3, [rear_ports[0], rear_ports[1]],
|
||||
front_ports[0], cable1, interfaces[0],
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Verify cable positions
|
||||
for iface in interfaces:
|
||||
iface.refresh_from_db()
|
||||
self.assertEqual(interfaces[0].cable_connector, 1)
|
||||
self.assertEqual(interfaces[0].cable_positions, [1, 2, 3, 4])
|
||||
self.assertEqual(interfaces[1].cable_connector, 1)
|
||||
self.assertEqual(interfaces[1].cable_positions, [1, 2, 3, 4])
|
||||
|
||||
# Verify rear port connector assignments
|
||||
for rp in rear_ports:
|
||||
rp.refresh_from_db()
|
||||
self.assertEqual(rear_ports[0].cable_connector, 1)
|
||||
self.assertEqual(rear_ports[0].cable_positions, [1, 2])
|
||||
self.assertEqual(rear_ports[1].cable_connector, 2)
|
||||
self.assertEqual(rear_ports[1].cable_positions, [1, 2])
|
||||
self.assertEqual(rear_ports[2].cable_connector, 1)
|
||||
self.assertEqual(rear_ports[2].cable_positions, [1, 2])
|
||||
self.assertEqual(rear_ports[3].cable_connector, 2)
|
||||
self.assertEqual(rear_ports[3].cable_positions, [1, 2])
|
||||
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interfaces[0]).render()
|
||||
|
||||
def test_202_single_path_via_pass_through_with_breakouts(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
|
||||
|
||||
@@ -10,7 +10,8 @@ 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.forms.rendering import M2MAddRemoveFields
|
||||
from utilities.testing import create_test_device
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@@ -417,3 +418,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())
|
||||
|
||||
@@ -5,6 +5,7 @@ from circuits.models import *
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.events import serialize_for_event
|
||||
from extras.models import CustomField
|
||||
from ipam.models import Prefix
|
||||
from netbox.choices import WeightUnitChoices
|
||||
@@ -849,6 +850,121 @@ 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') # #20474
|
||||
def test_single_module_token_at_nested_depth(self):
|
||||
"""
|
||||
A module type with a single {module} token should install at depth > 1
|
||||
without raising a token count mismatch error, resolving to the immediate
|
||||
parent bay's position.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Chassis with Rear Card',
|
||||
slug='chassis-with-rear-card'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Rear card slot',
|
||||
position='1'
|
||||
)
|
||||
|
||||
rear_card_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Rear Card'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=rear_card_type,
|
||||
name='SFP slot 1',
|
||||
position='1'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=rear_card_type,
|
||||
name='SFP slot 2',
|
||||
position='2'
|
||||
)
|
||||
|
||||
sfp_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='SFP Module'
|
||||
)
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=sfp_type,
|
||||
name='SFP {module}',
|
||||
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Test Chassis',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
rear_card_bay = device.modulebays.get(name='Rear card slot')
|
||||
rear_card = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=rear_card_bay,
|
||||
module_type=rear_card_type
|
||||
)
|
||||
|
||||
sfp_bay = rear_card.modulebays.get(name='SFP slot 2')
|
||||
sfp_module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=sfp_bay,
|
||||
module_type=sfp_type
|
||||
)
|
||||
|
||||
interface = sfp_module.interfaces.first()
|
||||
self.assertEqual(interface.name, 'SFP 2')
|
||||
|
||||
@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"""
|
||||
@@ -1201,6 +1317,94 @@ class CableTestCase(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
def test_cable_profile_change_preserves_terminations(self):
|
||||
"""
|
||||
When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
|
||||
bulk edit), the existing termination points must be preserved.
|
||||
"""
|
||||
cable = Cable.objects.first()
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
|
||||
# Verify initial state: cable has terminations and no profile
|
||||
self.assertEqual(cable.profile, '')
|
||||
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
|
||||
|
||||
# Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
|
||||
# Crucially, do NOT set a_terminations or b_terminations on the instance.
|
||||
cable_from_db = Cable.objects.get(pk=cable.pk)
|
||||
cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
|
||||
cable_from_db.save()
|
||||
|
||||
# Verify terminations are preserved
|
||||
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
|
||||
|
||||
# Verify the correct interfaces are still terminated
|
||||
cable_from_db.refresh_from_db()
|
||||
a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
|
||||
b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
|
||||
self.assertEqual(a_terms, [interface1])
|
||||
self.assertEqual(b_terms, [interface2])
|
||||
|
||||
@tag('regression') # #21498
|
||||
def test_path_refreshes_replaced_cablepath_reference(self):
|
||||
"""
|
||||
An already-instantiated interface should refresh its denormalized
|
||||
`_path` foreign key when the referenced CablePath row has been
|
||||
replaced in the database.
|
||||
"""
|
||||
stale_interface = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
old_path = CablePath.objects.get(pk=stale_interface._path_id)
|
||||
|
||||
new_path = CablePath(
|
||||
path=old_path.path,
|
||||
is_active=old_path.is_active,
|
||||
is_complete=old_path.is_complete,
|
||||
is_split=old_path.is_split,
|
||||
)
|
||||
old_path_id = old_path.pk
|
||||
old_path.delete()
|
||||
new_path.save()
|
||||
|
||||
# The old CablePath no longer exists
|
||||
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
|
||||
|
||||
# The already-instantiated interface still points to the deleted path
|
||||
# until the accessor refreshes `_path` from the database.
|
||||
self.assertEqual(stale_interface._path_id, old_path_id)
|
||||
self.assertEqual(stale_interface.path.pk, new_path.pk)
|
||||
|
||||
@tag('regression') # #21498
|
||||
def test_serialize_for_event_handles_stale_cablepath_reference_after_retermination(self):
|
||||
"""
|
||||
Serializing an interface whose previously cached `_path` row has been
|
||||
deleted during cable retermination must not raise.
|
||||
"""
|
||||
stale_interface = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
old_path_id = stale_interface._path_id
|
||||
new_peer = Interface.objects.get(device__name='TestDevice2', name='eth1')
|
||||
cable = stale_interface.cable
|
||||
|
||||
self.assertIsNotNone(cable)
|
||||
self.assertIsNotNone(old_path_id)
|
||||
self.assertEqual(stale_interface.cable_end, 'B')
|
||||
|
||||
cable.b_terminations = [new_peer]
|
||||
cable.save()
|
||||
|
||||
# The old CablePath was deleted during retrace.
|
||||
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
|
||||
|
||||
# The stale in-memory instance still holds the deleted FK value.
|
||||
self.assertEqual(stale_interface._path_id, old_path_id)
|
||||
|
||||
# Serialization must not raise ObjectDoesNotExist. Because this interface
|
||||
# was the former B-side termination, it is now disconnected.
|
||||
data = serialize_for_event(stale_interface)
|
||||
self.assertIsNone(data['connected_endpoints'])
|
||||
self.assertIsNone(data['connected_endpoints_type'])
|
||||
self.assertFalse(data['connected_endpoints_reachable'])
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import attrs, panels
|
||||
from netbox.ui import actions, attrs, panels
|
||||
|
||||
|
||||
class SitePanel(panels.ObjectAttributesPanel):
|
||||
@@ -137,6 +139,12 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
|
||||
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
|
||||
|
||||
|
||||
class DeviceRolePanel(panels.NestedGroupObjectPanel):
|
||||
color = attrs.ColorAttr('color')
|
||||
vm_role = attrs.BooleanAttr('vm_role', label=_('VM role'))
|
||||
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
|
||||
|
||||
|
||||
class DeviceTypePanel(panels.ObjectAttributesPanel):
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
model = attrs.TextAttr('model')
|
||||
@@ -153,21 +161,290 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
|
||||
rear_image = attrs.ImageAttr('rear_image')
|
||||
|
||||
|
||||
class ModulePanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
device_type = attrs.RelatedObjectAttr('device.device_type', linkify=True, grouped_by='manufacturer')
|
||||
module_bay = attrs.NestedObjectAttr('module_bay', linkify=True)
|
||||
status = attrs.ChoiceAttr('status')
|
||||
description = attrs.TextAttr('description')
|
||||
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
|
||||
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
|
||||
|
||||
|
||||
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ModuleTypePanel(panels.ObjectAttributesPanel):
|
||||
profile = attrs.RelatedObjectAttr('profile', linkify=True)
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
model = attrs.TextAttr('model', label=_('Model name'))
|
||||
part_number = attrs.TextAttr('part_number')
|
||||
description = attrs.TextAttr('description')
|
||||
airflow = attrs.ChoiceAttr('airflow')
|
||||
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
|
||||
|
||||
|
||||
class PlatformPanel(panels.NestedGroupObjectPanel):
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
|
||||
|
||||
|
||||
class ConsolePortPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
speed = attrs.ChoiceAttr('speed')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ConsoleServerPortPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
speed = attrs.ChoiceAttr('speed')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class PowerPortPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
description = attrs.TextAttr('description')
|
||||
maximum_draw = attrs.TextAttr('maximum_draw')
|
||||
allocated_draw = attrs.TextAttr('allocated_draw')
|
||||
|
||||
|
||||
class PowerOutletPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
description = attrs.TextAttr('description')
|
||||
color = attrs.ColorAttr('color')
|
||||
power_port = attrs.RelatedObjectAttr('power_port', linkify=True)
|
||||
feed_leg = attrs.ChoiceAttr('feed_leg')
|
||||
|
||||
|
||||
class FrontPortPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
color = attrs.ColorAttr('color')
|
||||
positions = attrs.TextAttr('positions')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class RearPortPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
color = attrs.ColorAttr('color')
|
||||
positions = attrs.TextAttr('positions')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ModuleBayPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
position = attrs.TextAttr('position')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class DeviceBayPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class InventoryItemPanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
role = attrs.RelatedObjectAttr('role', linkify=True)
|
||||
component = attrs.GenericForeignKeyAttr('component', linkify=True)
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
part_id = attrs.TextAttr('part_id', label=_('Part ID'))
|
||||
serial = attrs.TextAttr('serial')
|
||||
asset_tag = attrs.TextAttr('asset_tag')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class InventoryItemRolePanel(panels.OrganizationalObjectPanel):
|
||||
color = attrs.ColorAttr('color')
|
||||
|
||||
|
||||
class CablePanel(panels.ObjectAttributesPanel):
|
||||
type = attrs.ChoiceAttr('type')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
profile = attrs.ChoiceAttr('profile')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
label = attrs.TextAttr('label')
|
||||
description = attrs.TextAttr('description')
|
||||
color = attrs.ColorAttr('color')
|
||||
length = attrs.NumericAttr('length', unit_accessor='get_length_unit_display')
|
||||
|
||||
|
||||
class VirtualChassisPanel(panels.ObjectAttributesPanel):
|
||||
domain = attrs.TextAttr('domain')
|
||||
master = attrs.RelatedObjectAttr('master', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class PowerPanelPanel(panels.ObjectAttributesPanel):
|
||||
site = attrs.RelatedObjectAttr('site', linkify=True)
|
||||
location = attrs.NestedObjectAttr('location', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class PowerFeedPanel(panels.ObjectAttributesPanel):
|
||||
power_panel = attrs.RelatedObjectAttr('power_panel', linkify=True)
|
||||
rack = attrs.RelatedObjectAttr('rack', linkify=True)
|
||||
type = attrs.ChoiceAttr('type')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
description = attrs.TextAttr('description')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
connected_device = attrs.TemplatedAttr(
|
||||
'connected_endpoints',
|
||||
label=_('Connected device'),
|
||||
template_name='dcim/powerfeed/attrs/connected_device.html',
|
||||
)
|
||||
utilization = attrs.TemplatedAttr(
|
||||
'connected_endpoints',
|
||||
label=_('Utilization (allocated)'),
|
||||
template_name='dcim/powerfeed/attrs/utilization.html',
|
||||
)
|
||||
|
||||
|
||||
class PowerFeedElectricalPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Electrical Characteristics')
|
||||
|
||||
supply = attrs.ChoiceAttr('supply')
|
||||
voltage = attrs.TextAttr('voltage', format_string=_('{}V'))
|
||||
amperage = attrs.TextAttr('amperage', format_string=_('{}A'))
|
||||
phase = attrs.ChoiceAttr('phase')
|
||||
max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
|
||||
|
||||
|
||||
class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
identifier = attrs.TextAttr('identifier')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
primary_ip4 = attrs.TemplatedAttr(
|
||||
'primary_ip4',
|
||||
label=_('Primary IPv4'),
|
||||
template_name='dcim/device/attrs/ipaddress.html',
|
||||
)
|
||||
primary_ip6 = attrs.TemplatedAttr(
|
||||
'primary_ip6',
|
||||
label=_('Primary IPv6'),
|
||||
template_name='dcim/device/attrs/ipaddress.html',
|
||||
)
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
|
||||
|
||||
class MACAddressPanel(panels.ObjectAttributesPanel):
|
||||
mac_address = attrs.TextAttr('mac_address', label=_('MAC address'), style='font-monospace', copy_button=True)
|
||||
description = attrs.TextAttr('description')
|
||||
assignment = attrs.RelatedObjectAttr('assigned_object', linkify=True, grouped_by='parent_object')
|
||||
is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
|
||||
|
||||
|
||||
class ConnectionPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which displays connection information for a cabled object.
|
||||
"""
|
||||
template_name = 'dcim/panels/connection.html'
|
||||
title = _('Connection')
|
||||
|
||||
def __init__(self, trace_url_name, connect_options=None, show_endpoints=True, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.trace_url_name = trace_url_name
|
||||
self.connect_options = connect_options or []
|
||||
self.show_endpoints = show_endpoints
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'trace_url_name': self.trace_url_name,
|
||||
'connect_options': self.connect_options,
|
||||
'show_endpoints': self.show_endpoints,
|
||||
}
|
||||
|
||||
def render(self, context):
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
|
||||
class InventoryItemsPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which displays inventory items associated with a component.
|
||||
"""
|
||||
template_name = 'dcim/panels/component_inventory_items.html'
|
||||
title = _('Inventory Items')
|
||||
actions = [
|
||||
actions.AddObject(
|
||||
'dcim.inventoryitem',
|
||||
url_params={
|
||||
'component_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
|
||||
'component_id': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
def render(self, context):
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
|
||||
class VirtualChassisMembersPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which lists all members of a virtual chassis.
|
||||
"""
|
||||
|
||||
template_name = 'dcim/panels/virtual_chassis_members.html'
|
||||
title = _('Virtual Chassis Members')
|
||||
actions = [
|
||||
actions.AddObject(
|
||||
'dcim.device',
|
||||
url_params={
|
||||
'site': lambda ctx: (
|
||||
ctx['virtual_chassis'].master.site_id
|
||||
if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
|
||||
else ''
|
||||
),
|
||||
'rack': lambda ctx: (
|
||||
ctx['virtual_chassis'].master.rack_id
|
||||
if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
|
||||
else ''
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'virtual_chassis': context.get('virtual_chassis'),
|
||||
'vc_members': context.get('vc_members'),
|
||||
}
|
||||
|
||||
@@ -195,3 +472,106 @@ class PowerUtilizationPanel(panels.ObjectPanel):
|
||||
if not obj.powerports.exists() or not obj.poweroutlets.exists():
|
||||
return ''
|
||||
return super().render(context)
|
||||
|
||||
|
||||
class InterfacePanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
module = attrs.RelatedObjectAttr('module', linkify=True)
|
||||
name = attrs.TextAttr('name')
|
||||
label = attrs.TextAttr('label')
|
||||
type = attrs.ChoiceAttr('type')
|
||||
speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
|
||||
duplex = attrs.ChoiceAttr('duplex')
|
||||
mtu = attrs.TextAttr('mtu', label=_('MTU'))
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
|
||||
description = attrs.TextAttr('description')
|
||||
poe_mode = attrs.ChoiceAttr('poe_mode', label=_('PoE mode'))
|
||||
poe_type = attrs.ChoiceAttr('poe_type', label=_('PoE type'))
|
||||
mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
|
||||
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
|
||||
untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
|
||||
tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
|
||||
tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
|
||||
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
|
||||
|
||||
|
||||
class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Related Interfaces')
|
||||
|
||||
parent = attrs.RelatedObjectAttr('parent', linkify=True)
|
||||
bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
|
||||
lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
|
||||
|
||||
|
||||
class InterfaceAddressingPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Addressing')
|
||||
|
||||
mac_address = attrs.TemplatedAttr(
|
||||
'primary_mac_address',
|
||||
template_name='dcim/interface/attrs/mac_address.html',
|
||||
label=_('MAC address'),
|
||||
)
|
||||
wwn = attrs.TextAttr('wwn', style='font-monospace', label=_('WWN'))
|
||||
vrf = attrs.RelatedObjectAttr('vrf', linkify=True, label=_('VRF'))
|
||||
vlan_translation = attrs.RelatedObjectAttr('vlan_translation_policy', linkify=True, label=_('VLAN translation'))
|
||||
|
||||
|
||||
class InterfaceConnectionPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A connection panel for interfaces, which handles cable, wireless link, and virtual circuit cases.
|
||||
"""
|
||||
template_name = 'dcim/panels/interface_connection.html'
|
||||
title = _('Connection')
|
||||
|
||||
def render(self, context):
|
||||
obj = context.get('object')
|
||||
if obj and obj.is_virtual:
|
||||
return ''
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
|
||||
class VirtualCircuitPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which displays virtual circuit information for a virtual interface.
|
||||
"""
|
||||
template_name = 'dcim/panels/interface_virtual_circuit.html'
|
||||
title = _('Virtual Circuit')
|
||||
|
||||
def render(self, context):
|
||||
obj = context.get('object')
|
||||
if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
|
||||
return ''
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
|
||||
class InterfaceWirelessPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which displays wireless RF attributes for an interface, comparing local and peer values.
|
||||
"""
|
||||
template_name = 'dcim/panels/interface_wireless.html'
|
||||
title = _('Wireless')
|
||||
|
||||
def render(self, context):
|
||||
obj = context.get('object')
|
||||
if not obj or not obj.is_wireless:
|
||||
return ''
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
|
||||
class WirelessLANsPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which lists the wireless LANs associated with an interface.
|
||||
"""
|
||||
template_name = 'dcim/panels/interface_wireless_lans.html'
|
||||
title = _('Wireless LANs')
|
||||
|
||||
def render(self, context):
|
||||
obj = context.get('object')
|
||||
if not obj or not obj.is_wireless:
|
||||
return ''
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
@@ -3,6 +3,9 @@ from collections import defaultdict
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import router, transaction
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
|
||||
|
||||
def compile_path_node(ct_id, object_id):
|
||||
@@ -33,6 +36,51 @@ def path_node_to_object(repr):
|
||||
return ct.model_class().objects.filter(pk=object_id).first()
|
||||
|
||||
|
||||
def get_module_bay_positions(module_bay):
|
||||
"""
|
||||
Given a module bay, traverse up the module hierarchy and return
|
||||
a list of bay position strings from root to leaf.
|
||||
"""
|
||||
positions = []
|
||||
while module_bay:
|
||||
positions.append(module_bay.position)
|
||||
if module_bay.module:
|
||||
module_bay = module_bay.module.module_bay
|
||||
else:
|
||||
module_bay = None
|
||||
positions.reverse()
|
||||
return positions
|
||||
|
||||
|
||||
def resolve_module_placeholder(value, positions):
|
||||
"""
|
||||
Resolve {module} placeholder tokens in a string using the given
|
||||
list of module bay positions (ordered root to leaf).
|
||||
|
||||
A single {module} token resolves to the leaf (immediate parent) bay's position.
|
||||
Multiple tokens must match the tree depth and resolve level-by-level.
|
||||
|
||||
Returns the resolved string.
|
||||
Raises ValueError if token count is greater than 1 and doesn't match tree depth.
|
||||
"""
|
||||
if MODULE_TOKEN not in value:
|
||||
return value
|
||||
|
||||
token_count = value.count(MODULE_TOKEN)
|
||||
if token_count == 1:
|
||||
return value.replace(MODULE_TOKEN, positions[-1])
|
||||
if token_count == len(positions):
|
||||
for pos in positions:
|
||||
value = value.replace(MODULE_TOKEN, pos, 1)
|
||||
return value
|
||||
raise ValueError(
|
||||
_("Cannot install module with placeholder values in a module bay tree "
|
||||
"{level} levels deep but {tokens} placeholders given.").format(
|
||||
level=len(positions), tokens=token_count
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_cablepaths(objects):
|
||||
"""
|
||||
Create CablePaths for all paths originating from the specified set of nodes.
|
||||
|
||||
@@ -16,15 +16,18 @@ from circuits.models import Circuit, CircuitTermination
|
||||
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from ipam.tables import VLANTranslationRuleTable
|
||||
from ipam.ui.panels import FHRPGroupAssignmentsPanel
|
||||
from netbox.object_actions import *
|
||||
from netbox.ui import actions, layout
|
||||
from netbox.ui.panels import (
|
||||
CommentsPanel,
|
||||
ContextTablePanel,
|
||||
JSONPanel,
|
||||
NestedGroupObjectPanel,
|
||||
ObjectsTablePanel,
|
||||
OrganizationalObjectPanel,
|
||||
Panel,
|
||||
RelatedObjectsPanel,
|
||||
TemplatePanel,
|
||||
)
|
||||
@@ -388,7 +391,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
title=_('Child Groups'),
|
||||
filters={'parent_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
|
||||
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1576,7 +1579,7 @@ class ModuleTypeProfileListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ModuleTypeProfile)
|
||||
class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
class ModuleTypeProfileView(generic.ObjectView):
|
||||
template_name = 'generic/object.html'
|
||||
queryset = ModuleTypeProfile.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
@@ -1667,6 +1670,22 @@ class ModuleTypeListView(generic.ObjectListView):
|
||||
@register_model_view(ModuleType)
|
||||
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ModuleType.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ModuleTypePanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
Panel(
|
||||
title=_('Attributes'),
|
||||
template_name='dcim/panels/module_type_attributes.html',
|
||||
),
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
ImageAttachmentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2306,6 +2325,27 @@ class DeviceRoleListView(generic.ObjectListView):
|
||||
@register_model_view(DeviceRole)
|
||||
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.DeviceRolePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='dcim.DeviceRole',
|
||||
title=_('Child Device Roles'),
|
||||
filters={'parent_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject('dcim.DeviceRole', url_params={'parent': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2385,6 +2425,27 @@ class PlatformListView(generic.ObjectListView):
|
||||
@register_model_view(Platform)
|
||||
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Platform.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PlatformPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='dcim.Platform',
|
||||
title=_('Child Platforms'),
|
||||
filters={'parent_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject('dcim.Platform', url_params={'parent': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2496,6 +2557,7 @@ class DeviceView(generic.ObjectView):
|
||||
vc_members = []
|
||||
|
||||
return {
|
||||
'virtual_chassis': instance.virtual_chassis,
|
||||
'vc_members': vc_members,
|
||||
'svg_extra': f'highlight=id:{instance.pk}',
|
||||
}
|
||||
@@ -2733,6 +2795,7 @@ class DeviceBulkImportView(generic.BulkImportView):
|
||||
# For child devices, save the reverse relation to the parent device bay
|
||||
if parent_bay:
|
||||
device_bay = parent_bay
|
||||
device_bay.snapshot()
|
||||
device_bay.installed_device = obj
|
||||
device_bay.save()
|
||||
|
||||
@@ -2777,6 +2840,21 @@ class ModuleListView(generic.ObjectListView):
|
||||
@register_model_view(Module)
|
||||
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Module.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ModulePanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
Panel(
|
||||
title=_('Module Type'),
|
||||
template_name='dcim/panels/module_type.html',
|
||||
),
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2832,6 +2910,28 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
@register_model_view(ConsolePort)
|
||||
class ConsolePortView(generic.ObjectView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConsolePortPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:consoleport_trace',
|
||||
connect_options=[
|
||||
{
|
||||
'a_type': 'dcim.consoleport',
|
||||
'b_type': 'dcim.consoleserverport',
|
||||
'label': _('Console Server Port'),
|
||||
},
|
||||
{'a_type': 'dcim.consoleport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
|
||||
{'a_type': 'dcim.consoleport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
|
||||
],
|
||||
),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ConsolePort, 'add', detail=False)
|
||||
@@ -2903,6 +3003,24 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
@register_model_view(ConsoleServerPort)
|
||||
class ConsoleServerPortView(generic.ObjectView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConsoleServerPortPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:consoleserverport_trace',
|
||||
connect_options=[
|
||||
{'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.consoleport', 'label': _('Console Port')},
|
||||
{'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
|
||||
{'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
|
||||
],
|
||||
),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort, 'add', detail=False)
|
||||
@@ -2974,6 +3092,23 @@ class PowerPortListView(generic.ObjectListView):
|
||||
@register_model_view(PowerPort)
|
||||
class PowerPortView(generic.ObjectView):
|
||||
queryset = PowerPort.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PowerPortPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:powerport_trace',
|
||||
connect_options=[
|
||||
{'a_type': 'dcim.powerport', 'b_type': 'dcim.poweroutlet', 'label': _('Power Outlet')},
|
||||
{'a_type': 'dcim.powerport', 'b_type': 'dcim.powerfeed', 'label': _('Power Feed')},
|
||||
],
|
||||
),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(PowerPort, 'add', detail=False)
|
||||
@@ -3045,6 +3180,22 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
@register_model_view(PowerOutlet)
|
||||
class PowerOutletView(generic.ObjectView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PowerOutletPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:poweroutlet_trace',
|
||||
connect_options=[
|
||||
{'a_type': 'dcim.poweroutlet', 'b_type': 'dcim.powerport', 'label': _('Power Port')},
|
||||
],
|
||||
),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet, 'add', detail=False)
|
||||
@@ -3116,6 +3267,45 @@ class InterfaceListView(generic.ObjectListView):
|
||||
@register_model_view(Interface)
|
||||
class InterfaceView(generic.ObjectView):
|
||||
queryset = Interface.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.InterfacePanel(),
|
||||
panels.RelatedInterfacesPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
ContextTablePanel('vdc_table', title=_('Virtual Device Contexts')),
|
||||
panels.InterfaceAddressingPanel(),
|
||||
panels.VirtualCircuitPanel(),
|
||||
panels.InterfaceConnectionPanel(),
|
||||
panels.InterfaceWirelessPanel(),
|
||||
panels.WirelessLANsPanel(),
|
||||
FHRPGroupAssignmentsPanel(),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='ipam.IPAddress',
|
||||
filters={'interface_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('IP Addresses'),
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
model='dcim.MACAddress',
|
||||
filters={'interface_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('MAC Addresses'),
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
model='ipam.VLAN',
|
||||
filters={'interface_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('VLANs'),
|
||||
),
|
||||
ContextTablePanel('lag_interfaces_table', title=_('LAG Members')),
|
||||
ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
|
||||
ContextTablePanel('bridge_interfaces_table', title=_('Bridged Interfaces')),
|
||||
ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get assigned VDCs
|
||||
@@ -3130,45 +3320,29 @@ class InterfaceView(generic.ObjectView):
|
||||
vdc_table.configure(request)
|
||||
|
||||
# Get bridge interfaces
|
||||
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
|
||||
bridge_interfaces_table = tables.InterfaceTable(
|
||||
bridge_interfaces,
|
||||
Interface.objects.restrict(request.user, 'view').filter(bridge=instance),
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
)
|
||||
bridge_interfaces_table.configure(request)
|
||||
|
||||
# Get child interfaces
|
||||
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
|
||||
child_interfaces_table = tables.InterfaceTable(
|
||||
child_interfaces,
|
||||
Interface.objects.restrict(request.user, 'view').filter(parent=instance),
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
)
|
||||
child_interfaces_table.configure(request)
|
||||
|
||||
# Get LAG interfaces
|
||||
lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
|
||||
lag_interfaces_table = tables.InterfaceLAGMemberTable(
|
||||
lag_interfaces,
|
||||
orderable=False
|
||||
)
|
||||
lag_interfaces_table.configure(request)
|
||||
|
||||
# Get assigned VLANs and annotate whether each is tagged or untagged
|
||||
vlans = []
|
||||
if instance.untagged_vlan is not None:
|
||||
vlans.append(instance.untagged_vlan)
|
||||
vlans[0].tagged = False
|
||||
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
|
||||
vlan.tagged = True
|
||||
vlans.append(vlan)
|
||||
vlan_table = InterfaceVLANTable(
|
||||
interface=instance,
|
||||
data=vlans,
|
||||
orderable=False
|
||||
)
|
||||
vlan_table.configure(request)
|
||||
# Get LAG members (only for LAG interfaces)
|
||||
lag_interfaces_table = None
|
||||
if instance.is_lag:
|
||||
lag_interfaces_table = tables.InterfaceLAGMemberTable(
|
||||
Interface.objects.restrict(request.user, 'view').filter(lag=instance),
|
||||
orderable=False
|
||||
)
|
||||
lag_interfaces_table.configure(request)
|
||||
|
||||
# Get VLAN translation rules
|
||||
vlan_translation_table = None
|
||||
@@ -3181,11 +3355,9 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
'bridge_interfaces': bridge_interfaces,
|
||||
'bridge_interfaces_table': bridge_interfaces_table,
|
||||
'child_interfaces_table': child_interfaces_table,
|
||||
'lag_interfaces_table': lag_interfaces_table,
|
||||
'vlan_table': vlan_table,
|
||||
'vlan_translation_table': vlan_translation_table,
|
||||
}
|
||||
|
||||
@@ -3270,6 +3442,33 @@ class FrontPortListView(generic.ObjectListView):
|
||||
@register_model_view(FrontPort)
|
||||
class FrontPortView(generic.ObjectView):
|
||||
queryset = FrontPort.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.FrontPortPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:frontport_trace',
|
||||
show_endpoints=False,
|
||||
connect_options=[
|
||||
{'a_type': 'dcim.frontport', 'b_type': 'dcim.interface', 'label': _('Interface')},
|
||||
{'a_type': 'dcim.frontport', 'b_type': 'dcim.consoleserverport', 'label': _('Console Server Port')},
|
||||
{'a_type': 'dcim.frontport', 'b_type': 'dcim.consoleport', 'label': _('Console Port')},
|
||||
{'a_type': 'dcim.frontport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
|
||||
{'a_type': 'dcim.frontport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
|
||||
{
|
||||
'a_type': 'dcim.frontport',
|
||||
'b_type': 'circuits.circuittermination',
|
||||
'label': _('Circuit Termination'),
|
||||
},
|
||||
],
|
||||
),
|
||||
TemplatePanel('dcim/panels/front_port_mappings.html'),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -3346,6 +3545,31 @@ class RearPortListView(generic.ObjectListView):
|
||||
@register_model_view(RearPort)
|
||||
class RearPortView(generic.ObjectView):
|
||||
queryset = RearPort.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.RearPortPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
panels.InventoryItemsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:rearport_trace',
|
||||
show_endpoints=False,
|
||||
connect_options=[
|
||||
{'a_type': 'dcim.rearport', 'b_type': 'dcim.interface', 'label': _('Interface')},
|
||||
{'a_type': 'dcim.rearport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
|
||||
{'a_type': 'dcim.rearport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
|
||||
{
|
||||
'a_type': 'dcim.rearport',
|
||||
'b_type': 'circuits.circuittermination',
|
||||
'label': _('Circuit Termination'),
|
||||
},
|
||||
],
|
||||
),
|
||||
TemplatePanel('dcim/panels/rear_port_mappings.html'),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -3422,6 +3646,19 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
@register_model_view(ModuleBay)
|
||||
class ModuleBayView(generic.ObjectView):
|
||||
queryset = ModuleBay.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ModuleBayPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
Panel(
|
||||
title=_('Installed Module'),
|
||||
template_name='dcim/panels/installed_module.html',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ModuleBay, 'add', detail=False)
|
||||
@@ -3484,6 +3721,19 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
@register_model_view(DeviceBay)
|
||||
class DeviceBayView(generic.ObjectView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.DeviceBayPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
Panel(
|
||||
title=_('Installed Device'),
|
||||
template_name='dcim/panels/installed_device.html',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(DeviceBay, 'add', detail=False)
|
||||
@@ -3627,6 +3877,13 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
@register_model_view(InventoryItem)
|
||||
class InventoryItemView(generic.ObjectView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.InventoryItemPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'edit')
|
||||
@@ -3708,12 +3965,23 @@ class InventoryItemRoleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(InventoryItemRole)
|
||||
class InventoryItemRoleView(generic.ObjectView):
|
||||
class InventoryItemRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = InventoryItemRole.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.InventoryItemRolePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(),
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -3881,6 +4149,24 @@ class CableListView(generic.ObjectListView):
|
||||
@register_model_view(Cable)
|
||||
class CableView(generic.ObjectView):
|
||||
queryset = Cable.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CablePanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
Panel(
|
||||
title=_('Termination A'),
|
||||
template_name='dcim/panels/cable_termination_a.html',
|
||||
),
|
||||
Panel(
|
||||
title=_('Termination B'),
|
||||
template_name='dcim/panels/cable_termination_b.html',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Cable, 'add', detail=False)
|
||||
@@ -3912,19 +4198,6 @@ class CableEditView(generic.ObjectEditView):
|
||||
|
||||
return super().alter_object(obj, request, url_args, url_kwargs)
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
|
||||
params = {
|
||||
'a_terminations_type': request.GET.get('a_terminations_type'),
|
||||
'b_terminations_type': request.GET.get('b_terminations_type')
|
||||
}
|
||||
|
||||
for key in request.POST:
|
||||
if 'device' in key or 'power_panel' in key or 'circuit' in key:
|
||||
params.update({key: request.POST.get(key)})
|
||||
|
||||
return params
|
||||
|
||||
|
||||
@register_model_view(Cable, 'delete')
|
||||
class CableDeleteView(generic.ObjectDeleteView):
|
||||
@@ -4026,12 +4299,23 @@ class VirtualChassisListView(generic.ObjectListView):
|
||||
@register_model_view(VirtualChassis)
|
||||
class VirtualChassisView(generic.ObjectView):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VirtualChassisPanel(),
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.VirtualChassisMembersPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
members = Device.objects.restrict(request.user).filter(virtual_chassis=instance)
|
||||
|
||||
vc_members = Device.objects.restrict(request.user).filter(virtual_chassis=instance).order_by('vc_position')
|
||||
return {
|
||||
'members': members,
|
||||
'virtual_chassis': instance,
|
||||
'vc_members': vc_members,
|
||||
}
|
||||
|
||||
|
||||
@@ -4099,6 +4383,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
|
||||
members = formset.save(commit=False)
|
||||
devices = Device.objects.filter(pk__in=[m.pk for m in members])
|
||||
for device in devices:
|
||||
device.snapshot()
|
||||
device.vc_position = None
|
||||
device.save()
|
||||
for member in members:
|
||||
@@ -4270,6 +4555,27 @@ class PowerPanelListView(generic.ObjectListView):
|
||||
@register_model_view(PowerPanel)
|
||||
class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PowerPanelPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
ImageAttachmentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='dcim.PowerFeed',
|
||||
filters={'power_panel_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject('dcim.PowerFeed', url_params={'power_panel': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -4333,6 +4639,23 @@ class PowerFeedListView(generic.ObjectListView):
|
||||
@register_model_view(PowerFeed)
|
||||
class PowerFeedView(generic.ObjectView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PowerFeedPanel(),
|
||||
panels.PowerFeedElectricalPanel(),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ConnectionPanel(
|
||||
trace_url_name='dcim:powerfeed_trace',
|
||||
connect_options=[
|
||||
{'a_type': 'dcim.powerfeed', 'b_type': 'dcim.powerport', 'label': _('Power Port')},
|
||||
],
|
||||
),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(PowerFeed, 'add', detail=False)
|
||||
@@ -4401,6 +4724,23 @@ class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
@register_model_view(VirtualDeviceContext)
|
||||
class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VirtualDeviceContextPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='dcim.Interface',
|
||||
filters={'vdc_id': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -4469,6 +4809,16 @@ class MACAddressListView(generic.ObjectListView):
|
||||
@register_model_view(MACAddress)
|
||||
class MACAddressView(generic.ObjectView):
|
||||
queryset = MACAddress.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.MACAddressPanel(),
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(MACAddress, 'add', detail=False)
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework.fields import Field
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
@@ -49,8 +49,25 @@ class CustomFieldsDataField(Field):
|
||||
# TODO: Fix circular import
|
||||
from utilities.api import get_serializer_for_model
|
||||
data = {}
|
||||
cache = self.parent.context.get('cf_object_cache')
|
||||
|
||||
for cf in self._get_custom_fields():
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
if cache is not None and cf.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
):
|
||||
raw = obj.get(cf.name)
|
||||
if raw is None:
|
||||
value = None
|
||||
elif cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = cf.related_object_type.model_class()
|
||||
value = cache.get((model, raw))
|
||||
else:
|
||||
model = cf.related_object_type.model_class()
|
||||
value = [cache[(model, pk)] for pk in raw if (model, pk) in cache] or None
|
||||
else:
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
|
||||
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
serializer = get_serializer_for_model(cf.related_object_type.model_class())
|
||||
value = serializer(value, nested=True, context=self.parent.context).data
|
||||
@@ -87,3 +104,32 @@ class CustomFieldsDataField(Field):
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CustomFieldListSerializer(ListSerializer):
|
||||
"""
|
||||
ListSerializer that pre-fetches all OBJECT/MULTIOBJECT custom field related objects
|
||||
in bulk before per-item serialization.
|
||||
"""
|
||||
def to_representation(self, data):
|
||||
cf_field = self.child.fields.get('custom_fields')
|
||||
if isinstance(cf_field, CustomFieldsDataField):
|
||||
object_type_cfs = [
|
||||
cf for cf in cf_field._get_custom_fields()
|
||||
if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
|
||||
]
|
||||
cache = {}
|
||||
for cf in object_type_cfs:
|
||||
model = cf.related_object_type.model_class()
|
||||
pks = set()
|
||||
for item in data:
|
||||
raw = item.custom_field_data.get(cf.name)
|
||||
if raw is not None:
|
||||
if cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
pks.update(raw)
|
||||
else:
|
||||
pks.add(raw)
|
||||
for obj in model.objects.filter(pk__in=pks):
|
||||
cache[(model, obj.pk)] = obj
|
||||
self.child.context['cf_object_cache'] = cache
|
||||
return super().to_representation(data)
|
||||
|
||||
@@ -1,19 +1,70 @@
|
||||
from django.utils.translation import gettext as _
|
||||
import logging
|
||||
|
||||
from django.core.files.storage import storages
|
||||
from django.db import IntegrityError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.api.serializers_.jobs import JobSerializer
|
||||
from extras.models import Script
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from extras.models import Script, ScriptModule
|
||||
from netbox.api.serializers import ValidatedModelSerializer
|
||||
from utilities.datetime import local_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = (
|
||||
'ScriptDetailSerializer',
|
||||
'ScriptInputSerializer',
|
||||
'ScriptModuleSerializer',
|
||||
'ScriptSerializer',
|
||||
)
|
||||
|
||||
|
||||
class ScriptModuleSerializer(ValidatedModelSerializer):
|
||||
file = serializers.FileField(write_only=True)
|
||||
file_path = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ScriptModule
|
||||
fields = ['id', 'display', 'file_path', 'file', 'created', 'last_updated']
|
||||
brief_fields = ('id', 'display')
|
||||
|
||||
def validate(self, data):
|
||||
# ScriptModule.save() sets file_root; inject it here so full_clean() succeeds.
|
||||
# Pop 'file' before model instantiation — ScriptModule has no such field.
|
||||
file = data.pop('file', None)
|
||||
data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
|
||||
data = super().validate(data)
|
||||
data.pop('file_root', None)
|
||||
if file is not None:
|
||||
data['file'] = file
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
file = validated_data.pop('file')
|
||||
storage = storages.create_storage(storages.backends["scripts"])
|
||||
validated_data['file_path'] = storage.save(file.name, file)
|
||||
created = False
|
||||
try:
|
||||
instance = super().create(validated_data)
|
||||
created = True
|
||||
return instance
|
||||
except IntegrityError as e:
|
||||
if 'file_path' in str(e):
|
||||
raise serializers.ValidationError(
|
||||
_("A script module with this file name already exists.")
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not created and (file_path := validated_data.get('file_path')):
|
||||
try:
|
||||
storage.delete(file_path)
|
||||
except Exception:
|
||||
logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
|
||||
|
||||
|
||||
class ScriptSerializer(ValidatedModelSerializer):
|
||||
description = serializers.SerializerMethodField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@@ -26,6 +26,7 @@ router.register('journal-entries', views.JournalEntryViewSet)
|
||||
router.register('config-contexts', views.ConfigContextViewSet)
|
||||
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
|
||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||
router.register('scripts/upload', views.ScriptModuleViewSet)
|
||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
|
||||
app_name = 'extras-api'
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import RetrieveUpdateDestroyAPIView
|
||||
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
@@ -21,6 +21,7 @@ from netbox.api.features import SyncedDataMixin
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
|
||||
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
from utilities.request import copy_safe_request
|
||||
|
||||
@@ -264,6 +265,11 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, BaseViewSet):
|
||||
queryset = ScriptModule.objects.all()
|
||||
serializer_class = serializers.ScriptModuleSerializer
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
update=extend_schema(request=serializers.ScriptInputSerializer),
|
||||
partial_update=extend_schema(request=serializers.ScriptInputSerializer),
|
||||
|
||||
@@ -25,16 +25,54 @@ logger = logging.getLogger('netbox.events_processor')
|
||||
|
||||
class EventContext(UserDict):
|
||||
"""
|
||||
A custom dictionary that automatically serializes its associated object on demand.
|
||||
Dictionary-compatible wrapper for queued events that lazily serializes
|
||||
``event['data']`` on first access.
|
||||
|
||||
Backward-compatible with the plain-dict interface expected by existing
|
||||
EVENTS_PIPELINE consumers. When the same object is enqueued more than once
|
||||
in a single request, the serialization source is updated so consumers see
|
||||
the latest state.
|
||||
"""
|
||||
|
||||
# We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
|
||||
# queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
|
||||
# changes until a suitable minor release.
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Track which model instance should be serialized if/when `data` is
|
||||
# requested. This may be refreshed on duplicate enqueue, while leaving
|
||||
# the public `object` entry untouched for compatibility.
|
||||
self._serialization_source = None
|
||||
if 'object' in self:
|
||||
self._serialization_source = super().__getitem__('object')
|
||||
|
||||
def refresh_serialization_source(self, instance):
|
||||
"""
|
||||
Point lazy serialization at a fresher instance, invalidating any
|
||||
already-materialized ``data``.
|
||||
"""
|
||||
self._serialization_source = instance
|
||||
# UserDict.__contains__ checks the backing dict directly, so `in`
|
||||
# does not trigger __getitem__'s lazy serialization.
|
||||
if 'data' in self:
|
||||
del self['data']
|
||||
|
||||
def freeze_data(self, instance):
|
||||
"""
|
||||
Eagerly serialize and cache the payload for delete events, where the
|
||||
object may become inaccessible after deletion.
|
||||
"""
|
||||
super().__setitem__('data', serialize_for_event(instance))
|
||||
self._serialization_source = None
|
||||
|
||||
def __getitem__(self, item):
|
||||
if item == 'data' and 'data' not in self:
|
||||
data = serialize_for_event(self['object'])
|
||||
self.__setitem__('data', data)
|
||||
# Materialize the payload only when an event consumer asks for it.
|
||||
#
|
||||
# On coalesced events, use the latest explicitly queued instance so
|
||||
# webhooks/scripts/notifications observe the final queued state for
|
||||
# that object within the request.
|
||||
source = self._serialization_source or super().__getitem__('object')
|
||||
super().__setitem__('data', serialize_for_event(source))
|
||||
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
@@ -76,8 +114,9 @@ def get_snapshots(instance, event_type):
|
||||
|
||||
def enqueue_event(queue, instance, request, event_type):
|
||||
"""
|
||||
Enqueue a serialized representation of a created/updated/deleted object for the processing of
|
||||
events once the request has completed.
|
||||
Enqueue (or coalesce) an event for a created/updated/deleted object.
|
||||
|
||||
Events are processed after the request completes.
|
||||
"""
|
||||
# Bail if this type of object does not support event rules
|
||||
if not has_feature(instance, 'event_rules'):
|
||||
@@ -88,11 +127,18 @@ def enqueue_event(queue, instance, request, event_type):
|
||||
|
||||
assert instance.pk is not None
|
||||
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||
|
||||
if key in queue:
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
|
||||
# If the object is being deleted, update any prior "update" event to "delete"
|
||||
|
||||
# If the object is being deleted, convert any prior update event into a
|
||||
# delete event and freeze the payload before the object (or related
|
||||
# rows) become inaccessible.
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['event_type'] = event_type
|
||||
else:
|
||||
# Keep the public `object` entry stable for compatibility.
|
||||
queue[key].refresh_serialization_source(instance)
|
||||
else:
|
||||
queue[key] = EventContext(
|
||||
object_type=ObjectType.objects.get_for_model(instance),
|
||||
@@ -106,9 +152,11 @@ def enqueue_event(queue, instance, request, event_type):
|
||||
username=request.user.username, # DEPRECATED, will be removed in NetBox v4.7.0
|
||||
request_id=request.id, # DEPRECATED, will be removed in NetBox v4.7.0
|
||||
)
|
||||
# Force serialization of objects prior to them actually being deleted
|
||||
|
||||
# For delete events, eagerly serialize the payload before the row is gone.
|
||||
# This covers both first-time enqueues and coalesced update→delete promotions.
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
queue[key].freeze_data(instance)
|
||||
|
||||
|
||||
def process_event_rules(event_rules, object_type, event):
|
||||
@@ -133,9 +181,9 @@ def process_event_rules(event_rules, object_type, event):
|
||||
if not event_rule.eval_conditions(event['data']):
|
||||
continue
|
||||
|
||||
# Compile event data
|
||||
event_data = event_rule.action_data or {}
|
||||
event_data.update(event['data'])
|
||||
# Merge rule-specific action_data with the event payload.
|
||||
# Copy to avoid mutating the rule's stored action_data dict.
|
||||
event_data = {**(event_rule.action_data or {}), **event['data']}
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from strawberry.scalars import ID
|
||||
from strawberry_django import BaseFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
|
||||
|
||||
from extras import models
|
||||
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
|
||||
@@ -50,11 +50,11 @@ __all__ = (
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
|
||||
class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
@@ -107,22 +107,22 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
|
||||
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] = strawberry_django.filter_field()
|
||||
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
|
||||
class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@@ -137,10 +137,10 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
|
||||
related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
group_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
required: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
unique: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
@@ -166,7 +166,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
|
||||
validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
validation_regex: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
validation_regex: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -182,13 +182,13 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
|
||||
class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
base_choices: (
|
||||
BaseFilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None
|
||||
) = (
|
||||
@@ -202,14 +202,14 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.CustomLink, lookups=True)
|
||||
class CustomLinkFilter(ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
link_text: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
link_url: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
link_text: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
link_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
group_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
button_class: (
|
||||
BaseFilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None
|
||||
) = (
|
||||
@@ -220,15 +220,15 @@ class CustomLinkFilter(ChangeLoggedModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
|
||||
class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter):
|
||||
image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.JournalEntry, lookups=True)
|
||||
@@ -260,22 +260,22 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
|
||||
kind: BaseFilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
|
||||
class NotificationGroupFilter(ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.SavedFilter, lookups=True)
|
||||
class SavedFilterFilter(ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | 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()
|
||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
@@ -290,8 +290,8 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.TableConfig, lookups=True)
|
||||
class TableConfigFilter(ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | 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()
|
||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
@@ -303,30 +303,30 @@ class TableConfigFilter(ChangeLoggedModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.Tag, lookups=True)
|
||||
class TagFilter(ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.Webhook, lookups=True)
|
||||
class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
payload_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
http_method: (
|
||||
BaseFilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None
|
||||
) = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
http_content_type: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
additional_headers: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
body_template: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
secret: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
http_content_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
additional_headers: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
body_template: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
secret: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
ca_file_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -334,8 +334,8 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
|
||||
|
||||
@strawberry_django.filter_type(models.EventRule, lookups=True)
|
||||
class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -346,10 +346,10 @@ class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedMode
|
||||
action_type: BaseFilterLookup[Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
action_object_type: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
action_object_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
action_object_type_id: ID | None = strawberry_django.filter_field()
|
||||
action_object_id: ID | None = strawberry_django.filter_field()
|
||||
action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -81,7 +81,7 @@ class Command(BaseCommand):
|
||||
logger.error(f'\t{field}: {error.get("message")}')
|
||||
raise CommandError()
|
||||
|
||||
# Remove extra fields from ScriptForm before passng data to script
|
||||
# Remove extra fields from ScriptForm before passing data to script
|
||||
form.cleaned_data.pop('_schedule_at')
|
||||
form.cleaned_data.pop('_interval')
|
||||
form.cleaned_data.pop('_commit')
|
||||
@@ -94,10 +94,12 @@ class Command(BaseCommand):
|
||||
data=form.cleaned_data,
|
||||
request=NetBoxFakeRequest({
|
||||
'META': {},
|
||||
'COOKIES': {},
|
||||
'POST': data,
|
||||
'GET': {},
|
||||
'FILES': {},
|
||||
'user': user,
|
||||
'method': 'POST',
|
||||
'path': '',
|
||||
'id': uuid.uuid4()
|
||||
}),
|
||||
|
||||
67
netbox/extras/managers.py
Normal file
67
netbox/extras/managers.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from django.db import router
|
||||
from django.db.models import signals
|
||||
from taggit.managers import _TaggableManager
|
||||
from taggit.utils import require_instance_manager
|
||||
|
||||
__all__ = (
|
||||
'NetBoxTaggableManager',
|
||||
)
|
||||
|
||||
|
||||
class NetBoxTaggableManager(_TaggableManager):
|
||||
"""
|
||||
Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a
|
||||
single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags.
|
||||
"""
|
||||
|
||||
@require_instance_manager
|
||||
def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs):
|
||||
self._remove_prefetched_objects()
|
||||
if tag_kwargs is None:
|
||||
tag_kwargs = {}
|
||||
db = router.db_for_write(self.through, instance=self.instance)
|
||||
|
||||
tag_objs = self._to_tag_model_instances(tags, tag_kwargs)
|
||||
new_ids = {t.pk for t in tag_objs}
|
||||
|
||||
# Determine which tags are not already assigned to this object
|
||||
lookup = self._lookup_kwargs()
|
||||
vals = set(
|
||||
self.through._default_manager.using(db)
|
||||
.values_list("tag_id", flat=True)
|
||||
.filter(**lookup, tag_id__in=new_ids)
|
||||
)
|
||||
new_ids -= vals
|
||||
|
||||
if not new_ids:
|
||||
return
|
||||
|
||||
signals.m2m_changed.send(
|
||||
sender=self.through,
|
||||
action="pre_add",
|
||||
instance=self.instance,
|
||||
reverse=False,
|
||||
model=self.through.tag_model(),
|
||||
pk_set=new_ids,
|
||||
using=db,
|
||||
)
|
||||
|
||||
# Use a single bulk INSERT instead of one get_or_create per tag.
|
||||
self.through._default_manager.using(db).bulk_create(
|
||||
[
|
||||
self.through(tag=tag, **lookup, **(through_defaults or {}))
|
||||
for tag in tag_objs
|
||||
if tag.pk in new_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
signals.m2m_changed.send(
|
||||
sender=self.through,
|
||||
action="post_add",
|
||||
instance=self.instance,
|
||||
reverse=False,
|
||||
model=self.through.tag_model(),
|
||||
pk_set=new_ids,
|
||||
using=db,
|
||||
)
|
||||
@@ -74,7 +74,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return custom_fields
|
||||
|
||||
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
|
||||
custom_fields = self.get_queryset().filter(object_types=content_type)
|
||||
custom_fields = self.get_queryset().filter(object_types=content_type).select_related('related_object_type')
|
||||
|
||||
# Populate the request cache to avoid redundant lookups
|
||||
if cache is not None:
|
||||
|
||||
@@ -417,6 +417,7 @@ class NotificationTable(NetBoxTable):
|
||||
icon = columns.TemplateColumn(
|
||||
template_code=NOTIFICATION_ICON,
|
||||
accessor=tables.A('event'),
|
||||
orderable=False,
|
||||
attrs={
|
||||
'td': {'class': 'w-1'},
|
||||
'th': {'class': 'w-1'},
|
||||
@@ -479,8 +480,8 @@ class WebhookTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
ssl_validation = columns.BooleanColumn(
|
||||
verbose_name=_('SSL Validation')
|
||||
ssl_verification = columns.BooleanColumn(
|
||||
verbose_name=_('SSL Verification'),
|
||||
)
|
||||
owner = tables.Column(
|
||||
linkify=True,
|
||||
@@ -510,8 +511,9 @@ class EventRuleTable(NetBoxTable):
|
||||
verbose_name=_('Type'),
|
||||
)
|
||||
action_object = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Object'),
|
||||
orderable=False,
|
||||
linkify=True,
|
||||
)
|
||||
object_types = columns.ContentTypesColumn(
|
||||
verbose_name=_('Object Types'),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware, now
|
||||
from rest_framework import status
|
||||
@@ -1384,3 +1386,54 @@ class NotificationTest(APIViewTestCases.APIViewTestCase):
|
||||
'event_type': OBJECT_DELETED,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ScriptModuleTest(APITestCase):
|
||||
"""
|
||||
Tests for the POST /api/extras/scripts/upload/ endpoint.
|
||||
|
||||
ScriptModule is a proxy of core.ManagedFile (a different app) so the standard
|
||||
APIViewTestCases mixins cannot be used directly. All tests use add_permissions()
|
||||
with explicit Django model-level permissions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse('extras-api:scriptmodule-list') # /api/extras/scripts/upload/
|
||||
|
||||
def test_upload_script_module_without_permission(self):
|
||||
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
|
||||
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'file': upload_file},
|
||||
format='multipart',
|
||||
**self.header,
|
||||
)
|
||||
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_upload_script_module(self):
|
||||
# ScriptModule is a proxy of core.ManagedFile; both permissions required.
|
||||
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
|
||||
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
|
||||
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.save.return_value = 'test_upload.py'
|
||||
with patch('extras.api.serializers_.scripts.storages') as mock_storages:
|
||||
mock_storages.create_storage.return_value = mock_storage
|
||||
mock_storages.backends = {'scripts': {}}
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'file': upload_file},
|
||||
format='multipart',
|
||||
**self.header,
|
||||
)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['file_path'], 'test_upload.py')
|
||||
mock_storage.save.assert_called_once()
|
||||
self.assertTrue(ScriptModule.objects.filter(file_path='test_upload.py').exists())
|
||||
|
||||
def test_upload_script_module_without_file_fails(self):
|
||||
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
|
||||
response = self.client.post(self.url, {}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import uuid
|
||||
from unittest import skipIf
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
@@ -343,6 +345,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
|
||||
def test_send_webhook(self):
|
||||
request_id = uuid.uuid4()
|
||||
|
||||
@@ -426,6 +429,97 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['object_type'], script_type)
|
||||
self.assertEqual(job.kwargs['username'], self.user.username)
|
||||
|
||||
def test_duplicate_enqueue_refreshes_lazy_payload(self):
|
||||
"""
|
||||
When the same object is enqueued more than once in a single request,
|
||||
lazy serialization should use the most recently enqueued instance while
|
||||
preserving the original event['object'] reference.
|
||||
"""
|
||||
request = RequestFactory().get(reverse('dcim:site_add'))
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
stale_site = Site.objects.get(pk=site.pk)
|
||||
|
||||
queue = {}
|
||||
enqueue_event(queue, stale_site, request, OBJECT_UPDATED)
|
||||
|
||||
event = queue[f'dcim.site:{site.pk}']
|
||||
|
||||
# Data should not be materialized yet (lazy serialization)
|
||||
self.assertNotIn('data', event.data)
|
||||
|
||||
fresh_site = Site.objects.get(pk=site.pk)
|
||||
fresh_site.description = 'foo'
|
||||
fresh_site.save()
|
||||
|
||||
enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
|
||||
|
||||
# The original object reference should be preserved
|
||||
self.assertIs(event['object'], stale_site)
|
||||
|
||||
# But serialized data should reflect the fresher instance
|
||||
self.assertEqual(event['data']['description'], 'foo')
|
||||
self.assertEqual(event['snapshots']['postchange']['description'], 'foo')
|
||||
|
||||
def test_duplicate_enqueue_invalidates_materialized_data(self):
|
||||
"""
|
||||
If event['data'] has already been materialized before a second enqueue
|
||||
for the same object, the stale payload should be discarded and rebuilt
|
||||
from the fresher instance on next access.
|
||||
"""
|
||||
request = RequestFactory().get(reverse('dcim:site_add'))
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
queue = {}
|
||||
enqueue_event(queue, site, request, OBJECT_UPDATED)
|
||||
|
||||
event = queue[f'dcim.site:{site.pk}']
|
||||
|
||||
# Force early materialization
|
||||
self.assertEqual(event['data']['description'], '')
|
||||
|
||||
# Now update and re-enqueue
|
||||
fresh_site = Site.objects.get(pk=site.pk)
|
||||
fresh_site.description = 'updated'
|
||||
fresh_site.save()
|
||||
|
||||
enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
|
||||
|
||||
# Stale data should have been invalidated; new access should reflect update
|
||||
self.assertEqual(event['data']['description'], 'updated')
|
||||
|
||||
def test_update_then_delete_enqueue_freezes_payload(self):
|
||||
"""
|
||||
When an update event is coalesced with a subsequent delete, the event
|
||||
type should be promoted to OBJECT_DELETED and the payload should be
|
||||
eagerly frozen (since the object will be inaccessible after deletion).
|
||||
"""
|
||||
request = RequestFactory().get(reverse('dcim:site_add'))
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
queue = {}
|
||||
enqueue_event(queue, site, request, OBJECT_UPDATED)
|
||||
|
||||
event = queue[f'dcim.site:{site.pk}']
|
||||
|
||||
enqueue_event(queue, site, request, OBJECT_DELETED)
|
||||
|
||||
# Event type should have been promoted
|
||||
self.assertEqual(event['event_type'], OBJECT_DELETED)
|
||||
|
||||
# Data should already be materialized (frozen), not lazy
|
||||
self.assertIn('data', event.data)
|
||||
self.assertEqual(event['data']['name'], 'Site 1')
|
||||
self.assertIsNone(event['snapshots']['postchange'])
|
||||
|
||||
def test_duplicate_triggers(self):
|
||||
"""
|
||||
Test for erroneous duplicate event triggers resulting from saving an object multiple times
|
||||
|
||||
24
netbox/extras/tests/test_tables.py
Normal file
24
netbox/extras/tests/test_tables.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.test import RequestFactory, TestCase, tag
|
||||
|
||||
from extras.models import EventRule
|
||||
from extras.tables import EventRuleTable
|
||||
|
||||
|
||||
@tag('regression')
|
||||
class EventRuleTableTest(TestCase):
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
rule = EventRule.objects.all()
|
||||
disallowed = {
|
||||
'actions',
|
||||
}
|
||||
|
||||
orderable_columns = [
|
||||
column.name for column in EventRuleTable(rule).columns if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get('/')
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
table = EventRuleTable(rule)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
@@ -2,16 +2,55 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import actions, panels
|
||||
from netbox.ui import actions, attrs, panels
|
||||
from utilities.data import resolve_attr_path
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextAssignmentPanel',
|
||||
'ConfigContextPanel',
|
||||
'ConfigContextProfilePanel',
|
||||
'ConfigTemplatePanel',
|
||||
'CustomFieldBehaviorPanel',
|
||||
'CustomFieldChoiceSetChoicesPanel',
|
||||
'CustomFieldChoiceSetPanel',
|
||||
'CustomFieldObjectTypesPanel',
|
||||
'CustomFieldPanel',
|
||||
'CustomFieldRelatedObjectsPanel',
|
||||
'CustomFieldValidationPanel',
|
||||
'CustomFieldsPanel',
|
||||
'CustomLinkPanel',
|
||||
'EventRuleActionPanel',
|
||||
'EventRuleEventTypesPanel',
|
||||
'EventRulePanel',
|
||||
'ExportTemplatePanel',
|
||||
'ImageAttachmentFilePanel',
|
||||
'ImageAttachmentImagePanel',
|
||||
'ImageAttachmentPanel',
|
||||
'ImageAttachmentsPanel',
|
||||
'JournalEntryPanel',
|
||||
'NotificationGroupGroupsPanel',
|
||||
'NotificationGroupPanel',
|
||||
'NotificationGroupUsersPanel',
|
||||
'ObjectTypesPanel',
|
||||
'SavedFilterObjectTypesPanel',
|
||||
'SavedFilterPanel',
|
||||
'TableConfigColumnsPanel',
|
||||
'TableConfigOrderingPanel',
|
||||
'TableConfigPanel',
|
||||
'TagItemTypesPanel',
|
||||
'TagObjectTypesPanel',
|
||||
'TagPanel',
|
||||
'TagsPanel',
|
||||
'WebhookHTTPPanel',
|
||||
'WebhookPanel',
|
||||
'WebhookSSLPanel',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Generic panels
|
||||
#
|
||||
|
||||
class CustomFieldsPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel showing the value of all custom fields defined on an object.
|
||||
@@ -73,3 +112,403 @@ class TagsPanel(panels.ObjectPanel):
|
||||
**super().get_context(context),
|
||||
'object': resolve_attr_path(context, self.accessor),
|
||||
}
|
||||
|
||||
|
||||
class ObjectTypesPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel listing the object types assigned to the object.
|
||||
"""
|
||||
template_name = 'extras/panels/object_types.html'
|
||||
title = _('Object Types')
|
||||
|
||||
|
||||
#
|
||||
# CustomField panels
|
||||
#
|
||||
|
||||
class CustomFieldPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Custom Field')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
type = attrs.TemplatedAttr('type', label=_('Type'), template_name='extras/customfield/attrs/type.html')
|
||||
label = attrs.TextAttr('label')
|
||||
group_name = attrs.TextAttr('group_name', label=_('Group name'))
|
||||
description = attrs.TextAttr('description')
|
||||
required = attrs.BooleanAttr('required')
|
||||
unique = attrs.BooleanAttr('unique', label=_('Must be unique'))
|
||||
is_cloneable = attrs.BooleanAttr('is_cloneable', label=_('Cloneable'))
|
||||
choice_set = attrs.TemplatedAttr(
|
||||
'choice_set',
|
||||
template_name='extras/customfield/attrs/choice_set.html',
|
||||
)
|
||||
default = attrs.TextAttr('default', label=_('Default value'))
|
||||
related_object_filter = attrs.TemplatedAttr(
|
||||
'related_object_filter',
|
||||
template_name='extras/customfield/attrs/related_object_filter.html',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldBehaviorPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Behavior')
|
||||
|
||||
search_weight = attrs.TemplatedAttr(
|
||||
'search_weight',
|
||||
template_name='extras/customfield/attrs/search_weight.html',
|
||||
)
|
||||
filter_logic = attrs.ChoiceAttr('filter_logic')
|
||||
weight = attrs.NumericAttr('weight', label=_('Display weight'))
|
||||
ui_visible = attrs.ChoiceAttr('ui_visible', label=_('UI visible'))
|
||||
ui_editable = attrs.ChoiceAttr('ui_editable', label=_('UI editable'))
|
||||
|
||||
|
||||
class CustomFieldValidationPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Validation Rules')
|
||||
|
||||
validation_minimum = attrs.NumericAttr('validation_minimum', label=_('Minimum value'))
|
||||
validation_maximum = attrs.NumericAttr('validation_maximum', label=_('Maximum value'))
|
||||
validation_regex = attrs.TextAttr(
|
||||
'validation_regex',
|
||||
label=_('Regular expression'),
|
||||
style='font-monospace',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldObjectTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/object_types.html'
|
||||
title = _('Object Types')
|
||||
|
||||
|
||||
class CustomFieldRelatedObjectsPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/customfield_related_objects.html'
|
||||
title = _('Related Objects')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'related_models': context.get('related_models'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CustomFieldChoiceSet panels
|
||||
#
|
||||
|
||||
class CustomFieldChoiceSetPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Custom Field Choice Set')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
base_choices = attrs.ChoiceAttr('base_choices')
|
||||
order_alphabetically = attrs.BooleanAttr('order_alphabetically')
|
||||
choices_for = attrs.RelatedObjectListAttr('choices_for', linkify=True, label=_('Used by'))
|
||||
|
||||
|
||||
class CustomFieldChoiceSetChoicesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/customfieldchoiceset_choices.html'
|
||||
|
||||
def get_context(self, context):
|
||||
obj = context.get('object')
|
||||
total = len(obj.choices) if obj else 0
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'title': f'{_("Choices")} ({total})',
|
||||
'choices': context.get('choices'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CustomLink panels
|
||||
#
|
||||
|
||||
class CustomLinkPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Custom Link')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
group_name = attrs.TextAttr('group_name')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
button_class = attrs.ChoiceAttr('button_class')
|
||||
new_window = attrs.BooleanAttr('new_window')
|
||||
|
||||
|
||||
#
|
||||
# ExportTemplate panels
|
||||
#
|
||||
|
||||
class ExportTemplatePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Export Template')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
|
||||
file_name = attrs.TextAttr('file_name')
|
||||
file_extension = attrs.TextAttr('file_extension')
|
||||
as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
|
||||
|
||||
|
||||
#
|
||||
# SavedFilter panels
|
||||
#
|
||||
|
||||
class SavedFilterPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Saved Filter')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
user = attrs.TextAttr('user')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
shared = attrs.BooleanAttr('shared')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
|
||||
|
||||
class SavedFilterObjectTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/savedfilter_object_types.html'
|
||||
title = _('Assigned Models')
|
||||
|
||||
|
||||
#
|
||||
# TableConfig panels
|
||||
#
|
||||
|
||||
class TableConfigPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Table Config')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
object_type = attrs.TextAttr('object_type')
|
||||
table = attrs.TextAttr('table')
|
||||
user = attrs.TextAttr('user')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
shared = attrs.BooleanAttr('shared')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
|
||||
|
||||
class TableConfigColumnsPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tableconfig_columns.html'
|
||||
title = _('Columns Displayed')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'columns': context.get('columns'),
|
||||
}
|
||||
|
||||
|
||||
class TableConfigOrderingPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tableconfig_ordering.html'
|
||||
title = _('Ordering')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'columns': context.get('columns'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# NotificationGroup panels
|
||||
#
|
||||
|
||||
class NotificationGroupPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Notification Group')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class NotificationGroupGroupsPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/notificationgroup_groups.html'
|
||||
title = _('Groups')
|
||||
|
||||
|
||||
class NotificationGroupUsersPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/notificationgroup_users.html'
|
||||
title = _('Users')
|
||||
|
||||
|
||||
#
|
||||
# Webhook panels
|
||||
#
|
||||
|
||||
class WebhookPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Webhook')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class WebhookHTTPPanel(panels.ObjectAttributesPanel):
|
||||
title = _('HTTP Request')
|
||||
|
||||
http_method = attrs.ChoiceAttr('http_method', label=_('HTTP method'))
|
||||
payload_url = attrs.TextAttr('payload_url', label=_('Payload URL'), style='font-monospace')
|
||||
http_content_type = attrs.TextAttr('http_content_type', label=_('HTTP content type'))
|
||||
secret = attrs.TextAttr('secret')
|
||||
|
||||
|
||||
class WebhookSSLPanel(panels.ObjectAttributesPanel):
|
||||
title = _('SSL')
|
||||
|
||||
ssl_verification = attrs.BooleanAttr('ssl_verification', label=_('SSL verification'))
|
||||
ca_file_path = attrs.TextAttr('ca_file_path', label=_('CA file path'))
|
||||
|
||||
|
||||
#
|
||||
# EventRule panels
|
||||
#
|
||||
|
||||
class EventRulePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Event Rule')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class EventRuleEventTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/eventrule_event_types.html'
|
||||
title = _('Event Types')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'registry': context.get('registry'),
|
||||
}
|
||||
|
||||
|
||||
class EventRuleActionPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Action')
|
||||
|
||||
action_type = attrs.ChoiceAttr('action_type', label=_('Type'))
|
||||
action_object = attrs.RelatedObjectAttr('action_object', linkify=True, label=_('Object'))
|
||||
action_data = attrs.TemplatedAttr(
|
||||
'action_data',
|
||||
label=_('Data'),
|
||||
template_name='extras/eventrule/attrs/action_data.html',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tag panels
|
||||
#
|
||||
|
||||
class TagPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Tag')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
color = attrs.ColorAttr('color')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
tagged_items = attrs.TemplatedAttr(
|
||||
'extras_taggeditem_items',
|
||||
template_name='extras/tag/attrs/tagged_item_count.html',
|
||||
)
|
||||
|
||||
|
||||
class TagObjectTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tag_object_types.html'
|
||||
title = _('Allowed Object Types')
|
||||
|
||||
|
||||
class TagItemTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tag_item_types.html'
|
||||
title = _('Tagged Item Types')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'object_types': context.get('object_types'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ConfigContextProfile panels
|
||||
#
|
||||
|
||||
class ConfigContextProfilePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Config Context Profile')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
#
|
||||
# ConfigContext panels
|
||||
#
|
||||
|
||||
class ConfigContextPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Config Context')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
profile = attrs.RelatedObjectAttr('profile', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
is_active = attrs.BooleanAttr('is_active', label=_('Active'))
|
||||
|
||||
|
||||
class ConfigContextAssignmentPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/configcontext_assignment.html'
|
||||
title = _('Assignment')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'assigned_objects': context.get('assigned_objects'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ConfigTemplate panels
|
||||
#
|
||||
|
||||
class ConfigTemplatePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Config Template')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
|
||||
file_name = attrs.TextAttr('file_name')
|
||||
file_extension = attrs.TextAttr('file_extension')
|
||||
as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
|
||||
data_source = attrs.RelatedObjectAttr('data_source', linkify=True)
|
||||
data_file = attrs.TemplatedAttr(
|
||||
'data_path',
|
||||
template_name='extras/configtemplate/attrs/data_file.html',
|
||||
)
|
||||
data_synced = attrs.DateTimeAttr('data_synced')
|
||||
auto_sync_enabled = attrs.BooleanAttr('auto_sync_enabled')
|
||||
|
||||
|
||||
#
|
||||
# ImageAttachment panels
|
||||
#
|
||||
|
||||
class ImageAttachmentPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Image Attachment')
|
||||
|
||||
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent object'))
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ImageAttachmentFilePanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/imageattachment_file.html'
|
||||
title = _('File')
|
||||
|
||||
|
||||
class ImageAttachmentImagePanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/imageattachment_image.html'
|
||||
title = _('Image')
|
||||
|
||||
|
||||
#
|
||||
# JournalEntry panels
|
||||
#
|
||||
|
||||
class JournalEntryPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Journal Entry')
|
||||
|
||||
assigned_object = attrs.RelatedObjectAttr('assigned_object', linkify=True, label=_('Object'))
|
||||
created = attrs.DateTimeAttr('created', spec='minutes')
|
||||
created_by = attrs.TextAttr('created_by')
|
||||
kind = attrs.ChoiceAttr('kind')
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
@@ -23,6 +23,14 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from extras.utils import SharedObjectViewMixin
|
||||
from netbox.object_actions import *
|
||||
from netbox.ui import layout
|
||||
from netbox.ui.panels import (
|
||||
CommentsPanel,
|
||||
ContextTablePanel,
|
||||
JSONPanel,
|
||||
TemplatePanel,
|
||||
TextCodePanel,
|
||||
)
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
@@ -40,6 +48,7 @@ from . import filtersets, forms, tables
|
||||
from .constants import LOG_LEVEL_RANK
|
||||
from .models import *
|
||||
from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable
|
||||
from .ui import panels
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
@@ -57,6 +66,18 @@ class CustomFieldListView(generic.ObjectListView):
|
||||
@register_model_view(CustomField)
|
||||
class CustomFieldView(generic.ObjectView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CustomFieldPanel(),
|
||||
panels.CustomFieldBehaviorPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.CustomFieldObjectTypesPanel(),
|
||||
panels.CustomFieldValidationPanel(),
|
||||
panels.CustomFieldRelatedObjectsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = ()
|
||||
@@ -128,6 +149,14 @@ class CustomFieldChoiceSetListView(generic.ObjectListView):
|
||||
@register_model_view(CustomFieldChoiceSet)
|
||||
class CustomFieldChoiceSetView(generic.ObjectView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CustomFieldChoiceSetPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.CustomFieldChoiceSetChoicesPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
@@ -203,6 +232,16 @@ class CustomLinkListView(generic.ObjectListView):
|
||||
@register_model_view(CustomLink)
|
||||
class CustomLinkView(generic.ObjectView):
|
||||
queryset = CustomLink.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CustomLinkPanel(),
|
||||
panels.ObjectTypesPanel(title=_('Assigned Models')),
|
||||
],
|
||||
right_panels=[
|
||||
TextCodePanel('link_text', title=_('Link Text')),
|
||||
TextCodePanel('link_url', title=_('Link URL')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(CustomLink, 'add', detail=False)
|
||||
@@ -260,6 +299,19 @@ class ExportTemplateListView(generic.ObjectListView):
|
||||
@register_model_view(ExportTemplate)
|
||||
class ExportTemplateView(generic.ObjectView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ExportTemplatePanel(),
|
||||
TemplatePanel('core/inc/datafile_panel.html'),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ObjectTypesPanel(title=_('Assigned Models')),
|
||||
JSONPanel('environment_params', title=_('Environment Parameters')),
|
||||
],
|
||||
bottom_panels=[
|
||||
TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate, 'add', detail=False)
|
||||
@@ -321,6 +373,15 @@ class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
|
||||
@register_model_view(SavedFilter)
|
||||
class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.SavedFilterPanel(),
|
||||
panels.SavedFilterObjectTypesPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('parameters', title=_('Parameters')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(SavedFilter, 'add', detail=False)
|
||||
@@ -383,6 +444,15 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
|
||||
@register_model_view(TableConfig)
|
||||
class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
|
||||
queryset = TableConfig.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.TableConfigPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.TableConfigColumnsPanel(),
|
||||
panels.TableConfigOrderingPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
table = instance.table_class([])
|
||||
@@ -476,6 +546,15 @@ class NotificationGroupListView(generic.ObjectListView):
|
||||
@register_model_view(NotificationGroup)
|
||||
class NotificationGroupView(generic.ObjectView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.NotificationGroupPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.NotificationGroupGroupsPanel(),
|
||||
panels.NotificationGroupUsersPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'add', detail=False)
|
||||
@@ -660,6 +739,19 @@ class WebhookListView(generic.ObjectListView):
|
||||
@register_model_view(Webhook)
|
||||
class WebhookView(generic.ObjectView):
|
||||
queryset = Webhook.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.WebhookPanel(),
|
||||
panels.WebhookHTTPPanel(),
|
||||
panels.WebhookSSLPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TextCodePanel('additional_headers', title=_('Additional Headers')),
|
||||
TextCodePanel('body_template', title=_('Body Template')),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Webhook, 'add', detail=False)
|
||||
@@ -716,6 +808,19 @@ class EventRuleListView(generic.ObjectListView):
|
||||
@register_model_view(EventRule)
|
||||
class EventRuleView(generic.ObjectView):
|
||||
queryset = EventRule.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.EventRulePanel(),
|
||||
panels.ObjectTypesPanel(),
|
||||
panels.EventRuleEventTypesPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('conditions', title=_('Conditions')),
|
||||
panels.EventRuleActionPanel(),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'add', detail=False)
|
||||
@@ -774,6 +879,18 @@ class TagListView(generic.ObjectListView):
|
||||
@register_model_view(Tag)
|
||||
class TagView(generic.ObjectView):
|
||||
queryset = Tag.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.TagPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.TagObjectTypesPanel(),
|
||||
panels.TagItemTypesPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ContextTablePanel('taggeditem_table', title=_('Tagged Objects')),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
tagged_items = TaggedItem.objects.filter(tag=instance)
|
||||
@@ -853,6 +970,18 @@ class ConfigContextProfileListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigContextProfile)
|
||||
class ConfigContextProfileView(generic.ObjectView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConfigContextProfilePanel(),
|
||||
TemplatePanel('core/inc/datafile_panel.html'),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('schema', title=_('JSON Schema')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'add', detail=False)
|
||||
@@ -915,6 +1044,16 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigContext)
|
||||
class ConfigContextView(generic.ObjectView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConfigContextPanel(),
|
||||
TemplatePanel('core/inc/datafile_panel.html'),
|
||||
panels.ConfigContextAssignmentPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TemplatePanel('extras/panels/configcontext_data.html'),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Gather assigned objects for parsing in the template
|
||||
@@ -1034,6 +1173,18 @@ class ConfigTemplateListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigTemplate)
|
||||
class ConfigTemplateView(generic.ObjectView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConfigTemplatePanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('environment_params', title=_('Environment Parameters')),
|
||||
],
|
||||
bottom_panels=[
|
||||
TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'add', detail=False)
|
||||
@@ -1151,6 +1302,17 @@ class ImageAttachmentListView(generic.ObjectListView):
|
||||
@register_model_view(ImageAttachment)
|
||||
class ImageAttachmentView(generic.ObjectView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ImageAttachmentPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ImageAttachmentFilePanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
panels.ImageAttachmentImagePanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ImageAttachment, 'add', detail=False)
|
||||
@@ -1215,6 +1377,16 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
@register_model_view(JournalEntry)
|
||||
class JournalEntryView(generic.ObjectView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.JournalEntryPanel(),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(JournalEntry, 'add', detail=False)
|
||||
|
||||
@@ -210,8 +210,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = (
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
|
||||
'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name',
|
||||
'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
|
||||
)
|
||||
labels = {
|
||||
'scope_id': _('Scope ID'),
|
||||
@@ -424,17 +424,36 @@ class IPAddressImportForm(PrimaryModelImportForm):
|
||||
# Set as primary for device/VM
|
||||
if self.cleaned_data.get('is_primary') is not None:
|
||||
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
|
||||
if self.instance.address.version == 4:
|
||||
parent.primary_ip4 = ipaddress if self.cleaned_data.get('is_primary') else None
|
||||
elif self.instance.address.version == 6:
|
||||
parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
|
||||
parent.save()
|
||||
if self.cleaned_data.get('is_primary'):
|
||||
parent.snapshot()
|
||||
if self.instance.address.version == 4:
|
||||
parent.primary_ip4 = ipaddress
|
||||
elif self.instance.address.version == 6:
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
else:
|
||||
# Only clear the primary IP if this IP is currently set as primary
|
||||
if self.instance.address.version == 4 and parent.primary_ip4 == ipaddress:
|
||||
parent.snapshot()
|
||||
parent.primary_ip4 = None
|
||||
parent.save()
|
||||
elif self.instance.address.version == 6 and parent.primary_ip6 == ipaddress:
|
||||
parent.snapshot()
|
||||
parent.primary_ip6 = None
|
||||
parent.save()
|
||||
|
||||
# Set as OOB for device
|
||||
if self.cleaned_data.get('is_oob') is not None:
|
||||
parent = self.cleaned_data.get('device')
|
||||
parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
|
||||
parent.save()
|
||||
if self.cleaned_data.get('is_oob'):
|
||||
parent.snapshot()
|
||||
parent.oob_ip = ipaddress
|
||||
parent.save()
|
||||
elif parent.oob_ip == ipaddress:
|
||||
# Only clear OOB if this IP is currently set as the OOB IP
|
||||
parent.snapshot()
|
||||
parent.oob_ip = None
|
||||
parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
@@ -455,7 +474,8 @@ class FHRPGroupImportForm(PrimaryModelImportForm):
|
||||
fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class VLANGroupImportForm(OrganizationalModelImportForm):
|
||||
class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
|
||||
# Override ScopedImportForm.scope_type to set custom queryset
|
||||
scope_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False,
|
||||
@@ -475,10 +495,11 @@ class VLANGroupImportForm(OrganizationalModelImportForm):
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = (
|
||||
'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags'
|
||||
'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner',
|
||||
'comments', 'tags',
|
||||
)
|
||||
labels = {
|
||||
'scope_id': 'Scope ID',
|
||||
'scope_id': _('Scope ID'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import strawberry_django
|
||||
from django.db.models import Q
|
||||
from netaddr.core import AddrFormatError
|
||||
from strawberry.scalars import ID
|
||||
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup, StrFilterLookup
|
||||
|
||||
from dcim.graphql.filter_mixins import ScopedFilterMixin
|
||||
from dcim.models import Device
|
||||
@@ -70,8 +70,8 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.ASNRange, lookups=True)
|
||||
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
rir_id: ID | None = strawberry_django.filter_field()
|
||||
start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
@@ -84,7 +84,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.Aggregate, lookups=True)
|
||||
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
|
||||
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
rir_id: ID | None = strawberry_django.filter_field()
|
||||
date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
|
||||
@@ -120,14 +120,14 @@ class FHRPGroupFilter(PrimaryModelFilter):
|
||||
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
protocol: BaseFilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
auth_type: BaseFilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
auth_key: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
auth_key: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -138,7 +138,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
|
||||
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
interface_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
interface_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -174,7 +174,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.IPAddress, lookups=True)
|
||||
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
|
||||
address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
address: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
vrf_id: ID | None = strawberry_django.filter_field()
|
||||
status: BaseFilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
|
||||
@@ -195,7 +195,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
nat_outside_id: ID | None = strawberry_django.filter_field()
|
||||
dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
dns_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def assigned(self, value: bool, prefix) -> Q:
|
||||
@@ -225,8 +225,8 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
||||
|
||||
@strawberry_django.filter_type(models.IPRange, lookups=True)
|
||||
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
|
||||
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
start_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
end_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -279,7 +279,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.Prefix, lookups=True)
|
||||
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
|
||||
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
vrf_id: ID | None = strawberry_django.filter_field()
|
||||
vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
@@ -328,7 +328,7 @@ class RoleFilter(OrganizationalModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.RouteTarget, lookups=True)
|
||||
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -345,7 +345,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.Service, lookups=True)
|
||||
class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -357,7 +357,7 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
|
||||
class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.VLAN, lookups=True)
|
||||
@@ -371,7 +371,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
status: BaseFilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -401,7 +401,7 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
|
||||
class VLANTranslationPolicyFilter(PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
|
||||
@@ -410,7 +410,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
policy_id: ID | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
@@ -421,8 +421,8 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
|
||||
|
||||
@strawberry_django.filter_type(models.VRF, lookups=True)
|
||||
class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
rd: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
rd: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
|
||||
@@ -159,9 +159,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
if self.prefix:
|
||||
return self.prefix.version
|
||||
return None
|
||||
if not self.prefix:
|
||||
return None
|
||||
if isinstance(self.prefix, str):
|
||||
return netaddr.IPNetwork(self.prefix).version
|
||||
return self.prefix.version
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
@@ -335,11 +337,19 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
return self.prefix.version if self.prefix else None
|
||||
if not self.prefix:
|
||||
return None
|
||||
if isinstance(self.prefix, str):
|
||||
return netaddr.IPNetwork(self.prefix).version
|
||||
return self.prefix.version
|
||||
|
||||
@property
|
||||
def mask_length(self):
|
||||
return self.prefix.prefixlen if self.prefix else None
|
||||
if not self.prefix:
|
||||
return None
|
||||
if isinstance(self.prefix, str):
|
||||
return netaddr.IPNetwork(self.prefix).prefixlen
|
||||
return self.prefix.prefixlen
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
@@ -367,6 +377,16 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
def get_status_color(self):
|
||||
return PrefixStatusChoices.colors.get(self.status)
|
||||
|
||||
@cached_property
|
||||
def aggregate(self):
|
||||
"""
|
||||
Return the containing Aggregate for this Prefix, if any.
|
||||
"""
|
||||
try:
|
||||
return Aggregate.objects.get(prefix__net_contains_or_equals=str(self.prefix))
|
||||
except Aggregate.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_parents(self, include_self=False):
|
||||
"""
|
||||
Return all containing Prefixes in the hierarchy.
|
||||
@@ -432,9 +452,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
])
|
||||
available_ips = prefix - child_ips - child_ranges
|
||||
|
||||
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
|
||||
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
|
||||
self.family == 4 and self.prefix.prefixlen >= 31
|
||||
# Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
|
||||
if (
|
||||
self.is_pool
|
||||
or (self.family == 4 and self.prefix.prefixlen >= 31)
|
||||
or (self.family == 6 and self.prefix.prefixlen >= 127)
|
||||
):
|
||||
return available_ips
|
||||
|
||||
@@ -630,7 +652,11 @@ class IPRange(ContactsMixin, PrimaryModel):
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
return self.start_address.version if self.start_address else None
|
||||
if not self.start_address:
|
||||
return None
|
||||
if isinstance(self.start_address, str):
|
||||
return netaddr.IPAddress(self.start_address.split('/')[0]).version
|
||||
return self.start_address.version
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
@@ -978,9 +1004,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
if self.address:
|
||||
return self.address.version
|
||||
return None
|
||||
if not self.address:
|
||||
return None
|
||||
if isinstance(self.address, str):
|
||||
return netaddr.IPNetwork(self.address).version
|
||||
return self.address.version
|
||||
|
||||
@property
|
||||
def is_oob_ip(self):
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import django_tables2 as tables
|
||||
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 Interface
|
||||
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin, TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
'InterfaceVLANTable',
|
||||
'VLANDevicesTable',
|
||||
'VLANGroupTable',
|
||||
'VLANMembersTable',
|
||||
@@ -198,47 +196,6 @@ class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
exclude = ('id', )
|
||||
|
||||
|
||||
class InterfaceVLANTable(NetBoxTable):
|
||||
"""
|
||||
List VLANs assigned to a specific Interface.
|
||||
"""
|
||||
vid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('VID')
|
||||
)
|
||||
tagged = columns.BooleanColumn(
|
||||
verbose_name=_('Tagged'),
|
||||
false_mark=None
|
||||
)
|
||||
site = tables.Column(
|
||||
verbose_name=_('Site'),
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
accessor=Accessor('group__name'),
|
||||
verbose_name=_('Group')
|
||||
)
|
||||
tenant = TenantColumn(
|
||||
verbose_name=_('Tenant'),
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
)
|
||||
role = tables.Column(
|
||||
verbose_name=_('Role'),
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
|
||||
exclude = ('id', )
|
||||
|
||||
def __init__(self, interface, *args, **kwargs):
|
||||
self.interface = interface
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
#
|
||||
# VLAN Translation
|
||||
#
|
||||
@@ -290,6 +247,6 @@ class VLANTranslationRuleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VLANTranslationRule
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'policy', 'local_vid', 'remote_vid', 'description')
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from dcim.constants import InterfaceTypeChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
|
||||
from ipam.forms import PrefixForm
|
||||
from ipam.forms.bulk_import import IPAddressImportForm
|
||||
|
||||
|
||||
class PrefixFormTestCase(TestCase):
|
||||
@@ -41,3 +43,56 @@ class PrefixFormTestCase(TestCase):
|
||||
})
|
||||
|
||||
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
|
||||
|
||||
|
||||
class IPAddressImportFormTestCase(TestCase):
|
||||
"""Tests for IPAddressImportForm bulk import behavior."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
cls.device = Device.objects.create(
|
||||
name='Device 1',
|
||||
site=site,
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
)
|
||||
cls.interface = Interface.objects.create(
|
||||
device=cls.device,
|
||||
name='eth0',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
)
|
||||
|
||||
def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
|
||||
"""
|
||||
Regression test for #21440: importing a second IP with is_oob=False should
|
||||
not clear the OOB IP set by a previous row with is_oob=True.
|
||||
"""
|
||||
form1 = IPAddressImportForm(data={
|
||||
'address': '10.10.10.1/24',
|
||||
'status': 'active',
|
||||
'device': 'Device 1',
|
||||
'interface': 'eth0',
|
||||
'is_oob': True,
|
||||
})
|
||||
self.assertTrue(form1.is_valid(), form1.errors)
|
||||
ip1 = form1.save()
|
||||
|
||||
self.device.refresh_from_db()
|
||||
self.assertEqual(self.device.oob_ip, ip1)
|
||||
|
||||
form2 = IPAddressImportForm(data={
|
||||
'address': '2001:db8::1/64',
|
||||
'status': 'active',
|
||||
'device': 'Device 1',
|
||||
'interface': 'eth0',
|
||||
'is_oob': False,
|
||||
})
|
||||
self.assertTrue(form2.is_valid(), form2.errors)
|
||||
form2.save()
|
||||
|
||||
self.device.refresh_from_db()
|
||||
self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
|
||||
|
||||
@@ -11,6 +11,13 @@ from utilities.data import string_to_ranges
|
||||
|
||||
class TestAggregate(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when prefix is a string
|
||||
agg = Aggregate(prefix='10.0.0.0/8')
|
||||
self.assertEqual(agg.family, 4)
|
||||
agg_v6 = Aggregate(prefix='2001:db8::/32')
|
||||
self.assertEqual(agg_v6.family, 6)
|
||||
|
||||
def test_get_utilization(self):
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
aggregate = Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rir)
|
||||
@@ -40,6 +47,13 @@ class TestAggregate(TestCase):
|
||||
|
||||
class TestIPRange(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when start_address is a string
|
||||
ip_range = IPRange(start_address='10.0.0.1/24', end_address='10.0.0.254/24')
|
||||
self.assertEqual(ip_range.family, 4)
|
||||
ip_range_v6 = IPRange(start_address='2001:db8::1/64', end_address='2001:db8::ffff/64')
|
||||
self.assertEqual(ip_range_v6.family, 6)
|
||||
|
||||
def test_overlapping_range(self):
|
||||
iprange_192_168 = IPRange.objects.create(
|
||||
start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')
|
||||
@@ -90,6 +104,20 @@ class TestIPRange(TestCase):
|
||||
|
||||
class TestPrefix(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when prefix is a string
|
||||
prefix = Prefix(prefix='10.0.0.0/8')
|
||||
self.assertEqual(prefix.family, 4)
|
||||
prefix_v6 = Prefix(prefix='2001:db8::/32')
|
||||
self.assertEqual(prefix_v6.family, 6)
|
||||
|
||||
def test_mask_length_string(self):
|
||||
# Test property when prefix is a string
|
||||
prefix = Prefix(prefix='10.0.0.0/8')
|
||||
self.assertEqual(prefix.mask_length, 8)
|
||||
prefix_v6 = Prefix(prefix='2001:db8::/32')
|
||||
self.assertEqual(prefix_v6.mask_length, 32)
|
||||
|
||||
def test_get_duplicates(self):
|
||||
prefixes = Prefix.objects.bulk_create((
|
||||
Prefix(prefix=IPNetwork('192.0.2.0/24')),
|
||||
@@ -533,6 +561,13 @@ class TestPrefixHierarchy(TestCase):
|
||||
|
||||
class TestIPAddress(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when address is a string
|
||||
ip = IPAddress(address='10.0.0.1/24')
|
||||
self.assertEqual(ip.family, 4)
|
||||
ip_v6 = IPAddress(address='2001:db8::1/64')
|
||||
self.assertEqual(ip_v6.family, 6)
|
||||
|
||||
def test_get_duplicates(self):
|
||||
ips = IPAddress.objects.bulk_create((
|
||||
IPAddress(address=IPNetwork('192.0.2.1/24')),
|
||||
|
||||
@@ -39,3 +39,132 @@ class AnnotatedIPAddressTableTest(TestCase):
|
||||
|
||||
iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
|
||||
self.assertEqual(iprange_checkbox_count, 0)
|
||||
|
||||
def test_annotate_ip_space_ipv4_non_pool_excludes_network_and_broadcast(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('192.0.2.0/29'), # 8 addresses total
|
||||
status='active',
|
||||
is_pool=False,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# /29 non-pool: exclude .0 (network) and .7 (broadcast)
|
||||
self.assertEqual(available.first_ip, '192.0.2.1/29')
|
||||
self.assertEqual(available.size, 6)
|
||||
|
||||
def test_annotate_ip_space_ipv4_pool_includes_network_and_broadcast(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('192.0.2.8/29'), # 8 addresses total
|
||||
status='active',
|
||||
is_pool=True,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# Pool: all addresses are usable, including network/broadcast
|
||||
self.assertEqual(available.first_ip, '192.0.2.8/29')
|
||||
self.assertEqual(available.size, 8)
|
||||
|
||||
def test_annotate_ip_space_ipv4_31_includes_all_ips(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('192.0.2.16/31'), # 2 addresses total
|
||||
status='active',
|
||||
is_pool=False,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# /31: fully usable
|
||||
self.assertEqual(available.first_ip, '192.0.2.16/31')
|
||||
self.assertEqual(available.size, 2)
|
||||
|
||||
def test_annotate_ip_space_ipv4_32_includes_single_ip(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('192.0.2.100/32'), # 1 address total
|
||||
status='active',
|
||||
is_pool=False,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# /32: single usable address
|
||||
self.assertEqual(available.first_ip, '192.0.2.100/32')
|
||||
self.assertEqual(available.size, 1)
|
||||
|
||||
def test_annotate_ip_space_ipv6_non_pool_excludes_anycast_first_ip(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('2001:db8::/126'), # 4 addresses total
|
||||
status='active',
|
||||
is_pool=False,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
# No child records -> expect one AvailableIPSpace entry
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# For IPv6 non-pool prefixes (except /127-/128), the first address is reserved (subnet-router anycast)
|
||||
self.assertEqual(available.first_ip, '2001:db8::1/126')
|
||||
self.assertEqual(available.size, 3) # 4 total - 1 reserved anycast
|
||||
|
||||
def test_annotate_ip_space_ipv6_127_includes_all_ips(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('2001:db8::/127'), # 2 addresses total
|
||||
status='active',
|
||||
is_pool=False,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# /127 is fully usable (no anycast exclusion)
|
||||
self.assertEqual(available.first_ip, '2001:db8::/127')
|
||||
self.assertEqual(available.size, 2)
|
||||
|
||||
def test_annotate_ip_space_ipv6_128_includes_single_ip(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('2001:db8::1/128'), # 1 address total
|
||||
status='active',
|
||||
is_pool=False,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# /128 is fully usable (single host address)
|
||||
self.assertEqual(available.first_ip, '2001:db8::1/128')
|
||||
self.assertEqual(available.size, 1)
|
||||
|
||||
def test_annotate_ip_space_ipv6_pool_includes_anycast_first_ip(self):
|
||||
prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('2001:db8:1::/126'), # 4 addresses total
|
||||
status='active',
|
||||
is_pool=True,
|
||||
)
|
||||
|
||||
data = annotate_ip_space(prefix)
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
available = data[0]
|
||||
|
||||
# Pools are fully usable
|
||||
self.assertEqual(available.first_ip, '2001:db8:1::/126')
|
||||
self.assertEqual(available.size, 4)
|
||||
|
||||
@@ -435,13 +435,21 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
site = sites[0].pk
|
||||
cls.csv_data = (
|
||||
"vrf,prefix,status,scope_type,scope_id",
|
||||
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
|
||||
f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
|
||||
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
|
||||
)
|
||||
site = sites[0]
|
||||
cls.csv_data = {
|
||||
'default': (
|
||||
"vrf,prefix,status,scope_type,scope_id",
|
||||
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}",
|
||||
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}",
|
||||
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}",
|
||||
),
|
||||
'scope_name': (
|
||||
"vrf,prefix,status,scope_type,scope_name",
|
||||
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}",
|
||||
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}",
|
||||
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}",
|
||||
),
|
||||
}
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,description,status",
|
||||
@@ -532,6 +540,32 @@ scope_id: {site.pk}
|
||||
self.assertEqual(prefix.vlan.vid, 101)
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_scope_name(self):
|
||||
"""
|
||||
Test YAML-based import using scope_name instead of scope_id.
|
||||
"""
|
||||
site = Site.objects.get(name='Site 1')
|
||||
IMPORT_DATA = """
|
||||
prefix: 10.1.3.0/24
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_name: Site 1
|
||||
"""
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
prefix = Prefix.objects.get(prefix='10.1.3.0/24')
|
||||
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_group(self):
|
||||
"""
|
||||
@@ -884,12 +918,20 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,scope_type,scope_id,description",
|
||||
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
|
||||
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
|
||||
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
|
||||
)
|
||||
cls.csv_data = {
|
||||
'default': (
|
||||
"name,slug,scope_type,scope_id,description",
|
||||
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
|
||||
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
|
||||
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
|
||||
),
|
||||
'scope_name': (
|
||||
"name,slug,scope_type,scope_name,description",
|
||||
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
|
||||
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group",
|
||||
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group",
|
||||
),
|
||||
}
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
|
||||
24
netbox/ipam/ui/attrs.py
Normal file
24
netbox/ipam/ui/attrs.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from netbox.ui import attrs
|
||||
|
||||
|
||||
class VRFDisplayAttr(attrs.ObjectAttribute):
|
||||
"""
|
||||
Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
|
||||
the route distinguisher (RD).
|
||||
"""
|
||||
template_name = 'ipam/attrs/vrf.html'
|
||||
|
||||
def __init__(self, *args, show_rd=False, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.show_rd = show_rd
|
||||
|
||||
def render(self, obj, context):
|
||||
value = self.get_value(obj)
|
||||
return render_to_string(self.template_name, {
|
||||
**self.get_context(obj, context),
|
||||
'name': context['name'],
|
||||
'value': value,
|
||||
'show_rd': self.show_rd,
|
||||
})
|
||||
@@ -2,14 +2,15 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import actions, panels
|
||||
from netbox.ui import actions, attrs, panels
|
||||
|
||||
from .attrs import VRFDisplayAttr
|
||||
|
||||
|
||||
class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which lists all FHRP group assignments for a given object.
|
||||
"""
|
||||
|
||||
template_name = 'ipam/panels/fhrp_groups.html'
|
||||
title = _('FHRP Groups')
|
||||
actions = [
|
||||
@@ -35,3 +36,220 @@ class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
|
||||
label=_('Assign Group'),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class VRFPanel(panels.ObjectAttributesPanel):
|
||||
rd = attrs.TextAttr('rd', label=_('Route Distinguisher'), style='font-monospace')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
enforce_unique = attrs.BooleanAttr('enforce_unique', label=_('Unique IP Space'))
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class RouteTargetPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name', style='font-monospace')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class RIRPanel(panels.OrganizationalObjectPanel):
|
||||
is_private = attrs.BooleanAttr('is_private', label=_('Private'))
|
||||
|
||||
|
||||
class ASNRangePanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
|
||||
range = attrs.TextAttr('range_as_string_with_asdot', label=_('Range'))
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ASNPanel(panels.ObjectAttributesPanel):
|
||||
asn = attrs.TextAttr('asn_with_asdot', label=_('AS Number'))
|
||||
rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class AggregatePanel(panels.ObjectAttributesPanel):
|
||||
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
|
||||
rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
|
||||
utilization = attrs.TemplatedAttr(
|
||||
'prefix',
|
||||
template_name='ipam/aggregate/attrs/utilization.html',
|
||||
label=_('Utilization'),
|
||||
)
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
date_added = attrs.DateTimeAttr('date_added', spec='date', label=_('Date Added'))
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class RolePanel(panels.OrganizationalObjectPanel):
|
||||
weight = attrs.NumericAttr('weight')
|
||||
|
||||
|
||||
class IPRangePanel(panels.ObjectAttributesPanel):
|
||||
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
|
||||
start_address = attrs.TextAttr('start_address', label=_('Starting Address'))
|
||||
end_address = attrs.TextAttr('end_address', label=_('Ending Address'))
|
||||
size = attrs.NumericAttr('size')
|
||||
mark_populated = attrs.BooleanAttr('mark_populated', label=_('Marked Populated'))
|
||||
mark_utilized = attrs.BooleanAttr('mark_utilized', label=_('Marked Utilized'))
|
||||
utilization = attrs.TemplatedAttr(
|
||||
'utilization',
|
||||
template_name='ipam/iprange/attrs/utilization.html',
|
||||
label=_('Utilization'),
|
||||
)
|
||||
vrf = VRFDisplayAttr('vrf', label=_('VRF'), show_rd=True)
|
||||
role = attrs.RelatedObjectAttr('role', linkify=True)
|
||||
status = attrs.ChoiceAttr('status')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class IPAddressPanel(panels.ObjectAttributesPanel):
|
||||
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
|
||||
vrf = VRFDisplayAttr('vrf', label=_('VRF'))
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
role = attrs.ChoiceAttr('role')
|
||||
dns_name = attrs.TextAttr('dns_name', label=_('DNS Name'))
|
||||
description = attrs.TextAttr('description')
|
||||
assigned_object = attrs.RelatedObjectAttr(
|
||||
'assigned_object',
|
||||
linkify=True,
|
||||
grouped_by='parent_object',
|
||||
label=_('Assignment'),
|
||||
)
|
||||
nat_inside = attrs.TemplatedAttr(
|
||||
'nat_inside',
|
||||
template_name='ipam/ipaddress/attrs/nat_inside.html',
|
||||
label=_('NAT (inside)'),
|
||||
)
|
||||
nat_outside = attrs.TemplatedAttr(
|
||||
'nat_outside',
|
||||
template_name='ipam/ipaddress/attrs/nat_outside.html',
|
||||
label=_('NAT (outside)'),
|
||||
)
|
||||
is_primary_ip = attrs.BooleanAttr('is_primary_ip', label=_('Primary IP'))
|
||||
is_oob_ip = attrs.BooleanAttr('is_oob_ip', label=_('OOB IP'))
|
||||
|
||||
|
||||
class PrefixPanel(panels.ObjectAttributesPanel):
|
||||
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
|
||||
vrf = VRFDisplayAttr('vrf', label=_('VRF'))
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
aggregate = attrs.TemplatedAttr(
|
||||
'aggregate',
|
||||
template_name='ipam/prefix/attrs/aggregate.html',
|
||||
label=_('Aggregate'),
|
||||
)
|
||||
scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
|
||||
vlan = attrs.RelatedObjectAttr('vlan', linkify=True, label=_('VLAN'), grouped_by='group')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
role = attrs.RelatedObjectAttr('role', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
is_pool = attrs.BooleanAttr('is_pool', label=_('Is a pool'))
|
||||
|
||||
|
||||
class VLANGroupPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
|
||||
vid_ranges = attrs.TemplatedAttr(
|
||||
'vid_ranges_items',
|
||||
template_name='ipam/vlangroup/attrs/vid_ranges.html',
|
||||
label=_('VLAN IDs'),
|
||||
)
|
||||
utilization = attrs.UtilizationAttr('utilization')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
|
||||
|
||||
class VLANTranslationPolicyPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class VLANTranslationRulePanel(panels.ObjectAttributesPanel):
|
||||
policy = attrs.RelatedObjectAttr('policy', linkify=True)
|
||||
local_vid = attrs.NumericAttr('local_vid', label=_('Local VID'))
|
||||
remote_vid = attrs.NumericAttr('remote_vid', label=_('Remote VID'))
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class FHRPGroupPanel(panels.ObjectAttributesPanel):
|
||||
protocol = attrs.ChoiceAttr('protocol')
|
||||
group_id = attrs.NumericAttr('group_id', label=_('Group ID'))
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
member_count = attrs.NumericAttr('member_count', label=_('Members'))
|
||||
|
||||
|
||||
class FHRPGroupAuthPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Authentication')
|
||||
|
||||
auth_type = attrs.ChoiceAttr('auth_type', label=_('Authentication Type'))
|
||||
auth_key = attrs.TextAttr('auth_key', label=_('Authentication Key'))
|
||||
|
||||
|
||||
class VLANPanel(panels.ObjectAttributesPanel):
|
||||
region = attrs.NestedObjectAttr('site.region', linkify=True, label=_('Region'))
|
||||
site = attrs.RelatedObjectAttr('site', linkify=True)
|
||||
group = attrs.RelatedObjectAttr('group', linkify=True)
|
||||
vid = attrs.NumericAttr('vid', label=_('VLAN ID'))
|
||||
name = attrs.TextAttr('name')
|
||||
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
|
||||
status = attrs.ChoiceAttr('status')
|
||||
role = attrs.RelatedObjectAttr('role', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
qinq_role = attrs.ChoiceAttr('qinq_role', label=_('Q-in-Q Role'))
|
||||
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
|
||||
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
|
||||
|
||||
|
||||
class VLANCustomerVLANsPanel(panels.ObjectsTablePanel):
|
||||
"""
|
||||
A panel listing customer VLANs (C-VLANs) for an S-VLAN. Only renders when the VLAN has Q-in-Q
|
||||
role 'svlan'.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
'ipam.vlan',
|
||||
filters={'qinq_svlan_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Customer VLANs'),
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'ipam.vlan',
|
||||
url_params={
|
||||
'qinq_role': 'cvlan',
|
||||
'qinq_svlan': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
label=_('Add a VLAN'),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def render(self, context):
|
||||
obj = context.get('object')
|
||||
if not obj or obj.qinq_role != 'svlan':
|
||||
return ''
|
||||
return super().render(context)
|
||||
|
||||
|
||||
class ServiceTemplatePanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
protocol = attrs.ChoiceAttr('protocol')
|
||||
ports = attrs.TextAttr('port_list', label=_('Ports'))
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ServicePanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
parent = attrs.RelatedObjectAttr('parent', linkify=True)
|
||||
protocol = attrs.ChoiceAttr('protocol')
|
||||
ports = attrs.TextAttr('port_list', label=_('Ports'))
|
||||
ip_addresses = attrs.TemplatedAttr(
|
||||
'ipaddresses',
|
||||
template_name='ipam/service/attrs/ip_addresses.html',
|
||||
label=_('IP Addresses'),
|
||||
)
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
@@ -78,12 +78,21 @@ def annotate_ip_space(prefix):
|
||||
records = sorted(records, key=lambda x: x[0])
|
||||
|
||||
# Determine the first & last valid IP addresses in the prefix
|
||||
if prefix.family == 4 and prefix.mask_length < 31 and not prefix.is_pool:
|
||||
if (
|
||||
prefix.is_pool
|
||||
or (prefix.family == 4 and prefix.mask_length >= 31)
|
||||
or (prefix.family == 6 and prefix.mask_length >= 127)
|
||||
):
|
||||
# Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
|
||||
elif prefix.family == 4:
|
||||
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
|
||||
else:
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
|
||||
# For IPv6 prefixes, omit the Subnet-Router anycast address (RFC 4291)
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
|
||||
|
||||
if not records:
|
||||
|
||||
@@ -9,8 +9,16 @@ from circuits.models import Provider
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.forms import InterfaceFilterForm
|
||||
from dcim.models import Device, Interface, Site
|
||||
from ipam.tables import VLANTranslationRuleTable
|
||||
from extras.ui.panels import CustomFieldsPanel, TagsPanel
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||
from netbox.ui import actions, layout
|
||||
from netbox.ui.panels import (
|
||||
CommentsPanel,
|
||||
ContextTablePanel,
|
||||
ObjectsTablePanel,
|
||||
RelatedObjectsPanel,
|
||||
TemplatePanel,
|
||||
)
|
||||
from netbox.views import generic
|
||||
from utilities.query import count_related
|
||||
from utilities.tables import get_table_ordering
|
||||
@@ -23,6 +31,7 @@ from . import filtersets, forms, tables
|
||||
from .choices import PrefixStatusChoices
|
||||
from .constants import *
|
||||
from .models import *
|
||||
from .ui import panels
|
||||
from .utils import add_available_vlans, add_requested_prefixes, annotate_ip_space
|
||||
|
||||
#
|
||||
@@ -41,6 +50,27 @@ class VRFListView(generic.ObjectListView):
|
||||
@register_model_view(VRF)
|
||||
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VRF.objects.all()
|
||||
layout = layout.Layout(
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
panels.VRFPanel(),
|
||||
TagsPanel(),
|
||||
),
|
||||
layout.Column(
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
),
|
||||
),
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
ContextTablePanel('import_targets_table', title=_('Import route targets')),
|
||||
),
|
||||
layout.Column(
|
||||
ContextTablePanel('export_targets_table', title=_('Export route targets')),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
import_targets_table = tables.RouteTargetTable(
|
||||
@@ -134,6 +164,50 @@ class RouteTargetListView(generic.ObjectListView):
|
||||
@register_model_view(RouteTarget)
|
||||
class RouteTargetView(generic.ObjectView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
layout = layout.Layout(
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
panels.RouteTargetPanel(),
|
||||
TagsPanel(),
|
||||
),
|
||||
layout.Column(
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
),
|
||||
),
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
ObjectsTablePanel(
|
||||
'ipam.vrf',
|
||||
filters={'import_target_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Importing VRFs'),
|
||||
),
|
||||
),
|
||||
layout.Column(
|
||||
ObjectsTablePanel(
|
||||
'ipam.vrf',
|
||||
filters={'export_target_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Exporting VRFs'),
|
||||
),
|
||||
),
|
||||
),
|
||||
layout.Row(
|
||||
layout.Column(
|
||||
ObjectsTablePanel(
|
||||
'vpn.l2vpn',
|
||||
filters={'import_target_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Importing L2VPNs'),
|
||||
),
|
||||
),
|
||||
layout.Column(
|
||||
ObjectsTablePanel(
|
||||
'vpn.l2vpn',
|
||||
filters={'export_target_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Exporting L2VPNs'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(RouteTarget, 'add', detail=False)
|
||||
@@ -192,6 +266,17 @@ class RIRListView(generic.ObjectListView):
|
||||
@register_model_view(RIR)
|
||||
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = RIR.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.RIRPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -257,6 +342,16 @@ class ASNRangeListView(generic.ObjectListView):
|
||||
@register_model_view(ASNRange)
|
||||
class ASNRangeView(generic.ObjectView):
|
||||
queryset = ASNRange.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ASNRangePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ASNRange, 'asns')
|
||||
@@ -337,6 +432,17 @@ class ASNListView(generic.ObjectListView):
|
||||
@register_model_view(ASN)
|
||||
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ASN.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ASNPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -412,6 +518,16 @@ class AggregateListView(generic.ObjectListView):
|
||||
@register_model_view(Aggregate)
|
||||
class AggregateView(generic.ObjectView):
|
||||
queryset = Aggregate.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.AggregatePanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Aggregate, 'prefixes')
|
||||
@@ -506,6 +622,17 @@ class RoleListView(generic.ObjectListView):
|
||||
@register_model_view(Role)
|
||||
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Role.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.RolePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -569,15 +696,23 @@ class PrefixListView(generic.ObjectListView):
|
||||
@register_model_view(Prefix)
|
||||
class PrefixView(generic.ObjectView):
|
||||
queryset = Prefix.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PrefixPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TemplatePanel('ipam/panels/prefix_addressing.html'),
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ContextTablePanel('duplicate_prefix_table', title=_('Duplicate prefixes')),
|
||||
ContextTablePanel('parent_prefix_table', title=_('Parent prefixes')),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
try:
|
||||
aggregate = Aggregate.objects.restrict(request.user, 'view').get(
|
||||
prefix__net_contains_or_equals=str(instance.prefix)
|
||||
)
|
||||
except Aggregate.DoesNotExist:
|
||||
aggregate = None
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
|
||||
@@ -608,11 +743,12 @@ class PrefixView(generic.ObjectView):
|
||||
)
|
||||
duplicate_prefix_table.configure(request)
|
||||
|
||||
return {
|
||||
'aggregate': aggregate,
|
||||
context = {
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
}
|
||||
if duplicate_prefixes.exists():
|
||||
context['duplicate_prefix_table'] = duplicate_prefix_table
|
||||
return context
|
||||
|
||||
|
||||
@register_model_view(Prefix, 'prefixes')
|
||||
@@ -756,6 +892,19 @@ class IPRangeListView(generic.ObjectListView):
|
||||
@register_model_view(IPRange)
|
||||
class IPRangeView(generic.ObjectView):
|
||||
queryset = IPRange.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.IPRangePanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
@@ -853,6 +1002,23 @@ class IPAddressListView(generic.ObjectListView):
|
||||
@register_model_view(IPAddress)
|
||||
class IPAddressView(generic.ObjectView):
|
||||
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.IPAddressPanel(),
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
|
||||
ContextTablePanel('duplicate_ips_table', title=_('Duplicate IPs')),
|
||||
ObjectsTablePanel(
|
||||
'ipam.service',
|
||||
filters={'ip_address_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Application services'),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Parent prefixes table
|
||||
@@ -885,10 +1051,12 @@ class IPAddressView(generic.ObjectView):
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
duplicate_ips_table.configure(request)
|
||||
|
||||
return {
|
||||
context = {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
}
|
||||
if duplicate_ips.exists():
|
||||
context['duplicate_ips_table'] = duplicate_ips_table
|
||||
return context
|
||||
|
||||
|
||||
@register_model_view(IPAddress, 'add', detail=False)
|
||||
@@ -1038,6 +1206,17 @@ class VLANGroupListView(generic.ObjectListView):
|
||||
@register_model_view(VLANGroup)
|
||||
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VLANGroup.objects.annotate_utilization()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VLANGroupPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CommentsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -1125,19 +1304,32 @@ class VLANTranslationPolicyListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationPolicy)
|
||||
class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
class VLANTranslationPolicyView(generic.ObjectView):
|
||||
queryset = VLANTranslationPolicy.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
vlan_translation_table = VLANTranslationRuleTable(
|
||||
data=instance.rules.all(),
|
||||
orderable=False
|
||||
)
|
||||
vlan_translation_table.configure(request)
|
||||
|
||||
return {
|
||||
'vlan_translation_table': vlan_translation_table,
|
||||
}
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VLANTranslationPolicyPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
'ipam.vlantranslationrule',
|
||||
filters={'policy_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('VLAN translation rules'),
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'ipam.vlantranslationrule',
|
||||
url_params={'policy': lambda ctx: ctx['object'].pk},
|
||||
label=_('Add Rule'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationPolicy, 'add', detail=False)
|
||||
@@ -1193,13 +1385,17 @@ class VLANTranslationRuleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationRule)
|
||||
class VLANTranslationRuleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
class VLANTranslationRuleView(generic.ObjectView):
|
||||
queryset = VLANTranslationRule.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VLANTranslationRulePanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TagsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationRule, 'add', detail=False)
|
||||
@@ -1251,7 +1447,36 @@ class FHRPGroupListView(generic.ObjectListView):
|
||||
|
||||
@register_model_view(FHRPGroup)
|
||||
class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
queryset = FHRPGroup.objects.annotate(
|
||||
member_count=count_related(FHRPGroupAssignment, 'group')
|
||||
)
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.FHRPGroupPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.FHRPGroupAuthPanel(),
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
'ipam.ipaddress',
|
||||
filters={'fhrpgroup_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Virtual IP addresses'),
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'ipam.ipaddress',
|
||||
url_params={'fhrpgroup': lambda ctx: ctx['object'].pk},
|
||||
label=_('Add IP Address'),
|
||||
),
|
||||
],
|
||||
),
|
||||
ContextTablePanel('members_table', title=_('Members')),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get assigned interfaces
|
||||
@@ -1276,7 +1501,6 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
),
|
||||
'members_table': members_table,
|
||||
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1379,17 +1603,35 @@ class VLANListView(generic.ObjectListView):
|
||||
@register_model_view(VLAN)
|
||||
class VLANView(generic.ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
|
||||
'vrf', 'scope', 'role', 'tenant'
|
||||
)
|
||||
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
|
||||
prefix_table.configure(request)
|
||||
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
}
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.VLANPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
'ipam.prefix',
|
||||
filters={'vlan_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Prefixes'),
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'ipam.prefix',
|
||||
url_params={
|
||||
'tenant': lambda ctx: ctx['object'].tenant.pk if ctx['object'].tenant else None,
|
||||
'site': lambda ctx: ctx['object'].site.pk if ctx['object'].site else None,
|
||||
'vlan': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
label=_('Add a Prefix'),
|
||||
),
|
||||
],
|
||||
),
|
||||
panels.VLANCustomerVLANsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VLAN, 'interfaces')
|
||||
@@ -1483,6 +1725,16 @@ class ServiceTemplateListView(generic.ObjectListView):
|
||||
@register_model_view(ServiceTemplate)
|
||||
class ServiceTemplateView(generic.ObjectView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ServiceTemplatePanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ServiceTemplate, 'add', detail=False)
|
||||
@@ -1539,6 +1791,16 @@ class ServiceListView(generic.ObjectListView):
|
||||
@register_model_view(Service)
|
||||
class ServiceView(generic.ObjectView):
|
||||
queryset = Service.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ServicePanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CustomFieldsPanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
context = {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import CreateOnlyDefault
|
||||
|
||||
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldsDataField
|
||||
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField
|
||||
|
||||
from .base import ValidatedModelSerializer
|
||||
from .nested import NestedTagSerializer
|
||||
@@ -23,6 +23,29 @@ class CustomFieldModelSerializer(serializers.Serializer):
|
||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def many_init(cls, *args, **kwargs):
|
||||
"""
|
||||
We can't call super().many_init() and change the outcome because by the time it returns,
|
||||
the plain ListSerializer is already instantiated.
|
||||
Because every NetBox serializer defines its own Meta which doesn't inherit from a parent Meta,
|
||||
this would silently not apply to any real serializer.
|
||||
Thats why this method replicates many_init from parent and changed the default value for list_serializer_class.
|
||||
"""
|
||||
list_kwargs = {}
|
||||
for key in serializers.LIST_SERIALIZER_KWARGS_REMOVE:
|
||||
value = kwargs.pop(key, None)
|
||||
if value is not None:
|
||||
list_kwargs[key] = value
|
||||
list_kwargs['child'] = cls(*args, **kwargs)
|
||||
list_kwargs.update({
|
||||
key: value for key, value in kwargs.items()
|
||||
if key in serializers.LIST_SERIALIZER_KWARGS
|
||||
})
|
||||
meta = getattr(cls, 'Meta', None)
|
||||
list_serializer_class = getattr(meta, 'list_serializer_class', CustomFieldListSerializer)
|
||||
return list_serializer_class(*args, **list_kwargs)
|
||||
|
||||
|
||||
class TaggableModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -53,8 +76,11 @@ class TaggableModelSerializer(serializers.Serializer):
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
# Cache tags on instance so serialize_object() can reuse them without a DB query
|
||||
instance._tags = tags
|
||||
instance.tags.set([t.name for t in tags])
|
||||
else:
|
||||
instance._tags = []
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user