mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-21 20:18:38 -06:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8816af1389 | ||
|
|
ca76d37ffe | ||
|
|
ef0b0eaee9 | ||
|
|
8391c6ae95 | ||
|
|
0752fd2c63 | ||
|
|
ddb8ce90eb | ||
|
|
6193ef506f | ||
|
|
91e0b661a4 | ||
|
|
cef3eb0ab0 | ||
|
|
d314dac470 | ||
|
|
6294a96199 | ||
|
|
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 | ||
|
|
cb99199340 | ||
|
|
1be917fb90 | ||
|
|
3b0b95c265 | ||
|
|
cdc2fb2f06 | ||
|
|
951d856c3c |
+1
-1
@@ -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.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
+1
-1
@@ -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.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
+1
-1
@@ -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.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
+6
-13
@@ -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:
|
||||
@@ -33,7 +27,7 @@ jobs:
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
Vendored
+31
-1
@@ -30,9 +30,39 @@ jobs:
|
||||
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 }}
|
||||
|
||||
|
||||
Vendored
+4
-4
@@ -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@v6.0.0
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
discussion-inactive-days: 180
|
||||
issue-lock-reason: 'resolved'
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
+4
-2
@@ -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",
|
||||
|
||||
+441
-299
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
```
|
||||
|
||||
@@ -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,5 +1,34 @@
|
||||
# NetBox v4.5
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}."
|
||||
|
||||
@@ -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
|
||||
|
||||
+142
-131
@@ -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()
|
||||
|
||||
@@ -293,7 +293,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 +304,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 +402,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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1201,6 +1201,35 @@ 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])
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -137,6 +137,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,11 +159,36 @@ 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 VirtualChassisMembersPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which lists all members of a virtual chassis.
|
||||
|
||||
+78
-31
@@ -16,7 +16,7 @@ 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 netbox.object_actions import *
|
||||
from netbox.ui import actions, layout
|
||||
from netbox.ui.panels import (
|
||||
@@ -25,6 +25,7 @@ from netbox.ui.panels import (
|
||||
NestedGroupObjectPanel,
|
||||
ObjectsTablePanel,
|
||||
OrganizationalObjectPanel,
|
||||
Panel,
|
||||
RelatedObjectsPanel,
|
||||
TemplatePanel,
|
||||
)
|
||||
@@ -388,7 +389,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}),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1667,6 +1668,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 +2323,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 +2423,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 {
|
||||
@@ -2733,6 +2792,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 +2837,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 {
|
||||
@@ -3155,21 +3230,6 @@ class InterfaceView(generic.ObjectView):
|
||||
)
|
||||
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 VLAN translation rules
|
||||
vlan_translation_table = None
|
||||
if instance.vlan_translation_policy:
|
||||
@@ -3185,7 +3245,6 @@ class InterfaceView(generic.ObjectView):
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -3912,19 +3971,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):
|
||||
@@ -4099,6 +4145,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:
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -432,9 +432,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
|
||||
|
||||
|
||||
@@ -364,7 +364,6 @@ class VLANTranslationPolicy(PrimaryModel):
|
||||
max_length=100,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('VLAN translation policy')
|
||||
verbose_name_plural = _('VLAN translation policies')
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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.
|
||||
"""
|
||||
template_name = 'ipam/attrs/vrf.html'
|
||||
|
||||
def render(self, obj, context):
|
||||
value = self.get_value(obj)
|
||||
return render_to_string(self.template_name, {
|
||||
**self.get_context(obj, context),
|
||||
'name': context.get('name', ''),
|
||||
'value': value,
|
||||
})
|
||||
|
||||
|
||||
class VRFDisplayWithRDAttr(VRFDisplayAttr):
|
||||
"""
|
||||
Renders a VRF reference with its route distinguisher.
|
||||
"""
|
||||
template_name = 'ipam/attrs/vrf_with_rd.html'
|
||||
+202
-2
@@ -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, VRFDisplayWithRDAttr
|
||||
|
||||
|
||||
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,202 @@ 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)
|
||||
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 = VRFDisplayWithRDAttr('vrf', label=_('VRF'))
|
||||
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.TemplatedAttr(
|
||||
'assigned_object',
|
||||
template_name='ipam/ipaddress/attrs/assigned_object.html',
|
||||
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 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')
|
||||
|
||||
+11
-2
@@ -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:
|
||||
|
||||
+306
-36
@@ -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,6 +696,21 @@ class PrefixListView(generic.ObjectListView):
|
||||
@register_model_view(Prefix)
|
||||
class PrefixView(generic.ObjectView):
|
||||
queryset = Prefix.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
TemplatePanel('ipam/panels/prefix.html'),
|
||||
],
|
||||
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:
|
||||
@@ -608,11 +750,13 @@ class PrefixView(generic.ObjectView):
|
||||
)
|
||||
duplicate_prefix_table.configure(request)
|
||||
|
||||
return {
|
||||
result = {
|
||||
'aggregate': aggregate,
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
}
|
||||
if duplicate_prefixes.exists():
|
||||
result['duplicate_prefix_table'] = duplicate_prefix_table
|
||||
return result
|
||||
|
||||
|
||||
@register_model_view(Prefix, 'prefixes')
|
||||
@@ -756,6 +900,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 +1010,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 +1059,12 @@ class IPAddressView(generic.ObjectView):
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
duplicate_ips_table.configure(request)
|
||||
|
||||
return {
|
||||
result = {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
}
|
||||
if duplicate_ips.exists():
|
||||
result['duplicate_ips_table'] = duplicate_ips_table
|
||||
return result
|
||||
|
||||
|
||||
@register_model_view(IPAddress, 'add', detail=False)
|
||||
@@ -1038,6 +1214,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 +1312,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 +1393,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 +1455,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 +1509,6 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
),
|
||||
'members_table': members_table,
|
||||
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1379,17 +1611,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 +1733,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 +1799,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 = {}
|
||||
|
||||
@@ -53,8 +53,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
|
||||
|
||||
@@ -15,6 +15,7 @@ from strawberry_django import (
|
||||
DatetimeFilterLookup,
|
||||
FilterLookup,
|
||||
RangeLookup,
|
||||
StrFilterLookup,
|
||||
TimeFilterLookup,
|
||||
process_filters,
|
||||
)
|
||||
@@ -40,7 +41,7 @@ SKIP_MSG = 'Filter will be skipped on `null` value'
|
||||
|
||||
@strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
|
||||
class JSONLookup:
|
||||
string_lookup: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
string_lookup: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
|
||||
int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||
float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
|
||||
@@ -78,6 +79,9 @@ class IntegerLookup:
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
if isinstance(filters, RangeLookup):
|
||||
prefix = f'{prefix}range__'
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
@@ -101,6 +105,9 @@ class BigIntegerLookup:
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
if isinstance(filters, RangeLookup):
|
||||
prefix = f'{prefix}range__'
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
@@ -124,6 +131,9 @@ class FloatLookup:
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
if isinstance(filters, RangeLookup):
|
||||
prefix = f'{prefix}range__'
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated, TypeVar
|
||||
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
|
||||
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
|
||||
|
||||
__all__ = (
|
||||
'DistanceFilterMixin',
|
||||
@@ -48,7 +48,7 @@ class SyncedDataFilterMixin:
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
data_path: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
data_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import strawberry_django
|
||||
from strawberry import ID
|
||||
from strawberry_django import ComparisonFilterLookup, FilterLookup
|
||||
from strawberry_django import ComparisonFilterLookup, StrFilterLookup
|
||||
|
||||
from core.graphql.filter_mixins import ChangeLoggingMixin
|
||||
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
|
||||
@@ -42,21 +42,21 @@ class NetBoxModelFilter(
|
||||
|
||||
@dataclass
|
||||
class NestedGroupModelFilter(NetBoxModelFilter):
|
||||
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()
|
||||
parent_id: ID | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrganizationalModelFilter(NetBoxModelFilter):
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: 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()
|
||||
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrimaryModelFilter(NetBoxModelFilter):
|
||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -2,6 +2,8 @@ import strawberry
|
||||
from strawberry.types.unset import UNSET
|
||||
from strawberry_django.pagination import _QS, apply
|
||||
|
||||
from netbox.config import get_config
|
||||
|
||||
__all__ = (
|
||||
'OffsetPaginationInfo',
|
||||
'OffsetPaginationInput',
|
||||
@@ -47,4 +49,14 @@ def apply_pagination(
|
||||
# Ignore `offset` when `start` is set
|
||||
pagination.offset = 0
|
||||
|
||||
# Enforce MAX_PAGE_SIZE on the pagination limit
|
||||
max_page_size = get_config().MAX_PAGE_SIZE
|
||||
if max_page_size:
|
||||
if pagination is None:
|
||||
pagination = OffsetPaginationInput(limit=max_page_size)
|
||||
elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
|
||||
pagination.limit = max_page_size
|
||||
elif pagination.limit <= 0:
|
||||
pagination.limit = max_page_size
|
||||
|
||||
return apply(pagination, queryset, related_field_id=related_field_id)
|
||||
|
||||
@@ -40,15 +40,24 @@ class CoreMiddleware:
|
||||
with apply_request_processors(request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Check if language cookie should be renewed
|
||||
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
# Set or renew the language cookie based on the user's preference. This handles two cases:
|
||||
# 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
|
||||
# the request so we set the cookie here on the login response.
|
||||
# 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
|
||||
# the session expiry.
|
||||
if hasattr(request, '_language_cookie'):
|
||||
language = request._language_cookie
|
||||
elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||
language = request.user.config.get('locale.language')
|
||||
else:
|
||||
language = None
|
||||
if language:
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
# Attach the unique request ID as an HTTP header.
|
||||
response['X-Request-ID'] = request.id
|
||||
|
||||
@@ -15,6 +15,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
from extras.managers import NetBoxTaggableManager
|
||||
from extras.utils import is_taggable
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import CORE_APPS
|
||||
@@ -487,11 +488,12 @@ class JournalingMixin(models.Model):
|
||||
class TagsMixin(models.Model):
|
||||
"""
|
||||
Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
|
||||
which is a `TaggableManager` instance.
|
||||
which is a `NetBoxTaggableManager` instance.
|
||||
"""
|
||||
tags = TaggableManager(
|
||||
through='extras.TaggedItem',
|
||||
ordering=('weight', 'name'),
|
||||
manager=NetBoxTaggableManager,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import LocationStatusChoices
|
||||
from dcim.models import Location, Site
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
|
||||
from utilities.testing import APITestCase, TestCase, disable_warnings
|
||||
|
||||
|
||||
@@ -138,6 +138,40 @@ class GraphQLAPITestCase(APITestCase):
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site']['locations']), 0)
|
||||
|
||||
def test_graphql_integer_range_lookup(self):
|
||||
"""
|
||||
Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
|
||||
"""
|
||||
self.add_permissions('dcim.view_device')
|
||||
url = reverse('graphql')
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
|
||||
device_role = DeviceRole.objects.create(name='Test Role', slug='test-role')
|
||||
site = Site.objects.first()
|
||||
vc = VirtualChassis.objects.create(name='Test VC')
|
||||
|
||||
devices = [
|
||||
Device(name=f'Device {i}', device_type=device_type, role=device_role, site=site,
|
||||
virtual_chassis=vc, vc_position=i)
|
||||
for i in range(1, 6)
|
||||
]
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
# range_lookup should return devices with vc_position between 2 and 4 inclusive
|
||||
query = """
|
||||
{
|
||||
device_list(filters: {vc_position: {range_lookup: {start: 2, end: 4}}}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format="json", **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['device_list']), 3)
|
||||
|
||||
def test_offset_pagination(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('graphql')
|
||||
@@ -249,6 +283,53 @@ class GraphQLAPITestCase(APITestCase):
|
||||
self.assertEqual(len(data['data']['site_list']), 1)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
|
||||
|
||||
@override_settings(MAX_PAGE_SIZE=3)
|
||||
def test_max_page_size(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('graphql')
|
||||
|
||||
# Request without explicit limit should be capped by MAX_PAGE_SIZE
|
||||
query = """
|
||||
{
|
||||
site_list {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
|
||||
# Request with limit exceeding MAX_PAGE_SIZE should be capped
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {limit: 100}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
|
||||
# Request with limit under MAX_PAGE_SIZE should be respected
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {limit: 2}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site_list']), 2)
|
||||
|
||||
def test_pagination_conflict(self):
|
||||
url = reverse('graphql')
|
||||
query = """
|
||||
|
||||
@@ -10,6 +10,7 @@ __all__ = (
|
||||
'BooleanAttr',
|
||||
'ChoiceAttr',
|
||||
'ColorAttr',
|
||||
'DateTimeAttr',
|
||||
'GPSCoordinatesAttr',
|
||||
'GenericForeignKeyAttr',
|
||||
'ImageAttr',
|
||||
@@ -367,6 +368,26 @@ class GPSCoordinatesAttr(ObjectAttribute):
|
||||
})
|
||||
|
||||
|
||||
class DateTimeAttr(ObjectAttribute):
|
||||
"""
|
||||
A date or datetime attribute.
|
||||
|
||||
Parameters:
|
||||
spec (str): Controls the rendering format. Use 'date' for date-only rendering,
|
||||
or 'seconds'/'minutes' for datetime rendering with the given precision.
|
||||
"""
|
||||
template_name = 'ui/attrs/datetime.html'
|
||||
|
||||
def __init__(self, *args, spec='seconds', **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.spec = spec
|
||||
|
||||
def get_context(self, obj, context):
|
||||
return {
|
||||
'spec': self.spec,
|
||||
}
|
||||
|
||||
|
||||
class TimezoneAttr(ObjectAttribute):
|
||||
"""
|
||||
A timezone value. Includes the numeric offset from UTC.
|
||||
|
||||
@@ -44,15 +44,18 @@ class Panel:
|
||||
Parameters:
|
||||
title (str): The human-friendly title of the panel
|
||||
actions (list): An iterable of PanelActions to include in the panel header
|
||||
template_name (str): Overrides the default template name, if defined
|
||||
"""
|
||||
template_name = None
|
||||
title = None
|
||||
actions = None
|
||||
|
||||
def __init__(self, title=None, actions=None):
|
||||
def __init__(self, title=None, actions=None, template_name=None):
|
||||
if title is not None:
|
||||
self.title = title
|
||||
self.actions = actions or self.actions or []
|
||||
if template_name is not None:
|
||||
self.template_name = template_name
|
||||
|
||||
def get_context(self, context):
|
||||
"""
|
||||
@@ -317,9 +320,8 @@ class TemplatePanel(Panel):
|
||||
Parameters:
|
||||
template_name (str): The name of the template to render
|
||||
"""
|
||||
def __init__(self, template_name, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.template_name = template_name
|
||||
def __init__(self, template_name):
|
||||
super().__init__(template_name=template_name)
|
||||
|
||||
def render(self, context):
|
||||
# Pass the entire context to the template
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
@@ -32,19 +32,19 @@
|
||||
"htmx.org": "2.0.8",
|
||||
"query-string": "9.3.1",
|
||||
"sass": "1.97.3",
|
||||
"tom-select": "2.4.3",
|
||||
"tom-select": "2.5.2",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/cookie": "^1.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"esbuild-sass-plugin": "^3.6.0",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -52,12 +52,15 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^17.3.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6",
|
||||
"eslint/**/minimatch": "^3.1.3",
|
||||
"eslint-plugin-import/**/minimatch": "^3.1.3",
|
||||
"**/markdown-it": "^14.1.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -20,12 +20,7 @@ function storeColorMode(mode: ColorMode): void {
|
||||
}
|
||||
|
||||
function updateElements(targetMode: ColorMode): void {
|
||||
const body = document.querySelector('body');
|
||||
if (body && targetMode == 'dark') {
|
||||
body.setAttribute('data-bs-theme', 'dark');
|
||||
} else if (body) {
|
||||
body.setAttribute('data-bs-theme', 'light');
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', targetMode);
|
||||
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
const svg = elevation.firstElementChild ?? null;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { RecursivePartial, TomOption, TomSettings } from 'tom-select/dist/types/types';
|
||||
import { TomInput } from 'tom-select/dist/cjs/types/core';
|
||||
import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
|
||||
import { addClasses } from 'tom-select/src/vanilla.ts';
|
||||
import queryString from 'query-string';
|
||||
import TomSelect from 'tom-select';
|
||||
import type { Stringifiable } from 'query-string';
|
||||
import { DynamicParamsMap } from './dynamicParamsMap';
|
||||
import { NetBoxTomSelect } from './netboxTomSelect';
|
||||
|
||||
// Transitional
|
||||
import { QueryFilter, PathFilter } from '../types';
|
||||
import { getElement, replaceAll } from '../../util';
|
||||
|
||||
// Extends TomSelect to provide enhanced fetching of options via the REST API
|
||||
export class DynamicTomSelect extends TomSelect {
|
||||
// Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
|
||||
export class DynamicTomSelect extends NetBoxTomSelect {
|
||||
public readonly nullOption: Nullable<TomOption> = null;
|
||||
|
||||
// Transitional code from APISelect
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import TomSelect from 'tom-select';
|
||||
|
||||
/**
|
||||
* Extends TomSelect to work around a browser autofill bug where Edge's "last used" autofill
|
||||
* simultaneously focuses multiple inputs, triggering a cascading focus/open/blur loop between
|
||||
* TomSelect instances.
|
||||
*
|
||||
* Root cause: TomSelect's open() method calls focus(), which synchronously moves browser focus
|
||||
* to this instance's control input, then schedules setTimeout(onFocus, 0). When Edge autofill
|
||||
* has moved focus to a *different* select before the timeout fires, the delayed onFocus() call
|
||||
* re-steals browser focus back, causing the other instance to blur and close. Each instance's
|
||||
* deferred callback then repeats this, creating an infinite ping-pong loop.
|
||||
*
|
||||
* Fix: in the setTimeout callback, only proceed with onFocus() if this instance's element is
|
||||
* still the active element. If focus has already moved elsewhere, skip the call.
|
||||
*
|
||||
* Upstream bug: https://github.com/orchidjs/tom-select/issues/806
|
||||
* NetBox issue: https://github.com/netbox-community/netbox/issues/20077
|
||||
*/
|
||||
export class NetBoxTomSelect extends TomSelect {
|
||||
focus(): void {
|
||||
if (this.isDisabled || this.isReadOnly) return;
|
||||
|
||||
this.ignoreFocus = true;
|
||||
|
||||
const focusTarget = this.control_input.offsetWidth ? this.control_input : this.focus_node;
|
||||
focusTarget.focus();
|
||||
|
||||
setTimeout(() => {
|
||||
this.ignoreFocus = false;
|
||||
// Only proceed if this instance's element is still the active element. If Edge autofill
|
||||
// (or anything else) has moved focus to a different element in the interim, calling
|
||||
// onFocus() here would steal focus back and restart the cascade loop.
|
||||
if (document.activeElement === focusTarget || this.control.contains(document.activeElement)) {
|
||||
this.onFocus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TomOption } from 'tom-select/src/types';
|
||||
import TomSelect from 'tom-select';
|
||||
import { escape_html } from 'tom-select/src/utils';
|
||||
import { NetBoxTomSelect } from './classes/netboxTomSelect';
|
||||
import { getPlugins } from './config';
|
||||
import { getElements } from '../util';
|
||||
|
||||
@@ -9,7 +9,7 @@ export function initStaticSelects(): void {
|
||||
for (const select of getElements<HTMLSelectElement>(
|
||||
'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
|
||||
)) {
|
||||
new TomSelect(select, {
|
||||
new NetBoxTomSelect(select, {
|
||||
...getPlugins(select),
|
||||
maxOptions: undefined,
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export function initColorSelects(): void {
|
||||
}
|
||||
|
||||
for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
|
||||
new TomSelect(select, {
|
||||
new NetBoxTomSelect(select, {
|
||||
...getPlugins(select),
|
||||
maxOptions: undefined,
|
||||
render: {
|
||||
|
||||
@@ -112,7 +112,7 @@ img.plugin-icon {
|
||||
}
|
||||
|
||||
|
||||
body[data-bs-theme=dark] {
|
||||
html[data-bs-theme=dark] {
|
||||
// Assuming icon is black/white line art, invert it and tone down brightness
|
||||
img.plugin-icon {
|
||||
filter: grayscale(100%) invert(100%) brightness(80%);
|
||||
|
||||
@@ -93,7 +93,7 @@ pre {
|
||||
}
|
||||
|
||||
// Dark mode overrides
|
||||
body[data-bs-theme=dark] {
|
||||
html[data-bs-theme=dark] {
|
||||
// Override background color alpha value
|
||||
::selection {
|
||||
background-color: rgba(var(--tblr-primary-rgb),.48);
|
||||
@@ -174,16 +174,11 @@ pre code {
|
||||
}
|
||||
|
||||
// Theme-based visibility utilities
|
||||
// Tabler's .hide-theme-* utilities expect data-bs-theme on :root, but NetBox applies
|
||||
// it to body. These overrides use higher specificity selectors to ensure theme-based
|
||||
// visibility works correctly. The :root:not(.dummy) pattern provides the additional
|
||||
// specificity needed to override Tabler's :root:not() rules.
|
||||
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-light,
|
||||
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-dark {
|
||||
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
|
||||
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-light,
|
||||
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-dark {
|
||||
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-light,
|
||||
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-dark {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@
|
||||
}
|
||||
|
||||
// Light theme styling
|
||||
body[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
|
||||
html[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
|
||||
// Background Gradient
|
||||
background: linear-gradient(180deg, rgba(0, 133, 125, 0.00) 0%, rgba(0, 133, 125, 0.10) 100%), #FFF;
|
||||
}
|
||||
|
||||
// Dark theme styling
|
||||
body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
|
||||
html[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
|
||||
|
||||
// Background Gradient
|
||||
background: linear-gradient(180deg, rgba(0, 242, 212, 0.00) 0%, rgba(0, 242, 212, 0.10) 100%), #001423;
|
||||
|
||||
@@ -59,7 +59,7 @@ table th.orderable a {
|
||||
color: var(--#{$prefix}body-color);
|
||||
}
|
||||
|
||||
body[data-bs-theme=dark] {
|
||||
html[data-bs-theme=dark] {
|
||||
// Adjust table header background color
|
||||
.table thead th, .markdown>table thead th {
|
||||
background: $rich-black !important;
|
||||
|
||||
+123
-93
@@ -210,7 +210,7 @@
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
"@eslint/eslintrc@^3.3.1", "@eslint/eslintrc@^3.3.3":
|
||||
"@eslint/eslintrc@^3.3.1":
|
||||
version "3.3.3"
|
||||
resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz"
|
||||
integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==
|
||||
@@ -225,6 +225,21 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/eslintrc@^3.3.4":
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.4.tgz#e402b1920f7c1f5a15342caa432b1348cacbb641"
|
||||
integrity sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==
|
||||
dependencies:
|
||||
ajv "^6.14.0"
|
||||
debug "^4.3.2"
|
||||
espree "^10.0.1"
|
||||
globals "^14.0.0"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.2.1"
|
||||
js-yaml "^4.1.1"
|
||||
minimatch "^3.1.3"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.39.2", "@eslint/js@^9.39.2":
|
||||
version "9.39.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599"
|
||||
@@ -935,100 +950,100 @@
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
|
||||
integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
|
||||
"@typescript-eslint/eslint-plugin@^8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz#b1ce606d87221daec571e293009675992f0aae76"
|
||||
integrity sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/type-utils" "8.56.0"
|
||||
"@typescript-eslint/utils" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.1"
|
||||
"@typescript-eslint/type-utils" "8.56.1"
|
||||
"@typescript-eslint/utils" "8.56.1"
|
||||
"@typescript-eslint/visitor-keys" "8.56.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
|
||||
integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
|
||||
"@typescript-eslint/parser@^8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.1.tgz#21d13b3d456ffb08614c1d68bb9a4f8d9237cdc7"
|
||||
integrity sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.1"
|
||||
"@typescript-eslint/types" "8.56.1"
|
||||
"@typescript-eslint/typescript-estree" "8.56.1"
|
||||
"@typescript-eslint/visitor-keys" "8.56.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
|
||||
integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
|
||||
"@typescript-eslint/project-service@8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.1.tgz#65c8d645f028b927bfc4928593b54e2ecd809244"
|
||||
integrity sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.56.0"
|
||||
"@typescript-eslint/types" "^8.56.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.56.1"
|
||||
"@typescript-eslint/types" "^8.56.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
|
||||
integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
|
||||
"@typescript-eslint/scope-manager@8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz#254df93b5789a871351335dd23e20bc164060f24"
|
||||
integrity sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.1"
|
||||
"@typescript-eslint/visitor-keys" "8.56.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
|
||||
integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
|
||||
"@typescript-eslint/tsconfig-utils@8.56.1", "@typescript-eslint/tsconfig-utils@^8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz#1afa830b0fada5865ddcabdc993b790114a879b7"
|
||||
integrity sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==
|
||||
|
||||
"@typescript-eslint/type-utils@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
|
||||
integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
|
||||
"@typescript-eslint/type-utils@8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz#7a6c4fabf225d674644931e004302cbbdd2f2e24"
|
||||
integrity sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/utils" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.1"
|
||||
"@typescript-eslint/typescript-estree" "8.56.1"
|
||||
"@typescript-eslint/utils" "8.56.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
|
||||
integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
|
||||
"@typescript-eslint/types@8.56.1", "@typescript-eslint/types@^8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.1.tgz#975e5942bf54895291337c91b9191f6eb0632ab9"
|
||||
integrity sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
|
||||
integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
|
||||
"@typescript-eslint/typescript-estree@8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz#3b9e57d8129a860c50864c42188f761bdef3eab0"
|
||||
integrity sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.56.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
"@typescript-eslint/project-service" "8.56.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.56.1"
|
||||
"@typescript-eslint/types" "8.56.1"
|
||||
"@typescript-eslint/visitor-keys" "8.56.1"
|
||||
debug "^4.4.3"
|
||||
minimatch "^9.0.5"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/utils@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
|
||||
integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
|
||||
"@typescript-eslint/utils@8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.1.tgz#5a86acaf9f1b4c4a85a42effb217f73059f6deb7"
|
||||
integrity sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.1"
|
||||
"@typescript-eslint/types" "8.56.1"
|
||||
"@typescript-eslint/typescript-estree" "8.56.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
|
||||
integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
|
||||
"@typescript-eslint/visitor-keys@8.56.1":
|
||||
version "8.56.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz#50e03475c33a42d123dc99e63acf1841c0231f87"
|
||||
integrity sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
|
||||
@@ -1148,6 +1163,16 @@ ajv@^6.12.4:
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ajv@^6.14.0:
|
||||
version "6.14.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a"
|
||||
integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.1"
|
||||
fast-json-stable-stringify "^2.0.0"
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ansi-styles@^4.1.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
|
||||
@@ -1274,6 +1299,11 @@ balanced-match@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
balanced-match@^4.0.2:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||
|
||||
bootstrap@5.3.7:
|
||||
version "5.3.7"
|
||||
resolved "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz"
|
||||
@@ -1292,12 +1322,12 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz"
|
||||
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
|
||||
brace-expansion@^5.0.2:
|
||||
version "5.0.4"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336"
|
||||
integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
braces@^3.0.3:
|
||||
version "3.0.3"
|
||||
@@ -2189,10 +2219,10 @@ globals@^14.0.0:
|
||||
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@^17.3.0:
|
||||
version "17.3.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9"
|
||||
integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==
|
||||
globals@^17.4.0:
|
||||
version "17.4.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.4.0.tgz#33d7d297ed1536b388a0e2f4bcd0ff19c8ff91b5"
|
||||
integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==
|
||||
|
||||
globalthis@^1.0.3, globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
@@ -2749,10 +2779,10 @@ loose-envify@^1.1.0:
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
markdown-it@^14.1.0:
|
||||
version "14.1.0"
|
||||
resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz"
|
||||
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
|
||||
markdown-it@^14.1.0, markdown-it@^14.1.1:
|
||||
version "14.1.1"
|
||||
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.1.tgz#856f90b66fc39ae70affd25c1b18b581d7deee1f"
|
||||
integrity sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
entities "^4.4.0"
|
||||
@@ -2784,20 +2814,20 @@ micromatch@^4.0.5:
|
||||
braces "^3.0.3"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
minimatch@^10.2.2:
|
||||
version "10.2.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
|
||||
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.2"
|
||||
|
||||
minimatch@^3.1.2, minimatch@^3.1.3:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
|
||||
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^9.0.5:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz"
|
||||
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimist@^1.2.0, minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
|
||||
@@ -3455,10 +3485,10 @@ toggle-selection@^1.0.6:
|
||||
resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
|
||||
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
|
||||
|
||||
tom-select@2.4.3:
|
||||
version "2.4.3"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
|
||||
integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
|
||||
tom-select@2.5.2:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.5.2.tgz#77dd4bc780b1ea72905337b24f04ce19dc6d2ca1"
|
||||
integrity sha512-VAlGj5MBWVLMJje2NwA3XSmxa7CUFpp1tdzFZ8wymCkcLeP0NwF4ARmSuUK4BWbmSN1fETlSazWkMIxEpP4GdQ==
|
||||
dependencies:
|
||||
"@orchidjs/sifter" "^1.1.0"
|
||||
"@orchidjs/unicode-variants" "^1.1.2"
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version: "4.5.3"
|
||||
version: "4.5.4"
|
||||
edition: "Community"
|
||||
published: "2026-02-17"
|
||||
published: "2026-03-03"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card table-responsive">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -15,67 +15,3 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock extra_controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Device Role" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent" %}</th>
|
||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Color" %}</th>
|
||||
<td>
|
||||
<span class="badge color-label" style="background-color: #{{ object.color }}"> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VM Role" %}</th>
|
||||
<td>{% checkmark object.vm_role %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Config Template" %}</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Child Device Roles" %}
|
||||
{% if perms.dcim.add_devicerole %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'dcim:devicerole_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device Role" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'dcim:devicerole_list' parent_id=object.pk %}
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -86,6 +86,11 @@
|
||||
<th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
|
||||
<td>{{ object.qinq_svlan|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
{% elif object.mode %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Untagged VLAN" %}</th>
|
||||
<td>{{ object.untagged_vlan|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Transmit power (dBm)" %}</th>
|
||||
@@ -411,7 +416,10 @@
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "VLANs" %}</h2>
|
||||
{% htmx_table 'ipam:vlan_list' interface_id=object.pk %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if object.is_lag %}
|
||||
|
||||
@@ -46,75 +46,3 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Device" %}</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Device Type" %}</th>
|
||||
<td>{{ object.device.device_type|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Module Bay" %}</th>
|
||||
<td>{% nested_tree object.module_bay %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Serial Number" %}</th>
|
||||
<td class="font-monospace">{{ object.serial|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Asset Tag" %}</th>
|
||||
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.module_type.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model" %}</th>
|
||||
<td>{{ object.module_type|linkify }}</td>
|
||||
</tr>
|
||||
{% for k, v in object.module_type.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>{{ v|placeholder }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
|
||||
@@ -14,92 +11,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% include 'dcim/inc/moduletype_buttons.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Profile" %}</th>
|
||||
<td>{{ object.profile|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model Name" %}</th>
|
||||
<td>{{ object.model }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Part Number" %}</th>
|
||||
<td>{{ object.part_number|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Airflow" %}</th>
|
||||
<td>{{ object.get_airflow_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>
|
||||
{% if object.weight %}
|
||||
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Attributes" %}</h2>
|
||||
{% if not object.profile %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "No profile assigned" %}
|
||||
</div>
|
||||
{% elif object.attributes %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for k, v in object.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>
|
||||
{% if v is True or v is False %}
|
||||
{% checkmark v %}
|
||||
{% else %}
|
||||
{{ v|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "None" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'dcim/inc/moduletype_buttons.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.module_type.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model" %}</th>
|
||||
<td>{{ object.module_type|linkify }}</td>
|
||||
</tr>
|
||||
{% for k, v in object.module_type.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>
|
||||
{% if v is True or v is False %}
|
||||
{% checkmark v %}
|
||||
{% else %}
|
||||
{{ v|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
{% if not object.profile %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "No profile assigned" %}
|
||||
</div>
|
||||
{% elif object.attributes %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for k, v in object.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>
|
||||
{% if v is True or v is False %}
|
||||
{% checkmark v %}
|
||||
{% else %}
|
||||
{{ v|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "None" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock panel_content %}
|
||||
@@ -18,61 +18,3 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock extra_controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Platform" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent" %}</th>
|
||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.manufacturer|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Config Template" %}</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Child Platforms" %}
|
||||
{% if perms.dcim.add_platform %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'dcim:platform_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Platform" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'dcim:platform_list' parent_id=object.pk %}
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block controls %}
|
||||
{% add_button model %}
|
||||
{% if perms.core.add_managedfile and perms.extras.add_scriptmodule %}
|
||||
{% add_button model %}
|
||||
{% endif %}
|
||||
{% endblock controls %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -92,7 +92,7 @@ Context:
|
||||
|
||||
<div class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
|
||||
<input type="hidden" id="object-list-return-url" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
|
||||
|
||||
{# Warn of any missing prerequisite objects #}
|
||||
{% if prerequisite_model %}
|
||||
|
||||
@@ -32,4 +32,9 @@
|
||||
{% action_buttons actions model multi=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Update the return_url to reflect any changed query parameters (e.g. per_page) #}
|
||||
{% if not table.embedded %}
|
||||
<input type="hidden" id="object-list-return-url" name="return_url" value="{{ request.get_full_path }}" hx-swap-oob="outerHTML:#object-list-return-url" />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,62 +1 @@
|
||||
{% extends 'ipam/aggregate/base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Aggregate" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Family" %}</th>
|
||||
<td>IPv{{ object.family }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "RIR" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'ipam:aggregate_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Utilization" %}</th>
|
||||
<td>
|
||||
{% utilization_graph object.get_utilization %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Date Added" %}</th>
|
||||
<td>{{ object.date_added|isodate|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load helpers %}
|
||||
{% utilization_graph object.get_utilization %}
|
||||
@@ -1,8 +1,4 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
@@ -12,51 +8,3 @@
|
||||
<li class="breadcrumb-item"><a href="{% url 'ipam:asn_list' %}?range_id={{ object.range.pk }}">{{ object.range }}</a></li>
|
||||
{% endif %}
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "ASN" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "AS Number" %}</th>
|
||||
<td>{{ object.asn_with_asdot }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "RIR" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'ipam:asn_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,57 +1 @@
|
||||
{% extends 'ipam/asnrange/base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "ASN Range" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "RIR" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'ipam:asnrange_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Range" %}</th>
|
||||
<td>{{ object.range_as_string_with_asdot }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load helpers i18n %}
|
||||
{% if value %}{{ value|linkify }}{% else %}<span>{% trans "Global" %}</span>{% endif %}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load helpers i18n %}
|
||||
{% if value %}{{ value|linkify }} ({{ value.rd }}){% else %}<span>{% trans "Global" %}</span>{% endif %}
|
||||
@@ -1,7 +1,4 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{# Omit assigned IP addresses from object representation #}
|
||||
@@ -11,75 +8,3 @@
|
||||
{{ block.super }}
|
||||
<li class="breadcrumb-item"><a href="{% url 'ipam:fhrpgroup_list' %}?protocol={{ object.protocol }}">{{ object.get_protocol_display }}</a></li>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "FHRP Group" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Protocol" %}</th>
|
||||
<td>{{ object.get_protocol_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group ID" %}</th>
|
||||
<td>{{ object.group_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Members" %}</th>
|
||||
<td>{{ member_count }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Authentication" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Authentication Type" %}</th>
|
||||
<td>{{ object.get_auth_type_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Authentication Key" %}</th>
|
||||
<td>{{ object.auth_key|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Virtual IP Addresses" %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?fhrpgroup={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add IP Address" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'ipam:ipaddress_list' fhrpgroup_id=object.pk %}
|
||||
</div>
|
||||
{% include 'inc/panel_table.html' with table=members_table heading='Members' %}
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,129 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-4">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "IP Address" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Family" %}</th>
|
||||
<td>IPv{{ object.family }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VRF" %}</th>
|
||||
<td>
|
||||
{% if object.vrf %}
|
||||
<a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
|
||||
{% else %}
|
||||
<span>{% trans "Global" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Role" %}</th>
|
||||
<td>
|
||||
{% if object.role %}
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "DNS Name" %}</th>
|
||||
<td>{{ object.dns_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Assignment" %}</th>
|
||||
<td>
|
||||
{% if object.assigned_object %}
|
||||
{% if object.assigned_object.parent_object %}
|
||||
{{ object.assigned_object.parent_object|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.assigned_object|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "NAT (inside)" %}</th>
|
||||
<td>
|
||||
{% if object.nat_inside %}
|
||||
{{ object.nat_inside|linkify }}
|
||||
{% if object.nat_inside.assigned_object %}
|
||||
({{ object.nat_inside.assigned_object.parent_object|linkify }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "NAT (outside)" %}</th>
|
||||
<td>
|
||||
{% for ip in object.nat_outside.all %}
|
||||
{{ ip|linkify }}
|
||||
{% if ip.assigned_object %}
|
||||
({{ ip.assigned_object.parent_object|linkify }})
|
||||
{% endif %}<br/>
|
||||
{% empty %}
|
||||
{{ ''|placeholder }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Primary IP</th>
|
||||
<td>{% checkmark object.is_primary_ip %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">OOB IP</th>
|
||||
<td>{% checkmark object.is_oob_ip %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-8">
|
||||
{% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
|
||||
{% if duplicate_ips_table.rows %}
|
||||
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Application Services" %}</h2>
|
||||
{% htmx_table 'ipam:service_list' ip_address_id=object.pk %}
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends 'ipam/ipaddress/base.html' %}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load helpers %}
|
||||
{% if value.parent_object %}{{ value.parent_object|linkify }} / {% endif %}{{ value|linkify }}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load helpers %}
|
||||
{{ value|linkify }}{% if value.assigned_object %} ({{ value.assigned_object.parent_object|linkify }}){% endif %}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load helpers %}
|
||||
{% for ip in value.all %}{{ ip|linkify }}{% if ip.assigned_object %} ({{ ip.assigned_object.parent_object|linkify }}){% endif %}<br/>{% empty %}<span class="text-muted">—</span>{% endfor %}
|
||||
@@ -1,98 +1 @@
|
||||
{% extends 'ipam/iprange/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "IP Range" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Family" %}</th>
|
||||
<td>IPv{{ object.family }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Starting Address" %}</th>
|
||||
<td>{{ object.start_address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Ending Address" %}</th>
|
||||
<td>{{ object.end_address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Size" %}</th>
|
||||
<td>{{ object.size }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Marked Populated" %}</th>
|
||||
<td>{% checkmark object.mark_populated %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Marked Utilized" %}</th>
|
||||
<td>{% checkmark object.mark_utilized %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Utilization" %}</th>
|
||||
<td>
|
||||
{% if object.mark_utilized %}
|
||||
{% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
|
||||
{% else %}
|
||||
{% utilization_graph object.utilization %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VRF" %}</th>
|
||||
<td>
|
||||
{% if object.vrf %}
|
||||
{{ object.vrf|linkify }} ({{ object.vrf.rd }})
|
||||
{% else %}
|
||||
<span>{% trans "Global" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Role" %}</th>
|
||||
<td>{{ object.role|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{% load helpers %}
|
||||
{% if object.mark_utilized %}
|
||||
{% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
|
||||
{% else %}
|
||||
{% utilization_graph value %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% load helpers i18n %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Prefix" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Family" %}</th>
|
||||
<td>IPv{{ object.family }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VRF" %}</th>
|
||||
<td>
|
||||
{% if object.vrf %}
|
||||
{{ object.vrf|linkify }}
|
||||
{% else %}
|
||||
<span>{% trans "Global" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>
|
||||
{% if object.tenant.group %}
|
||||
{{ object.tenant.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.tenant|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Aggregate" %}</th>
|
||||
<td>
|
||||
{% if aggregate %}
|
||||
{{ aggregate|linkify }} ({{ aggregate.rir }})
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Scope" %}</th>
|
||||
{% if object.scope %}
|
||||
<td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
|
||||
{% else %}
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN" %}</th>
|
||||
<td>
|
||||
{% if object.vlan %}
|
||||
{% if object.vlan.group %}
|
||||
{{ object.vlan.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.vlan|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Role" %}</th>
|
||||
<td>{{ object.role|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Is a pool" %}</th>
|
||||
<td>{% checkmark object.is_pool %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user