Compare commits

..

4 Commits

Author SHA1 Message Date
Arthur
30350ff996 fixes 2026-02-13 12:52:59 -08:00
Arthur
bb90b654cd fixes 2026-02-12 13:47:33 -08:00
Arthur
fbd74d3b2c fixes 2026-02-12 13:36:50 -08:00
Arthur
a2f31b1094 #21364 update swagger endpoint for /api/extras/scripts/ 2026-02-12 13:26:49 -08:00
878 changed files with 98321 additions and 93848 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.5.8 placeholder: v4.5.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

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

View File

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

View File

@@ -53,22 +53,15 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v4
- name: Check Python linting & PEP8 compliance
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
with:
version: "0.15.10"
args: "check --output-format=github"
src: "netbox/"
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@@ -76,7 +69,7 @@ jobs:
run: npm install -g yarn run: npm install -g yarn
- name: Setup Node.js with Yarn Caching - name: Setup Node.js with Yarn Caching
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: yarn cache: yarn
@@ -89,7 +82,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install coverage tblib pip install ruff coverage tblib
- name: Build documentation - name: Build documentation
run: mkdocs build run: mkdocs build
@@ -100,6 +93,9 @@ jobs:
- name: Check for missing migrations - name: Check for missing migrations
run: python netbox/manage.py makemigrations --check run: python netbox/manage.py makemigrations --check
- name: Check PEP8 compliance
run: ruff check netbox/
- name: Check UI ESLint, TypeScript, and Prettier Compliance - name: Check UI ESLint, TypeScript, and Prettier Compliance
run: yarn --cwd netbox/project-static validate run: yarn --cwd netbox/project-static validate

View File

@@ -1,37 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
jobs:
claude-review:
# 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:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
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'
plugins: 'code-review@claude-code-plugins'
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

View File

@@ -1,80 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
# Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
# (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
# Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
- name: Configure git remote for fork PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine PR number based on event type
if [ "${{ github.event_name }}" = "issue_comment" ]; then
PR_NUMBER="${{ github.event.issue.number }}"
elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
exit 0 # issues event — no PR branch to worry about
fi
# Fetch fork info in one API call; silently skip if this is not a PR
PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
if [ -z "$PR_INFO" ]; then
exit 0
fi
IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
if [ "$IS_FORK" = "true" ]; then
FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
fi
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# 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
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -15,7 +15,7 @@ jobs:
if: github.repository == 'netbox-community/netbox' if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@v9
with: with:
close-issue-message: > close-issue-message: >
This issue is being closed as no further information has been provided. If This issue is being closed as no further information has been provided. If

View File

@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox' if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@v9
with: with:
# General parameters # General parameters
operations-per-run: 200 operations-per-run: 200

View File

@@ -27,16 +27,16 @@ jobs:
build-mode: none build-mode: none
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 uses: github/codeql-action/init@v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 uses: github/codeql-action/analyze@v4
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@@ -11,14 +11,14 @@ permissions:
pull-requests: write pull-requests: write
discussions: write discussions: write
concurrency:
group: lock-threads
jobs: jobs:
lock: lock:
if: github.repository == 'netbox-community/netbox' if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with: with:
issue-inactive-days: 90
pr-inactive-days: 30
discussion-inactive-days: 180 discussion-inactive-days: 180
issue-lock-reason: 'resolved'

View File

@@ -20,19 +20,19 @@ jobs:
steps: steps:
- name: Create app token - name: Create app token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 uses: actions/create-github-app-token@v1
id: app-token id: app-token
with: with:
app-id: 1076524 app-id: 1076524
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }} private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo - name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v4
with: with:
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Set up Python - name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: 3.12 python-version: 3.12
@@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }} run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes - name: Commit changes
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0 uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with: with:
add: 'netbox/translations/' add: 'netbox/translations/'
default_author: github_actions default_author: github_actions

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.2 rev: v0.14.1
hooks: hooks:
- id: ruff - id: ruff
name: "Ruff linter" name: "Ruff linter"

View File

@@ -1,87 +0,0 @@
# NetBox
Network source-of-truth and infrastructure resource modeling (IRM) tool combining DCIM and IPAM. Built on Django + PostgreSQL + Redis.
## Tech Stack
- Python 3.12+ / Django / Django REST Framework
- PostgreSQL (required), Redis (required for caching/queuing)
- GraphQL via Strawberry, background jobs via RQ
- Docs: MkDocs (in `docs/`)
## Repository Layout
- `netbox/` — Django project root; run all `manage.py` commands from here
- `netbox/netbox/` — Core settings, URLs, WSGI entrypoint
- `netbox/<app>/` — Django apps: `circuits`, `core`, `dcim`, `ipam`, `extras`, `tenancy`, `virtualization`, `wireless`, `users`, `vpn`
- `docs/` — MkDocs documentation source
- `contrib/` — Example configs (systemd, nginx, etc.) and other resources
## Development Setup
```bash
python -m venv ~/.venv/netbox
source ~/.venv/netbox/bin/activate
pip install -r requirements.txt
# Copy and configure
cp netbox/netbox/configuration.example.py netbox/netbox/configuration.py
# Edit configuration.py: set DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS
cd netbox/
python manage.py migrate
python manage.py runserver
```
## Key Commands
All commands run from the `netbox/` subdirectory with venv active.
```bash
# Development server
python manage.py runserver
# Run full test suite
export NETBOX_CONFIGURATION=netbox.configuration_testing
python manage.py test
# Faster test runs (no DB rebuild, parallel)
python manage.py test --keepdb --parallel 4
# Migrations
python manage.py makemigrations
python manage.py migrate
# Shell
python manage.py nbshell # NetBox-enhanced shell
```
## Architecture Conventions
- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
- **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
- **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
- **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
- **Templates**: Django templates in `netbox/templates/<app>/`.
- **Tests**: Mirror the app structure in `<app>/tests/`. Use `netbox.configuration_testing` for test config.
## Coding Standards
- Follow existing Django conventions; don't reinvent patterns already present in the codebase.
- New models must include `created`, `last_updated` fields (inherit from `NetBoxModel` where appropriate).
- Every model exposed in the UI needs: model, serializer, filterset, form, table, views, URL route, and tests.
- API serializers must include a `url` field (absolute URL of the object).
- Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
- Avoid adding new dependencies without strong justification.
- Avoid running `ruff format` on existing files, as this tends to introduce unnecessary style changes.
- Don't craft Django database migrations manually: Prompt the user to run `manage.py makemigrations` instead.
## Branch & PR Conventions
- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
- Use the `main` branch for patch releases; `feature` tracks work for the upcoming minor/major release.
- Every PR must reference an approved GitHub issue.
- PRs must include tests for new functionality.
## Gotchas
- `configuration.py` is gitignored — never commit it.
- `manage.py` lives in `netbox/`, NOT the repo root. Running from the wrong directory is a common mistake.
- `NETBOX_CONFIGURATION` env var controls which settings module loads; set to `netbox.configuration_testing` for tests.
- The `extras` app is a catch-all for cross-cutting features (custom fields, tags, webhooks, scripts).
- Plugins API: only documented public APIs are stable. Internal NetBox code is subject to change without notice.
- See `docs/development/` for the full contributing guide and code style details.

View File

@@ -84,8 +84,6 @@ 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. * 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.) * 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.) * 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.)
@@ -98,10 +96,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
greater than 80 characters in length greater than 80 characters in length
> [!CAUTION] > [!CAUTION]
> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human. > Any contributions which include AI-generated or reproduced content will be rejected.
* Some other tips to keep in mind: * 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. (GitHub allows only people who have commented on an issue to be assigned as its owner.) * 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.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment. * 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. * All new functionality must include relevant tests where applicable.

View File

@@ -27,7 +27,9 @@ django-graphiql-debug-toolbar
django-htmx django-htmx
# Modified Preorder Tree Traversal (recursive nesting of objects) # Modified Preorder Tree Traversal (recursive nesting of objects)
django-mptt # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
# v0.18.0 introduces errant migrations which need to be resolved
django-mptt==0.17.0
# Context managers for PostgreSQL advisory locks # Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
@@ -55,8 +57,7 @@ django-storages
# Abstraction models for rendering and paginating HTML tables # Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
# See #21902 for upgrading to django-tables2 v2.9+ django-tables2
django-tables2<2.9
# User-defined tags for objects # User-defined tags for objects
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
@@ -99,10 +100,6 @@ jsonschema
# https://python-markdown.github.io/changelog/ # https://python-markdown.github.io/changelog/
Markdown Markdown
# MkDocs
# https://github.com/mkdocs/mkdocs/releases
mkdocs<2.0
# MkDocs Material theme (for documentation build) # MkDocs Material theme (for documentation build)
# https://squidfunk.github.io/mkdocs-material/changelog/ # https://squidfunk.github.io/mkdocs-material/changelog/
mkdocs-material mkdocs-material

View File

@@ -349,7 +349,6 @@
"5gbase-t", "5gbase-t",
"10gbase-br-d", "10gbase-br-d",
"10gbase-br-u", "10gbase-br-u",
"10gbase-cu",
"10gbase-cx4", "10gbase-cx4",
"10gbase-er", "10gbase-er",
"10gbase-lr", "10gbase-lr",
@@ -368,7 +367,6 @@
"40gbase-fr4", "40gbase-fr4",
"40gbase-lr4", "40gbase-lr4",
"40gbase-sr4", "40gbase-sr4",
"40gbase-sr4-bd",
"50gbase-cr", "50gbase-cr",
"50gbase-er", "50gbase-er",
"50gbase-fr", "50gbase-fr",
@@ -416,13 +414,9 @@
"800gbase-dr8", "800gbase-dr8",
"800gbase-sr8", "800gbase-sr8",
"800gbase-vr8", "800gbase-vr8",
"1.6tbase-cr8",
"1.6tbase-dr8",
"1.6tbase-dr8-2",
"100base-x-sfp", "100base-x-sfp",
"1000base-x-gbic", "1000base-x-gbic",
"1000base-x-sfp", "1000base-x-sfp",
"2.5gbase-x-sfp",
"10gbase-x-sfpp", "10gbase-x-sfpp",
"10gbase-x-xenpak", "10gbase-x-xenpak",
"10gbase-x-xfp", "10gbase-x-xfp",
@@ -452,9 +446,6 @@
"400gbase-x-osfp-rhs", "400gbase-x-osfp-rhs",
"800gbase-x-osfp", "800gbase-x-osfp",
"800gbase-x-qsfpdd", "800gbase-x-qsfpdd",
"1.6tbase-x-osfp1600",
"1.6tbase-x-osfp1600-rhs",
"1.6tbase-x-qsfpdd1600",
"1000base-kx", "1000base-kx",
"2.5gbase-kx", "2.5gbase-kx",
"5gbase-kr", "5gbase-kr",
@@ -466,7 +457,6 @@
"100gbase-kp4", "100gbase-kp4",
"100gbase-kr2", "100gbase-kr2",
"100gbase-kr4", "100gbase-kr4",
"1.6tbase-kr8",
"ieee802.11a", "ieee802.11a",
"ieee802.11g", "ieee802.11g",
"ieee802.11n", "ieee802.11n",

File diff suppressed because one or more lines are too long

View File

@@ -220,14 +220,6 @@ This parameter defines the URL of the repository that will be checked for new Ne
--- ---
## RQ
Default: `{}` (Empty)
This is a wrapper for passing global configuration parameters to [Django RQ](https://github.com/rq/django-rq) to customize its behavior. It is employed within NetBox primarily to alter conditions during testing.
---
## RQ_DEFAULT_TIMEOUT ## RQ_DEFAULT_TIMEOUT
Default: `300` Default: `300`

View File

@@ -241,49 +241,21 @@ STORAGES = {
Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts. Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts.
If using a remote storage such as S3 or an S3-compatible service, define the configuration as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example: If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example:
```python ```python
STORAGES = { STORAGES = {
'default': { "scripts": {
'BACKEND': 'storages.backends.s3.S3Storage', "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
'OPTIONS': { "OPTIONS": {
'bucket_name': 'netbox', 'access_key': 'access key',
'access_key': 'access key',
'secret_key': 'secret key', 'secret_key': 'secret key',
'region_name': 'us-east-1', "allow_overwrite": True,
'endpoint_url': 'https://s3.example.com', }
'location': 'media/', },
},
},
'staticfiles': {
'BACKEND': 'storages.backends.s3.S3Storage',
'OPTIONS': {
'bucket_name': 'netbox',
'access_key': 'access key',
'secret_key': 'secret key',
'region_name': 'us-east-1',
'endpoint_url': 'https://s3.example.com',
'location': 'static/',
},
},
'scripts': {
'BACKEND': 'storages.backends.s3.S3Storage',
'OPTIONS': {
'bucket_name': 'netbox',
'access_key': 'access key',
'secret_key': 'secret key',
'region_name': 'us-east-1',
'endpoint_url': 'https://s3.example.com',
'location': 'scripts/',
'file_overwrite': True,
},
},
} }
``` ```
`bucket_name` is required for `S3Storage`. When using an S3-compatible service, set `region_name` and `endpoint_url` according to your provider.
The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html). The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html).
!!! note !!! note
@@ -307,7 +279,6 @@ STORAGES = {
'bucket_name': os.environ.get('AWS_STORAGE_BUCKET_NAME'), 'bucket_name': os.environ.get('AWS_STORAGE_BUCKET_NAME'),
'access_key': os.environ.get('AWS_S3_ACCESS_KEY_ID'), 'access_key': os.environ.get('AWS_S3_ACCESS_KEY_ID'),
'secret_key': os.environ.get('AWS_S3_SECRET_ACCESS_KEY'), 'secret_key': os.environ.get('AWS_S3_SECRET_ACCESS_KEY'),
'region_name': os.environ.get('AWS_S3_REGION_NAME'),
'endpoint_url': os.environ.get('AWS_S3_ENDPOINT_URL'), 'endpoint_url': os.environ.get('AWS_S3_ENDPOINT_URL'),
'location': 'media/', 'location': 'media/',
} }
@@ -318,7 +289,6 @@ STORAGES = {
'bucket_name': os.environ.get('AWS_STORAGE_BUCKET_NAME'), 'bucket_name': os.environ.get('AWS_STORAGE_BUCKET_NAME'),
'access_key': os.environ.get('AWS_S3_ACCESS_KEY_ID'), 'access_key': os.environ.get('AWS_S3_ACCESS_KEY_ID'),
'secret_key': os.environ.get('AWS_S3_SECRET_ACCESS_KEY'), 'secret_key': os.environ.get('AWS_S3_SECRET_ACCESS_KEY'),
'region_name': os.environ.get('AWS_S3_REGION_NAME'),
'endpoint_url': os.environ.get('AWS_S3_ENDPOINT_URL'), 'endpoint_url': os.environ.get('AWS_S3_ENDPOINT_URL'),
'location': 'static/', 'location': 'static/',
} }

View File

@@ -18,8 +18,7 @@ They can also be used as a mechanism for validating the integrity of data within
Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish. Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish.
!!! danger "Only install trusted scripts" !!! danger "Only install trusted scripts"
Custom scripts have unrestricted access to change anything in the database and are inherently unsafe and should only be installed and run from trusted sources. You should also review and set permissions for who can run scripts if the script can modify any data. Custom scripts have unrestricted access to change anything in the databse and are inherently unsafe and should only be installed and run from trusted sources. You should also review and set permissions for who can run scripts if the script can modify any data.
## Writing Custom Scripts ## Writing Custom Scripts
@@ -215,7 +214,6 @@ if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot() obj.snapshot()
obj.property = "New Value" obj.property = "New Value"
obj._changelog_message = 'Example Message Text' # Optional
obj.full_clean() obj.full_clean()
obj.save() obj.save()
``` ```
@@ -384,18 +382,6 @@ A calendar date. Returns a `datetime.date` object.
A complete date & time. Returns a `datetime.datetime` object. A complete date & time. Returns a `datetime.datetime` object.
## Uploading Scripts via the API
Script modules can be uploaded to NetBox via the REST API by sending a `multipart/form-data` POST request to `/api/extras/scripts/upload/`. The caller must have the `extras.add_scriptmodule` and `core.add_managedfile` permissions.
```no-highlight
curl -X POST \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-F "file=@/path/to/myscript.py" \
http://netbox/api/extras/scripts/upload/
```
## Running Custom Scripts ## Running Custom Scripts
!!! note !!! note

View File

@@ -168,14 +168,6 @@ Update the static OpenAPI schema definition at `contrib/openapi.json` with the m
./manage.py spectacular --format openapi-json > ../contrib/openapi.json ./manage.py spectacular --format openapi-json > ../contrib/openapi.json
``` ```
### Update Development Dependencies
Keep development tooling versions consistent across the project. If you upgrade a dev-only dependency, update all places where its pinned so local tooling and CI run the same versions.
* Ruff:
* `.pre-commit-config.yaml`
* `.github/workflows/ci.yml`
### Submit a Pull Request ### Submit a Pull Request
Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** to merge the current release branch (e.g. `release-vX.Y.Z`) into `main`. Copy the documented release notes into the pull request's body. Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** to merge the current release branch (e.g. `release-vX.Y.Z`) into `main`. Copy the documented release notes into the pull request's body.

View File

@@ -34,8 +34,7 @@ The following rules are ignored when linting.
##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long ##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long
NetBox enforces a maximum line length of 120 characters for Python code using Ruff (E501). NetBox does not enforce a hard restriction on line length, although a maximum length of 120 characters is strongly encouraged for Python code where possible. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations).
The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations).
##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star ##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star
@@ -48,14 +47,6 @@ Wildcard imports (for example, `from .constants import *`) are acceptable under
The justification for ignoring this rule is the same as F403 above. The justification for ignoring this rule is the same as F403 above.
##### [RET504](https://docs.astral.sh/ruff/rules/unnecessary-assign/): Unnecessary assign
There are multiple instances where it is more readable and clearer to first assign to a variable and then return it.
##### [UP032](https://docs.astral.sh/ruff/rules/f-string/): f-string
For localizable strings, it is necessary to not use the `f-string` syntax, as Django's translation functions (e.g. `gettext_lazy`) require plain string literals.
### Introducing New Dependencies ### Introducing New Dependencies
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks. The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks.

View File

@@ -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: The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`. * `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). * `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. * `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. * `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. * `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,20 +38,18 @@ If no body template is specified, the request body will be populated with a JSON
```json ```json
{ {
"event": "created", "event": "created",
"timestamp": "2026-03-06T15:11:23.503186+00:00", "timestamp": "2021-03-09 17:55:33.968016+00:00",
"object_type": "dcim.site", "model": "site",
"username": "jstretch", "username": "jstretch",
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6", "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"data": { "data": {
"id": 4, "id": 19,
"url": "/api/dcim/sites/4/",
"display_url": "/dcim/sites/4/",
"display": "Site 1",
"name": "Site 1", "name": "Site 1",
"slug": "site-1", "slug": "site-1",
"status": { "status":
"value": "active", "value": "active",
"label": "Active" "label": "Active",
"id": 1
}, },
"region": null, "region": null,
... ...
@@ -59,10 +57,8 @@ If no body template is specified, the request body will be populated with a JSON
"snapshots": { "snapshots": {
"prechange": null, "prechange": null,
"postchange": { "postchange": {
"created": "2026-03-06T15:11:23.484Z", "created": "2021-03-09",
"owner": null, "last_updated": "2021-03-09T17:55:33.851Z",
"description": "",
"comments": "",
"name": "Site 1", "name": "Site 1",
"slug": "site-1", "slug": "site-1",
"status": "active", "status": "active",

View File

@@ -36,16 +36,13 @@ If false, synchronization will be disabled.
### Ignore Rules ### Ignore Rules
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. 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.
| Rule | Description | | Rule | Description |
|-----------------------|------------------------------------------------------| |----------------|------------------------------------------|
| `README` | Ignore any files named `README` | | `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension | | `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` | | `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 ### Sync Interval

View File

@@ -77,14 +77,14 @@ The file path to a particular certificate authority (CA) file to use when valida
## Context Data ## Context Data
The following context variables are available to the text and link templates. The following context variables are available in to the text and link templates.
| Variable | Description | | Variable | Description |
|---------------|------------------------------------------------------| |--------------|----------------------------------------------------|
| `event` | The event type (`created`, `updated`, or `deleted`) | | `event` | The event type (`create`, `update`, or `delete`) |
| `timestamp` | The time at which the event occurred | | `timestamp` | The time at which the event occured |
| `object_type` | The type of object impacted (`app_label.model_name`) | | `model` | The type of object impacted |
| `username` | The name of the user associated with the change | | `username` | The name of the user associated with the change |
| `request_id` | The unique request ID | | `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object | | `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object | | `snapshots` | Pre- and post-change snapshots of the object |

View File

@@ -1,14 +1,12 @@
# Search # Search
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models. Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
```python title="search.py" ```python
# search.py # search.py
from netbox.search import SearchIndex, register_search from netbox.search import SearchIndex
from .models import MyModel from .models import MyModel
@register_search
class MyModelIndex(SearchIndex): class MyModelIndex(SearchIndex):
model = MyModel model = MyModel
fields = ( fields = (
@@ -19,11 +17,15 @@ class MyModelIndex(SearchIndex):
display_attrs = ('site', 'device', 'status', 'description') display_attrs = ('site', 'device', 'status', 'description')
``` ```
Decorate each `SearchIndex` subclass with `@register_search` to register it with NetBox. When using the default `search.py` module, no additional `indexes = [...]` list is required. Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
Fields listed in `display_attrs` are not cached for matching, but they are displayed alongside the object in global search results to provide additional context. To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
```python
indexes = [MyModelIndex]
```
!!! tip !!! tip
The legacy `indexes = [...]` list remains supported via `PluginConfig.search_indexes` for backward compatibility and custom loading patterns. The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
::: netbox.search.SearchIndex ::: netbox.search.SearchIndex

View File

@@ -1,178 +1,5 @@
# NetBox v4.5 # NetBox v4.5
## v4.5.8 (2026-04-14)
### Enhancements
* [#21430](https://github.com/netbox-community/netbox/issues/21430) - Display the device role's color in the device view
* [#21795](https://github.com/netbox-community/netbox/issues/21795) - Update `humanize_speed` template filter to support decimal Gbps/Tbps values
### Bug Fixes
* [#21529](https://github.com/netbox-community/netbox/issues/21529) - Exclude non-existent custom fields from object changelog data returned via the REST API
* [#21542](https://github.com/netbox-community/netbox/issues/21542) - Expand interface speed field to 64-bit integer to prevent overflow for LAG interfaces exceeding ~2.1 Tbps
* [#21704](https://github.com/netbox-community/netbox/issues/21704) - Fix missing port mappings in device type YAML export
* [#21783](https://github.com/netbox-community/netbox/issues/21783) - Fix support for bulk import of cables connected to power feeds
* [#21801](https://github.com/netbox-community/netbox/issues/21801) - Prevent duplicate filename collision when uploading files using S3 storage
* [#21814](https://github.com/netbox-community/netbox/issues/21814) - Fix custom script "last run" time to reflect job start time rather than creation time
* [#21835](https://github.com/netbox-community/netbox/issues/21835) - Correct help text for color selection form fields
* [#21841](https://github.com/netbox-community/netbox/issues/21841) - Restore visibility of the edit button for script modules to non-superusers
* [#21845](https://github.com/netbox-community/netbox/issues/21845) - Fix CSV export of connection columns rendering template whitespace instead of a formatted value
* [#21869](https://github.com/netbox-community/netbox/issues/21869) - Remove redundant `ScriptModule` class synchronization triggered on save
---
## v4.5.7 (2026-04-03)
### Enhancements
* [#21095](https://github.com/netbox-community/netbox/issues/21095) - Adopt IEC unit labels (e.g. GiB) for virtual machine resources
* [#21696](https://github.com/netbox-community/netbox/issues/21696) - Add support for django-rq 4.0 and introduce `RQ` configuration parameter
* [#21701](https://github.com/netbox-community/netbox/issues/21701) - Support uploading custom scripts via the REST API (`/api/extras/scripts/upload/`)
* [#21760](https://github.com/netbox-community/netbox/issues/21760) - Add a 1C2P:2C1P breakout cable profile
### Performance Improvements
* [#21655](https://github.com/netbox-community/netbox/issues/21655) - Optimize queries for object and multi-object type custom fields
### Bug Fixes
* [#20474](https://github.com/netbox-community/netbox/issues/20474) - Fix installation of modules with placeholder values in component names
* [#21498](https://github.com/netbox-community/netbox/issues/21498) - Fix server error triggered by event rules referencing deleted objects
* [#21533](https://github.com/netbox-community/netbox/issues/21533) - Ensure read-only fields are included in REST API responses upon object creation
* [#21535](https://github.com/netbox-community/netbox/issues/21535) - Fix filtering of object-type custom fields when "is empty" is selected
* [#21784](https://github.com/netbox-community/netbox/issues/21784) - Fix `AttributeError` exception when sorting a table as an anonymous user
* [#21808](https://github.com/netbox-community/netbox/issues/21808) - Fix `RelatedObjectDoesNotExist` exception when viewing an interface with a virtual circuit termination
* [#21810](https://github.com/netbox-community/netbox/issues/21810) - Fix `AttributeError` exception when viewing virtual chassis member
* [#21825](https://github.com/netbox-community/netbox/issues/21825) - Fix sorting by broken columns in several object lists
---
## v4.5.6 (2026-03-31)
### Enhancements
* [#21480](https://github.com/netbox-community/netbox/issues/21480) - Add OSFP224 (1.6T) interface type
* [#21727](https://github.com/netbox-community/netbox/issues/21727) - Add 2.5GBASE-X SFP modular interface type
* [#21743](https://github.com/netbox-community/netbox/issues/21743) - Improve object change diff styling and layout
* [#21793](https://github.com/netbox-community/netbox/issues/21793) - Add 50 Gbps, 800 Gbps, and 1.6 Tbps interface speed options
### Bug Fixes
* [#20467](https://github.com/netbox-community/netbox/issues/20467) - Fix resolution of the `{module}` variable for position fields in nested modules
* [#21698](https://github.com/netbox-community/netbox/issues/21698) - Adjust custom field URL filter to support non-standard port numbers
* [#21707](https://github.com/netbox-community/netbox/issues/21707) - Fix grouping of owner fields in provider account add/edit forms
* [#21749](https://github.com/netbox-community/netbox/issues/21749) - Fix `FieldError` exception when sorting the circuit group assignment table by the member column
* [#21763](https://github.com/netbox-community/netbox/issues/21763) - Use separate add/remove form fields when editing a site or provider with a large number of ASNs assigned
---
## v4.5.5 (2026-03-17)
### Enhancements
* [#21114](https://github.com/netbox-community/netbox/issues/21114) - Support path exclusions for data source synchronization
* [#21578](https://github.com/netbox-community/netbox/issues/21578) - Support identifying scope object by name or slug when bulk importing scoped objects
### Performance Improvements
* [#21330](https://github.com/netbox-community/netbox/issues/21330) - Optimize the assignment of tags when saving objects
* [#21402](https://github.com/netbox-community/netbox/issues/21402) - Avoid excessive database queries when rendering unnamed devices via the REST API
* [#21611](https://github.com/netbox-community/netbox/issues/21611) - Replace inefficient calls to `.count()` with `.exists()`
### Bug Fixes
* [#19867](https://github.com/netbox-community/netbox/issues/19867) - Preserve the "per page" pagination setting when returning from object edit forms
* [#20077](https://github.com/netbox-community/netbox/issues/20077) - Fix form field focus bug in Microsoft Edge
* [#20385](https://github.com/netbox-community/netbox/issues/20385) - Enforce `MAX_PAGE_SIZE` limit for GraphQL API requests
* [#20468](https://github.com/netbox-community/netbox/issues/20468) - Fix range-based filter lookups for integer fields in GraphQL API
* [#20915](https://github.com/netbox-community/netbox/issues/20915) - Restore user language preference after login via social authentication
* [#20934](https://github.com/netbox-community/netbox/issues/20934) - Fix dark mode flicker on page load
* [#21012](https://github.com/netbox-community/netbox/issues/21012) - Add pagination for VLAN table on interface view to prevent silent truncation at 100 entries
* [#21380](https://github.com/netbox-community/netbox/issues/21380) - Fix display of the background tasks table on mobile
* [#21440](https://github.com/netbox-community/netbox/issues/21440) - Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
* [#21468](https://github.com/netbox-community/netbox/issues/21468) - Preserve safe custom HTTP headers when copying requests for background job processing
* [#21486](https://github.com/netbox-community/netbox/issues/21486) - Fix `AttributeError` exception caused by missing `COOKIES` attribute on `NetBoxFakeRequest`
* [#21512](https://github.com/netbox-community/netbox/issues/21512) - Fix GraphQL filter field name mismatch for device component types (e.g. `console_ports`)
* [#21531](https://github.com/netbox-community/netbox/issues/21531) - Fix search functionality for location when combined with other filters
* [#21556](https://github.com/netbox-community/netbox/issues/21556) - Avoid clearing the platform field when changing device type in the device edit form
* [#21579](https://github.com/netbox-community/netbox/issues/21579) - Hide the script "Add" button for users lacking the required permission
* [#21580](https://github.com/netbox-community/netbox/issues/21580) - Hide the virtual machine "Add components" dropdown for users lacking change permission
* [#21586](https://github.com/netbox-community/netbox/issues/21586) - Fix broken "Add child group" link in site group view (was pointing to the region endpoint)
* [#21618](https://github.com/netbox-community/netbox/issues/21618) - Fix cable termination points being lost when bulk-editing the cable profile
* [#21651](https://github.com/netbox-community/netbox/issues/21651) - Disable sorting by the `is_primary` column in the MAC address list view
* [#21653](https://github.com/netbox-community/netbox/issues/21653) - Fix profile-based cable tracing when a single origin carries multiple positions
* [#21673](https://github.com/netbox-community/netbox/issues/21673) - Fix display of primary IP address with associated NAT IP on virtual machine view
* [#21686](https://github.com/netbox-community/netbox/issues/21686) - Clean up cached circuit attributes when reassigning a circuit termination
---
## v4.5.4 (2026-03-03)
### Enhancements
* [#21369](https://github.com/netbox-community/netbox/issues/21369) - Support lazy-loading of image attachments
* [#21385](https://github.com/netbox-community/netbox/issues/21385) - Add contact assignment support for virtual circuits
* [#21394](https://github.com/netbox-community/netbox/issues/21394) - Add 10GBASE-CU and 40GBASE-SR4 BiDi interface types
* [#21477](https://github.com/netbox-community/netbox/issues/21477) - Extend GraphQL API filters for cables
### Performance Improvements
* [#21456](https://github.com/netbox-community/netbox/issues/21456) - Improve performance of config context resolution via GraphQL API
* [#21459](https://github.com/netbox-community/netbox/issues/21459) - Avoid prefetching data for hidden table columns
### Bug Fixes
* [#20490](https://github.com/netbox-community/netbox/issues/20490) - Restrict visibility of scripts in list view to users with view permission
* [#20911](https://github.com/netbox-community/netbox/issues/20911) - Sort module bay options alphabetically when installing a module
* [#21347](https://github.com/netbox-community/netbox/issues/21347) - The allocation of IPv6 addresses from a non-pool prefix should start at one, not zero
* [#21429](https://github.com/netbox-community/netbox/issues/21429) - Termination type should persist when employing "create & add another" workflow for cables
* [#21478](https://github.com/netbox-community/netbox/issues/21478) - Fix GraphQL union type resolution for connected console ports
* [#21481](https://github.com/netbox-community/netbox/issues/21481) - Fix display of facility ID on rack view
* [#21518](https://github.com/netbox-community/netbox/issues/21518) - Fix decimal custom field displaying as unset when value is zero
* [#21524](https://github.com/netbox-community/netbox/issues/21524) - Avoid `IndexError` exception when encountering stale cable paths
* [#21527](https://github.com/netbox-community/netbox/issues/21527) - Fix display of primary IP address with associated NAT IP on device view
* [#21550](https://github.com/netbox-community/netbox/issues/21550) - Ensure pre-change snapshots are recorded for related objects
---
## v4.5.3 (2026-02-17)
### Enhancements
* [#19129](https://github.com/netbox-community/netbox/issues/19129) - Improve display of multiple MAC addresses within interfaces table
* [#20981](https://github.com/netbox-community/netbox/issues/20981) - Enhance JSON rendering for custom validators and protection rules in config revision view
* [#21240](https://github.com/netbox-community/netbox/issues/21240) - Add support for configuring Redis `KWARGS` parameters
* [#21257](https://github.com/netbox-community/netbox/issues/21257) - `ContentTypeFilter` now accepts multiple values
* [#21266](https://github.com/netbox-community/netbox/issues/21266) - Add table columns representing installed devices to the device bays table
* [#21267](https://github.com/netbox-community/netbox/issues/21267) - Normalize device height formatting in rack units (display "0U")
* [#21268](https://github.com/netbox-community/netbox/issues/21268) - Add device type details panel to device view
* [#21337](https://github.com/netbox-community/netbox/issues/21337) - Show the assigned platform's parent on the virtual machine UI view
### Performance Improvements
* [#20211](https://github.com/netbox-community/netbox/issues/20211) - Use thumbnails for image attachment hover previews to improve page load performance
* [#21016](https://github.com/netbox-community/netbox/issues/21016) - Restore missing SQL indexes for MPTT fields
* [#21196](https://github.com/netbox-community/netbox/issues/21196) - `q` filter should match on primary IP only for IP address values when filtering devices/VMs
* [#21420](https://github.com/netbox-community/netbox/issues/21420) - Improve query performance of `ContentTypeFilter`
* [#21421](https://github.com/netbox-community/netbox/issues/21421) - Eliminate extraneous application of `DISTINCT` to queries for `MultipleChoiceFilter`
### Bug Fixes
* [#20435](https://github.com/netbox-community/netbox/issues/20435) - Fix navigation menu margin issue when scrollbar appears
* [#21127](https://github.com/netbox-community/netbox/issues/21127) - Ensure assigned cable paths are cleared when removing terminations from a cable
* [#21277](https://github.com/netbox-community/netbox/issues/21277) - Record pre-change snapshot when adding cluster members via UI
* [#21320](https://github.com/netbox-community/netbox/issues/21320) - Avoid validation failures when site or optional fields are missing during rack import
* [#21354](https://github.com/netbox-community/netbox/issues/21354) - Fix base URL in Swagger when `BASE_PATH` is set
* [#21358](https://github.com/netbox-community/netbox/issues/21358) - Token list in UI cannot be ordered by token value
* [#21371](https://github.com/netbox-community/netbox/issues/21371) - Fix `KeyError` exception when triggering a webhook from an event rule
* [#21375](https://github.com/netbox-community/netbox/issues/21375) - Address failure condition in `ipam.0070_vlangroup_vlan_id_ranges` migration
* [#21390](https://github.com/netbox-community/netbox/issues/21390) - Avoid creating "empty" changelog records for related objects when processing manyo-to-many relations
* [#21397](https://github.com/netbox-community/netbox/issues/21397) - Correct rendering of owner field in CircuitType edit form
* [#21412](https://github.com/netbox-community/netbox/issues/21412) - Avoid `AttributeError` exception on initialization when a plugin has local imports in `__init__.py`
---
## v4.5.2 (2026-02-03) ## v4.5.2 (2026-02-03)
### Enhancements ### Enhancements

View File

@@ -1,7 +1,6 @@
from django.urls import include, path from django.urls import include, path
from utilities.urls import get_model_urls from utilities.urls import get_model_urls
from . import views from . import views
app_name = 'account' app_name = 'account'

View File

@@ -2,15 +2,14 @@ import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render, resolve_url from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.http import urlencode from django.utils.http import urlencode
@@ -36,11 +35,11 @@ from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks from utilities.string import remove_linebreaks
from utilities.views import register_model_view from utilities.views import register_model_view
# #
# Login/logout # Login/logout
# #
class LoginView(View): class LoginView(View):
""" """
Perform user authentication via the web UI. Perform user authentication via the web UI.
@@ -140,8 +139,9 @@ class LoginView(View):
return response return response
username = form['username'].value() else:
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}") username = form['username'].value()
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,

View File

@@ -1,2 +1,2 @@
from .serializers_.circuits import *
from .serializers_.providers import * from .serializers_.providers import *
from .serializers_.circuits import *

View File

@@ -4,34 +4,24 @@ from rest_framework import serializers
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import ( from circuits.models import (
Circuit, Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
CircuitGroup, VirtualCircuitTermination, VirtualCircuitType,
CircuitGroupAssignment,
CircuitTermination,
CircuitType,
VirtualCircuit,
VirtualCircuitTermination,
VirtualCircuitType,
) )
from dcim.api.serializers_.cables import CabledObjectSerializer
from dcim.api.serializers_.device_components import InterfaceSerializer from dcim.api.serializers_.device_components import InterfaceSerializer
from dcim.api.serializers_.cables import CabledObjectSerializer
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.gfk_fields import GFKSerializerField from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import ( from netbox.api.serializers import (
NetBoxModelSerializer, NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer,
OrganizationalModelSerializer,
PrimaryModelSerializer,
WritableNestedSerializer,
) )
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
__all__ = ( __all__ = (
'CircuitSerializer',
'CircuitGroupAssignmentSerializer', 'CircuitGroupAssignmentSerializer',
'CircuitGroupSerializer', 'CircuitGroupSerializer',
'CircuitSerializer',
'CircuitTerminationSerializer', 'CircuitTerminationSerializer',
'CircuitTypeSerializer', 'CircuitTypeSerializer',
'VirtualCircuitSerializer', 'VirtualCircuitSerializer',

View File

@@ -5,7 +5,6 @@ from ipam.api.serializers_.asns import ASNSerializer
from ipam.models import ASN from ipam.models import ASN
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import PrimaryModelSerializer from netbox.api.serializers import PrimaryModelSerializer
from .nested import NestedProviderAccountSerializer from .nested import NestedProviderAccountSerializer
__all__ = ( __all__ = (

View File

@@ -1,7 +1,7 @@
from netbox.api.routers import NetBoxRouter from netbox.api.routers import NetBoxRouter
from . import views from . import views
router = NetBoxRouter() router = NetBoxRouter()
router.APIRootView = views.CircuitsRootView router.APIRootView = views.CircuitsRootView

View File

@@ -4,7 +4,6 @@ from circuits import filtersets
from circuits.models import * from circuits.models import *
from dcim.api.views import PassThroughPortMixin from dcim.api.views import PassThroughPortMixin
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from . import serializers from . import serializers

View File

@@ -9,8 +9,7 @@ class CircuitsConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from . import signals, search # noqa: F401
from . import search, signals # noqa: F401
from .models import CircuitTermination from .models import CircuitTermination
# Register models # Register models

View File

@@ -2,11 +2,11 @@ from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
# #
# Circuits # Circuits
# #
class CircuitStatusChoices(ChoiceSet): class CircuitStatusChoices(ChoiceSet):
key = 'Circuit.status' key = 'Circuit.status'

View File

@@ -1,5 +1,6 @@
from django.db.models import Q from django.db.models import Q
# models values for ContentTypes which may be CircuitTermination termination types # models values for ContentTypes which may be CircuitTermination termination types
CIRCUIT_TERMINATION_TERMINATION_TYPES = ( CIRCUIT_TERMINATION_TERMINATION_TYPES = (
'region', 'sitegroup', 'site', 'location', 'providernetwork', 'region', 'sitegroup', 'site', 'location', 'providernetwork',

View File

@@ -9,13 +9,9 @@ from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
MultiValueCharFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
MultiValueContentTypeFilter,
MultiValueNumberFilter,
TreeNodeMultipleChoiceFilter,
) )
from utilities.filtersets import register_filterset from utilities.filtersets import register_filterset
from .choices import * from .choices import *
from .models import * from .models import *
@@ -25,9 +21,9 @@ __all__ = (
'CircuitGroupFilterSet', 'CircuitGroupFilterSet',
'CircuitTerminationFilterSet', 'CircuitTerminationFilterSet',
'CircuitTypeFilterSet', 'CircuitTypeFilterSet',
'ProviderNetworkFilterSet',
'ProviderAccountFilterSet', 'ProviderAccountFilterSet',
'ProviderFilterSet', 'ProviderFilterSet',
'ProviderNetworkFilterSet',
'VirtualCircuitFilterSet', 'VirtualCircuitFilterSet',
'VirtualCircuitTerminationFilterSet', 'VirtualCircuitTerminationFilterSet',
'VirtualCircuitTypeFilterSet', 'VirtualCircuitTypeFilterSet',
@@ -103,13 +99,11 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'), label=_('Provider (ID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug', field_name='provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Provider (slug)'), label=_('Provider (slug)'),
) )
@@ -133,13 +127,11 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
class ProviderNetworkFilterSet(PrimaryModelFilterSet): class ProviderNetworkFilterSet(PrimaryModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'), label=_('Provider (ID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug', field_name='provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Provider (slug)'), label=_('Provider (slug)'),
) )
@@ -171,26 +163,22 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'), label=_('Provider (ID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug', field_name='provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Provider (slug)'), label=_('Provider (slug)'),
) )
provider_account_id = django_filters.ModelMultipleChoiceFilter( provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account', field_name='provider_account',
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
distinct=False,
label=_('Provider account (ID)'), label=_('Provider account (ID)'),
) )
provider_account = django_filters.ModelMultipleChoiceFilter( provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account__account', field_name='provider_account__account',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='account', to_field_name='account',
label=_('Provider account (account)'), label=_('Provider account (account)'),
) )
@@ -201,19 +189,16 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
) )
type_id = django_filters.ModelMultipleChoiceFilter( type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
distinct=False,
label=_('Circuit type (ID)'), label=_('Circuit type (ID)'),
) )
type = django_filters.ModelMultipleChoiceFilter( type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug', field_name='type__slug',
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Circuit type (slug)'), label=_('Circuit type (slug)'),
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices, choices=CircuitStatusChoices,
distinct=False,
null_value=None null_value=None
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
@@ -260,12 +245,10 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
) )
termination_a_id = django_filters.ModelMultipleChoiceFilter( termination_a_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitTermination.objects.all(), queryset=CircuitTermination.objects.all(),
distinct=False,
label=_('Termination A (ID)'), label=_('Termination A (ID)'),
) )
termination_z_id = django_filters.ModelMultipleChoiceFilter( termination_z_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitTermination.objects.all(), queryset=CircuitTermination.objects.all(),
distinct=False,
label=_('Termination A (ID)'), label=_('Termination A (ID)'),
) )
@@ -296,10 +279,9 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
) )
circuit_id = django_filters.ModelMultipleChoiceFilter( circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
distinct=False,
label=_('Circuit'), label=_('Circuit'),
) )
termination_type = MultiValueContentTypeFilter() termination_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='_region', field_name='_region',
@@ -328,14 +310,12 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
distinct=False,
field_name='_site', field_name='_site',
label=_('Site (ID)'), label=_('Site (ID)'),
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug', field_name='_site__slug',
queryset=Site.objects.all(), queryset=Site.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Site (slug)'), label=_('Site (slug)'),
) )
@@ -354,20 +334,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
) )
provider_network_id = django_filters.ModelMultipleChoiceFilter( provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
distinct=False,
field_name='_provider_network', field_name='_provider_network',
label=_('ProviderNetwork (ID)'), label=_('ProviderNetwork (ID)'),
) )
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider_id', field_name='circuit__provider_id',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'), label=_('Provider (ID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug', field_name='circuit__provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Provider (slug)'), label=_('Provider (slug)'),
) )
@@ -404,7 +381,7 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
method='search', method='search',
label=_('Search'), label=_('Search'),
) )
member_type = MultiValueContentTypeFilter() member_type = ContentTypeFilter()
circuit = MultiValueCharFilter( circuit = MultiValueCharFilter(
method='filter_circuit', method='filter_circuit',
field_name='cid', field_name='cid',
@@ -437,13 +414,11 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
) )
group_id = django_filters.ModelMultipleChoiceFilter( group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(), queryset=CircuitGroup.objects.all(),
distinct=False,
label=_('Circuit group (ID)'), label=_('Circuit group (ID)'),
) )
group = django_filters.ModelMultipleChoiceFilter( group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug', field_name='group__slug',
queryset=CircuitGroup.objects.all(), queryset=CircuitGroup.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Circuit group (slug)'), label=_('Circuit group (slug)'),
) )
@@ -513,49 +488,41 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider', field_name='provider_network__provider',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'), label=_('Provider (ID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider__slug', field_name='provider_network__provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Provider (slug)'), label=_('Provider (slug)'),
) )
provider_account_id = django_filters.ModelMultipleChoiceFilter( provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account', field_name='provider_account',
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
distinct=False,
label=_('Provider account (ID)'), label=_('Provider account (ID)'),
) )
provider_account = django_filters.ModelMultipleChoiceFilter( provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account__account', field_name='provider_account__account',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='account', to_field_name='account',
label=_('Provider account (account)'), label=_('Provider account (account)'),
) )
provider_network_id = django_filters.ModelMultipleChoiceFilter( provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
distinct=False,
label=_('Provider network (ID)'), label=_('Provider network (ID)'),
) )
type_id = django_filters.ModelMultipleChoiceFilter( type_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuitType.objects.all(), queryset=VirtualCircuitType.objects.all(),
distinct=False,
label=_('Virtual circuit type (ID)'), label=_('Virtual circuit type (ID)'),
) )
type = django_filters.ModelMultipleChoiceFilter( type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug', field_name='type__slug',
queryset=VirtualCircuitType.objects.all(), queryset=VirtualCircuitType.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Virtual circuit type (slug)'), label=_('Virtual circuit type (slug)'),
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices, choices=CircuitStatusChoices,
distinct=False,
null_value=None null_value=None
) )
@@ -581,49 +548,41 @@ class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
) )
virtual_circuit_id = django_filters.ModelMultipleChoiceFilter( virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuit.objects.all(), queryset=VirtualCircuit.objects.all(),
distinct=False,
label=_('Virtual circuit'), label=_('Virtual circuit'),
) )
role = django_filters.MultipleChoiceFilter( role = django_filters.MultipleChoiceFilter(
choices=VirtualCircuitTerminationRoleChoices, choices=VirtualCircuitTerminationRoleChoices,
distinct=False,
null_value=None null_value=None
) )
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_network__provider', field_name='virtual_circuit__provider_network__provider',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
label=_('Provider (ID)'), label=_('Provider (ID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_network__provider__slug', field_name='virtual_circuit__provider_network__provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
distinct=False,
to_field_name='slug', to_field_name='slug',
label=_('Provider (slug)'), label=_('Provider (slug)'),
) )
provider_account_id = django_filters.ModelMultipleChoiceFilter( provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_account', field_name='virtual_circuit__provider_account',
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
distinct=False,
label=_('Provider account (ID)'), label=_('Provider account (ID)'),
) )
provider_account = django_filters.ModelMultipleChoiceFilter( provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_account__account', field_name='virtual_circuit__provider_account__account',
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
distinct=False,
to_field_name='account', to_field_name='account',
label=_('Provider account (account)'), label=_('Provider account (account)'),
) )
provider_network_id = django_filters.ModelMultipleChoiceFilter( provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
distinct=False,
field_name='virtual_circuit__provider_network', field_name='virtual_circuit__provider_network',
label=_('Provider network (ID)'), label=_('Provider network (ID)'),
) )
interface_id = django_filters.ModelMultipleChoiceFilter( interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
distinct=False,
field_name='interface', field_name='interface',
label=_('Interface (ID)'), label=_('Interface (ID)'),
) )

View File

@@ -4,10 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import ( from circuits.choices import (
CircuitCommitRateChoices, CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices,
CircuitPriorityChoices,
CircuitStatusChoices,
VirtualCircuitTerminationRoleChoices,
) )
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import * from circuits.models import *
@@ -18,10 +15,7 @@ from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditFor
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice, get_field_value from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import ( from utilities.forms.fields import (
ColorField, ColorField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ContentTypeChoiceField,
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
) )
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
@@ -33,8 +27,8 @@ __all__ = (
'CircuitGroupBulkEditForm', 'CircuitGroupBulkEditForm',
'CircuitTerminationBulkEditForm', 'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm', 'CircuitTypeBulkEditForm',
'ProviderAccountBulkEditForm',
'ProviderBulkEditForm', 'ProviderBulkEditForm',
'ProviderAccountBulkEditForm',
'ProviderNetworkBulkEditForm', 'ProviderNetworkBulkEditForm',
'VirtualCircuitBulkEditForm', 'VirtualCircuitBulkEditForm',
'VirtualCircuitTerminationBulkEditForm', 'VirtualCircuitTerminationBulkEditForm',

View File

@@ -12,14 +12,14 @@ from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
__all__ = ( __all__ = (
'CircuitImportForm',
'CircuitGroupAssignmentImportForm', 'CircuitGroupAssignmentImportForm',
'CircuitGroupImportForm', 'CircuitGroupImportForm',
'CircuitImportForm',
'CircuitTerminationImportForm', 'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm', 'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm', 'CircuitTypeImportForm',
'ProviderAccountImportForm',
'ProviderImportForm', 'ProviderImportForm',
'ProviderAccountImportForm',
'ProviderNetworkImportForm', 'ProviderNetworkImportForm',
'VirtualCircuitImportForm', 'VirtualCircuitImportForm',
'VirtualCircuitTerminationImportForm', 'VirtualCircuitTerminationImportForm',

View File

@@ -2,10 +2,7 @@ from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from circuits.choices import ( from circuits.choices import (
CircuitCommitRateChoices, CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices,
CircuitPriorityChoices,
CircuitStatusChoices,
CircuitTerminationSideChoices,
VirtualCircuitTerminationRoleChoices, VirtualCircuitTerminationRoleChoices,
) )
from circuits.models import * from circuits.models import *
@@ -13,7 +10,7 @@ from dcim.models import Location, Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
@@ -25,8 +22,8 @@ __all__ = (
'CircuitGroupFilterForm', 'CircuitGroupFilterForm',
'CircuitTerminationFilterForm', 'CircuitTerminationFilterForm',
'CircuitTypeFilterForm', 'CircuitTypeFilterForm',
'ProviderAccountFilterForm',
'ProviderFilterForm', 'ProviderFilterForm',
'ProviderAccountFilterForm',
'ProviderNetworkFilterForm', 'ProviderNetworkFilterForm',
'VirtualCircuitFilterForm', 'VirtualCircuitFilterForm',
'VirtualCircuitTerminationFilterForm', 'VirtualCircuitTerminationFilterForm',

View File

@@ -4,9 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import ( from circuits.choices import (
CircuitCommitRateChoices, CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices, VirtualCircuitTerminationRoleChoices,
CircuitTerminationPortSpeedChoices,
VirtualCircuitTerminationRoleChoices,
) )
from circuits.constants import * from circuits.constants import *
from circuits.models import * from circuits.models import *
@@ -16,13 +14,10 @@ from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelF
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.forms import get_field_value from utilities.forms import get_field_value
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
SlugField,
) )
from utilities.forms.mixins import DistanceValidationMixin from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle from utilities.templatetags.builtins.filters import bettertitle
@@ -32,8 +27,8 @@ __all__ = (
'CircuitGroupForm', 'CircuitGroupForm',
'CircuitTerminationForm', 'CircuitTerminationForm',
'CircuitTypeForm', 'CircuitTypeForm',
'ProviderAccountForm',
'ProviderForm', 'ProviderForm',
'ProviderAccountForm',
'ProviderNetworkForm', 'ProviderNetworkForm',
'VirtualCircuitForm', 'VirtualCircuitForm',
'VirtualCircuitTerminationForm', 'VirtualCircuitTerminationForm',
@@ -48,42 +43,17 @@ class ProviderForm(PrimaryModelForm):
label=_('ASNs'), label=_('ASNs'),
required=False required=False
) )
add_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Add ASNs'),
required=False
)
remove_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Remove ASNs'),
required=False
)
fieldsets = ( fieldsets = (
FieldSet('name', 'slug', M2MAddRemoveFields('asns'), 'description', 'tags'), FieldSet('name', 'slug', 'asns', 'description', 'tags'),
) )
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'name', 'slug', 'description', 'owner', 'comments', 'tags', 'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
# Add/remove mode for large M2M sets
self.fields.pop('asns')
self.fields['add_asns'].widget.add_query_param('provider_id__n', self.instance.pk)
self.fields['remove_asns'].widget.add_query_param('provider_id', self.instance.pk)
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
else:
# Simple mode for new objects or small M2M sets
self.fields.pop('add_asns')
self.fields.pop('remove_asns')
if self.instance.pk:
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
class ProviderAccountForm(PrimaryModelForm): class ProviderAccountForm(PrimaryModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
@@ -93,14 +63,10 @@ class ProviderAccountForm(PrimaryModelForm):
quick_add=True quick_add=True
) )
fieldsets = (
FieldSet('provider', 'account', 'name', 'description', 'tags'),
)
class Meta: class Meta:
model = ProviderAccount model = ProviderAccount
fields = [ fields = [
'provider', 'account', 'name', 'description', 'owner', 'comments', 'tags', 'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
] ]
@@ -125,13 +91,13 @@ class ProviderNetworkForm(PrimaryModelForm):
class CircuitTypeForm(OrganizationalModelForm): class CircuitTypeForm(OrganizationalModelForm):
fieldsets = ( fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'tags'), FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
) )
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'name', 'slug', 'color', 'description', 'comments', 'tags',
] ]

View File

@@ -3,9 +3,9 @@ import strawberry
from circuits.choices import * from circuits.choices import *
__all__ = ( __all__ = (
'CircuitPriorityEnum',
'CircuitStatusEnum', 'CircuitStatusEnum',
'CircuitTerminationSideEnum', 'CircuitTerminationSideEnum',
'CircuitPriorityEnum',
'VirtualCircuitTerminationRoleEnum', 'VirtualCircuitTerminationRoleEnum',
) )

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated from typing import Annotated, TYPE_CHECKING
import strawberry import strawberry
import strawberry_django import strawberry_django

View File

@@ -1,10 +1,10 @@
from datetime import date from datetime import date
from typing import TYPE_CHECKING, Annotated from typing import Annotated, TYPE_CHECKING
import strawberry import strawberry
import strawberry_django import strawberry_django
from strawberry.scalars import ID from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DateFilterLookup, StrFilterLookup from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
from circuits import models from circuits import models
from circuits.graphql.filter_mixins import CircuitTypeFilterMixin from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
@@ -19,7 +19,6 @@ if TYPE_CHECKING:
from dcim.graphql.filters import InterfaceFilter, LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter from dcim.graphql.filters import InterfaceFilter, LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
from ipam.graphql.filters import ASNFilter from ipam.graphql.filters import ASNFilter
from netbox.graphql.filter_lookups import IntegerLookup from netbox.graphql.filter_lookups import IntegerLookup
from .enums import * from .enums import *
__all__ = ( __all__ = (
@@ -28,8 +27,8 @@ __all__ = (
'CircuitGroupFilter', 'CircuitGroupFilter',
'CircuitTerminationFilter', 'CircuitTerminationFilter',
'CircuitTypeFilter', 'CircuitTypeFilter',
'ProviderAccountFilter',
'ProviderFilter', 'ProviderFilter',
'ProviderAccountFilter',
'ProviderNetworkFilter', 'ProviderNetworkFilter',
'VirtualCircuitFilter', 'VirtualCircuitFilter',
'VirtualCircuitTerminationFilter', 'VirtualCircuitTerminationFilter',
@@ -62,9 +61,9 @@ class CircuitTerminationFilter(
upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
xconnect_id: StrFilterLookup[str] | None = strawberry_django.filter_field() xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field()
pp_info: StrFilterLookup[str] | None = strawberry_django.filter_field() pp_info: FilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()
# Cached relations # Cached relations
_provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( _provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -92,7 +91,7 @@ class CircuitFilter(
TenancyFilterMixin, TenancyFilterMixin,
PrimaryModelFilter PrimaryModelFilter
): ):
cid: StrFilterLookup[str] | None = strawberry_django.filter_field() cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -145,8 +144,8 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
@strawberry_django.filter_type(models.Provider, lookups=True) @strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter): class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field()
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | 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 = ( circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
@@ -159,18 +158,18 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
strawberry_django.filter_field() strawberry_django.filter_field()
) )
provider_id: ID | None = strawberry_django.filter_field() provider_id: ID | None = strawberry_django.filter_field()
account: StrFilterLookup[str] | None = strawberry_django.filter_field() account: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True) @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilter): class ProviderNetworkFilter(PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
provider_id: ID | None = strawberry_django.filter_field() provider_id: ID | None = strawberry_django.filter_field()
service_id: StrFilterLookup[str] | None = strawberry_django.filter_field() service_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True) @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
@@ -180,7 +179,7 @@ class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True) @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter): class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
cid: StrFilterLookup[str] | None = strawberry_django.filter_field() cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -218,4 +217,4 @@ class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin,
strawberry_django.filter_field() strawberry_django.filter_field()
) )
interface_id: ID | None = strawberry_django.filter_field() interface_id: ID | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -1,3 +1,5 @@
from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
@@ -7,34 +9,34 @@ from .types import *
@strawberry.type(name="Query") @strawberry.type(name="Query")
class CircuitsQuery: class CircuitsQuery:
circuit: CircuitType = strawberry_django.field() circuit: CircuitType = strawberry_django.field()
circuit_list: list[CircuitType] = strawberry_django.field() circuit_list: List[CircuitType] = strawberry_django.field()
circuit_termination: CircuitTerminationType = strawberry_django.field() circuit_termination: CircuitTerminationType = strawberry_django.field()
circuit_termination_list: list[CircuitTerminationType] = strawberry_django.field() circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field()
circuit_type: CircuitTypeType = strawberry_django.field() circuit_type: CircuitTypeType = strawberry_django.field()
circuit_type_list: list[CircuitTypeType] = strawberry_django.field() circuit_type_list: List[CircuitTypeType] = strawberry_django.field()
circuit_group: CircuitGroupType = strawberry_django.field() circuit_group: CircuitGroupType = strawberry_django.field()
circuit_group_list: list[CircuitGroupType] = strawberry_django.field() circuit_group_list: List[CircuitGroupType] = strawberry_django.field()
circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field() circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
circuit_group_assignment_list: list[CircuitGroupAssignmentType] = strawberry_django.field() circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field()
provider: ProviderType = strawberry_django.field() provider: ProviderType = strawberry_django.field()
provider_list: list[ProviderType] = strawberry_django.field() provider_list: List[ProviderType] = strawberry_django.field()
provider_account: ProviderAccountType = strawberry_django.field() provider_account: ProviderAccountType = strawberry_django.field()
provider_account_list: list[ProviderAccountType] = strawberry_django.field() provider_account_list: List[ProviderAccountType] = strawberry_django.field()
provider_network: ProviderNetworkType = strawberry_django.field() provider_network: ProviderNetworkType = strawberry_django.field()
provider_network_list: list[ProviderNetworkType] = strawberry_django.field() provider_network_list: List[ProviderNetworkType] = strawberry_django.field()
virtual_circuit: VirtualCircuitType = strawberry_django.field() virtual_circuit: VirtualCircuitType = strawberry_django.field()
virtual_circuit_list: list[VirtualCircuitType] = strawberry_django.field() virtual_circuit_list: List[VirtualCircuitType] = strawberry_django.field()
virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field() virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
virtual_circuit_termination_list: list[VirtualCircuitTerminationType] = strawberry_django.field() virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field()
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field() virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: list[VirtualCircuitTypeType] = strawberry_django.field() virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field()

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Annotated from typing import Annotated, List, TYPE_CHECKING, Union
import strawberry import strawberry
import strawberry_django import strawberry_django
@@ -8,7 +8,6 @@ from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
from tenancy.graphql.types import TenantType from tenancy.graphql.types import TenantType
from .filters import * from .filters import *
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -21,9 +20,9 @@ __all__ = (
'CircuitTerminationType', 'CircuitTerminationType',
'CircuitType', 'CircuitType',
'CircuitTypeType', 'CircuitTypeType',
'ProviderType',
'ProviderAccountType', 'ProviderAccountType',
'ProviderNetworkType', 'ProviderNetworkType',
'ProviderType',
'VirtualCircuitTerminationType', 'VirtualCircuitTerminationType',
'VirtualCircuitType', 'VirtualCircuitType',
'VirtualCircuitTypeType', 'VirtualCircuitTypeType',
@@ -37,10 +36,10 @@ __all__ = (
pagination=True pagination=True
) )
class ProviderType(ContactsMixin, PrimaryObjectType): class ProviderType(ContactsMixin, PrimaryObjectType):
networks: list[Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')]] networks: List[Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')]]
circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
asns: list[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
accounts: list[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]] accounts: List[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -51,7 +50,7 @@ class ProviderType(ContactsMixin, PrimaryObjectType):
) )
class ProviderAccountType(ContactsMixin, PrimaryObjectType): class ProviderAccountType(ContactsMixin, PrimaryObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -62,7 +61,7 @@ class ProviderAccountType(ContactsMixin, PrimaryObjectType):
) )
class ProviderNetworkType(PrimaryObjectType): class ProviderNetworkType(PrimaryObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
circuit_terminations: list[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -72,17 +71,16 @@ class ProviderNetworkType(PrimaryObjectType):
pagination=True pagination=True
) )
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')] circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
@strawberry_django.field @strawberry_django.field
def termination(self) -> Annotated[ def termination(self) -> Annotated[Union[
Annotated['LocationType', strawberry.lazy('dcim.graphql.types')] Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
| Annotated['RegionType', strawberry.lazy('dcim.graphql.types')] Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
| Annotated['SiteGroupType', strawberry.lazy('dcim.graphql.types')] Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
| Annotated['SiteType', strawberry.lazy('dcim.graphql.types')] Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
| Annotated['ProviderNetworkType', strawberry.lazy('circuits.graphql.types')], Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')],
strawberry.union('CircuitTerminationTerminationType'), ], strawberry.union("CircuitTerminationTerminationType")] | None:
] | None:
return self.termination return self.termination
@@ -95,7 +93,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
class CircuitTypeType(OrganizationalObjectType): class CircuitTypeType(OrganizationalObjectType):
color: str color: str
circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -111,7 +109,7 @@ class CircuitType(PrimaryObjectType, ContactsMixin):
termination_z: CircuitTerminationType | None termination_z: CircuitTerminationType | None
type: CircuitTypeType type: CircuitTypeType
tenant: TenantType | None tenant: TenantType | None
terminations: list[CircuitTerminationType] terminations: List[CircuitTerminationType]
@strawberry_django.type( @strawberry_django.type(
@@ -131,14 +129,13 @@ class CircuitGroupType(OrganizationalObjectType):
pagination=True pagination=True
) )
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')] group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
@strawberry_django.field @strawberry_django.field
def member(self) -> Annotated[ def member(self) -> Annotated[Union[
Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')] Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')],
| Annotated['VirtualCircuitType', strawberry.lazy('circuits.graphql.types')], Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')],
strawberry.union('CircuitGroupAssignmentMemberType'), ], strawberry.union("CircuitGroupAssignmentMemberType")] | None:
] | None:
return self.member return self.member
@@ -151,7 +148,7 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
class VirtualCircuitTypeType(OrganizationalObjectType): class VirtualCircuitTypeType(OrganizationalObjectType):
color: str color: str
virtual_circuits: list[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]] virtual_circuits: List[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -177,11 +174,11 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
filters=VirtualCircuitFilter, filters=VirtualCircuitFilter,
pagination=True pagination=True
) )
class VirtualCircuitType(ContactsMixin, PrimaryObjectType): class VirtualCircuitType(PrimaryObjectType):
provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"]) provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
provider_account: ProviderAccountType | None provider_account: ProviderAccountType | None
type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field( type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
select_related=["type"] select_related=["type"]
) )
tenant: TenantType | None tenant: TenantType | None
terminations: list[VirtualCircuitTerminationType] terminations: List[VirtualCircuitTerminationType]

View File

@@ -1,8 +1,7 @@
import django.db.models.deletion
from django.db import migrations, models
import ipam.fields import ipam.fields
from utilities.json import CustomFieldJSONEncoder from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,6 +1,6 @@
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,7 +1,6 @@
# Generated by Django 4.2.5 on 2023-10-20 21:25 # Generated by Django 4.2.5 on 2023-10-20 21:25
from django.db import migrations from django.db import migrations
import utilities.fields import utilities.fields

View File

@@ -1,8 +1,7 @@
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
from django.db import migrations, models
import utilities.json import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -8,16 +8,10 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *
from dcim.models import CabledObjectModel from dcim.models import CabledObjectModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import (
ContactsMixin,
CustomFieldsMixin,
CustomLinksMixin,
ExportTemplatesMixin,
ImageAttachmentsMixin,
TagsMixin,
)
from netbox.models.mixins import DistanceMixin from netbox.models.mixins import DistanceMixin
from netbox.models.features import (
ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin,
)
from .base import BaseCircuitType from .base import BaseCircuitType
__all__ = ( __all__ = (
@@ -347,13 +341,6 @@ class CircuitTermination(
verbose_name = _('circuit termination') verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations') verbose_name_plural = _('circuit terminations')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache original values to detect changes
self._orig_circuit_id = self.__dict__.get('circuit_id')
self._orig_term_side = self.__dict__.get('term_side')
def __str__(self): def __str__(self):
return f'{self.circuit}: Termination {self.term_side}' return f'{self.circuit}: Termination {self.term_side}'
@@ -367,39 +354,11 @@ class CircuitTermination(
raise ValidationError(_("A circuit termination must attach to a terminating object.")) raise ValidationError(_("A circuit termination must attach to a terminating object."))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self._state.adding
update_fields = kwargs.get('update_fields')
# Only consider circuit/term_side changes if those fields
# are actually being persisted
if update_fields is not None:
tracking_relevant = 'circuit' in update_fields or 'term_side' in update_fields
else:
tracking_relevant = True
circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
# Cache objects associated with the terminating object (for filtering) # Cache objects associated with the terminating object (for filtering)
self.cache_related_objects() self.cache_related_objects()
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Clear the old termination reference if circuit or term_side changed
if circuit_changed or term_side_changed:
old_termination_name = f'termination_{self._orig_term_side.lower()}'
Circuit.objects.filter(pk=self._orig_circuit_id).update(**{old_termination_name: None})
# Update the cache if this is a new termination or circuit/term_side changed
if is_new or circuit_changed or term_side_changed:
# Update the new circuit's termination reference
termination_name = f'termination_{self.term_side.lower()}'
Circuit.objects.filter(pk=self.circuit_id).update(**{termination_name: self.pk})
# Update cached values for subsequent saves
self._orig_circuit_id = self.circuit_id
self._orig_term_side = self.term_side
def cache_related_objects(self): def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None self._provider_network = self._region = self._site_group = self._site = self._location = None
if self.termination_type: if self.termination_type:

View File

@@ -6,9 +6,9 @@ from netbox.models import PrimaryModel
from netbox.models.features import ContactsMixin from netbox.models.features import ContactsMixin
__all__ = ( __all__ = (
'ProviderNetwork',
'Provider', 'Provider',
'ProviderAccount', 'ProviderAccount',
'ProviderNetwork',
) )

View File

@@ -8,8 +8,7 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *
from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models import ChangeLoggedModel, PrimaryModel
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin
from .base import BaseCircuitType from .base import BaseCircuitType
__all__ = ( __all__ = (
@@ -30,7 +29,7 @@ class VirtualCircuitType(BaseCircuitType):
verbose_name_plural = _('virtual circuit types') verbose_name_plural = _('virtual circuit types')
class VirtualCircuit(ContactsMixin, PrimaryModel): class VirtualCircuit(PrimaryModel):
""" """
A virtual connection between two or more endpoints, delivered across one or more physical circuits. A virtual connection between two or more endpoints, delivered across one or more physical circuits.
""" """
@@ -185,8 +184,6 @@ class VirtualCircuitTermination(
return self.virtual_circuit.terminations.filter( return self.virtual_circuit.terminations.filter(
role=VirtualCircuitTerminationRoleChoices.ROLE_HUB role=VirtualCircuitTerminationRoleChoices.ROLE_HUB
) )
# Fallback for unexpected roles
return self.virtual_circuit.terminations.none()
def clean(self): def clean(self):
super().clean() super().clean()

View File

@@ -1,5 +1,4 @@
from netbox.search import SearchIndex, register_search from netbox.search import SearchIndex, register_search
from . import models from . import models

View File

@@ -2,10 +2,20 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from dcim.signals import rebuild_paths from dcim.signals import rebuild_paths
from .models import CircuitTermination from .models import CircuitTermination
@receiver(post_save, sender=CircuitTermination)
def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update its parent Circuit.
"""
termination_name = f'termination_{instance.term_side.lower()}'
instance.circuit.refresh_from_db()
setattr(instance.circuit, termination_name, instance)
instance.circuit.save()
@receiver((post_save, post_delete), sender=CircuitTermination) @receiver((post_save, post_delete), sender=CircuitTermination)
def rebuild_cablepaths(instance, raw=False, **kwargs): def rebuild_cablepaths(instance, raw=False, **kwargs):
""" """

View File

@@ -4,7 +4,6 @@ from django.utils.translation import gettext_lazy as _
from circuits.models import * from circuits.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .columns import CommitRateColumn from .columns import CommitRateColumn
__all__ = ( __all__ = (
@@ -190,16 +189,14 @@ class CircuitGroupAssignmentTable(NetBoxTable):
provider = tables.Column( provider = tables.Column(
accessor='member__provider', accessor='member__provider',
verbose_name=_('Provider'), verbose_name=_('Provider'),
orderable=False, linkify=True
linkify=True,
) )
member_type = columns.ContentTypeColumn( member_type = columns.ContentTypeColumn(
verbose_name=_('Type') verbose_name=_('Type')
) )
member = tables.Column( member = tables.Column(
verbose_name=_('Circuit'), verbose_name=_('Circuit'),
orderable=False, linkify=True
linkify=True,
) )
priority = tables.Column( priority = tables.Column(
verbose_name=_('Priority'), verbose_name=_('Priority'),

View File

@@ -7,9 +7,9 @@ from netbox.tables import PrimaryModelTable, columns
from tenancy.tables import ContactsColumnMixin from tenancy.tables import ContactsColumnMixin
__all__ = ( __all__ = (
'ProviderTable',
'ProviderAccountTable', 'ProviderAccountTable',
'ProviderNetworkTable', 'ProviderNetworkTable',
'ProviderTable',
) )

View File

@@ -71,7 +71,7 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel
model = VirtualCircuit model = VirtualCircuit
fields = ( fields = (
'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant', 'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
'tenant_group', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant', 'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
@@ -95,7 +95,6 @@ class VirtualCircuitTerminationTable(NetBoxTable):
verbose_name=_('Provider network') verbose_name=_('Provider network')
) )
provider_account = tables.Column( provider_account = tables.Column(
accessor=tables.A('virtual_circuit__provider_account'),
linkify=True, linkify=True,
verbose_name=_('Account') verbose_name=_('Account')
) )
@@ -113,7 +112,7 @@ class VirtualCircuitTerminationTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = VirtualCircuitTermination model = VirtualCircuitTermination
fields = ( fields = (
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interface', 'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
'description', 'created', 'last_updated', 'actions', 'description', 'created', 'last_updated', 'actions',
) )
default_columns = ( default_columns = (

View File

@@ -5,16 +5,7 @@ from circuits.filtersets import *
from circuits.models import * from circuits.models import *
from dcim.choices import InterfaceTypeChoices, LocationStatusChoices from dcim.choices import InterfaceTypeChoices, LocationStatusChoices
from dcim.models import ( from dcim.models import (
Cable, Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
Device,
DeviceRole,
DeviceType,
Interface,
Location,
Manufacturer,
Region,
Site,
SiteGroup,
) )
from ipam.models import ASN, RIR from ipam.models import ASN, RIR
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices

View File

@@ -1,148 +0,0 @@
from django.test import TestCase
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
from dcim.models import Site
class CircuitTerminationTestCase(TestCase):
@classmethod
def setUpTestData(cls):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
cls.sites = (
Site.objects.create(name='Site 1', slug='site-1'),
Site.objects.create(name='Site 2', slug='site-2'),
)
cls.circuits = (
Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type),
Circuit.objects.create(cid='Circuit 2', provider=provider, type=circuit_type),
)
cls.provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
def test_circuit_termination_creation_populates_circuit_cache(self):
"""
When a CircuitTermination is created, the parent Circuit's termination_a or termination_z
cache field should be populated.
"""
# Create A termination
termination_a = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination_a)
self.assertIsNone(self.circuits[0].termination_z)
# Create Z termination
termination_z = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='Z',
termination=self.sites[1],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination_a)
self.assertEqual(self.circuits[0].termination_z, termination_z)
def test_circuit_termination_circuit_change_clears_old_cache(self):
"""
When a CircuitTermination's circuit is changed, the old Circuit's cache should be cleared
and the new Circuit's cache should be populated.
"""
# Create termination on self.circuits[0]
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
# Move termination to self.circuits[1]
termination.circuit = self.circuits[1]
termination.save()
self.circuits[0].refresh_from_db()
self.circuits[1].refresh_from_db()
# Old circuit's cache should be cleared
self.assertIsNone(self.circuits[0].termination_a)
# New circuit's cache should be populated
self.assertEqual(self.circuits[1].termination_a, termination)
def test_circuit_termination_term_side_change_clears_old_cache(self):
"""
When a CircuitTermination's term_side is changed, the old side's cache should be cleared
and the new side's cache should be populated.
"""
# Create A termination
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
self.assertIsNone(self.circuits[0].termination_z)
# Change from A to Z
termination.term_side = 'Z'
termination.save()
self.circuits[0].refresh_from_db()
# A side should be cleared, Z side should be populated
self.assertIsNone(self.circuits[0].termination_a)
self.assertEqual(self.circuits[0].termination_z, termination)
def test_circuit_termination_circuit_and_term_side_change(self):
"""
When both circuit and term_side are changed, the old Circuit's old side cache should be
cleared and the new Circuit's new side cache should be populated.
"""
# Create A termination on self.circuits[0]
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
# Change to self.circuits[1] Z side
termination.circuit = self.circuits[1]
termination.term_side = 'Z'
termination.save()
self.circuits[0].refresh_from_db()
self.circuits[1].refresh_from_db()
# Old circuit's A side should be cleared
self.assertIsNone(self.circuits[0].termination_a)
self.assertIsNone(self.circuits[0].termination_z)
# New circuit's Z side should be populated
self.assertIsNone(self.circuits[1].termination_a)
self.assertEqual(self.circuits[1].termination_z, termination)
def test_circuit_termination_deletion_clears_cache(self):
"""
When a CircuitTermination is deleted, the parent Circuit's cache should be cleared.
"""
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
# Delete the termination
termination.delete()
self.circuits[0].refresh_from_db()
# Cache should be cleared (SET_NULL behavior)
self.assertIsNone(self.circuits[0].termination_a)

View File

@@ -1,46 +1,23 @@
from circuits.tables import * from django.test import RequestFactory, tag, TestCase
from utilities.testing import TableTestCases
from circuits.models import CircuitTermination
from circuits.tables import CircuitTerminationTable
class CircuitTypeTableTest(TableTestCases.StandardTableTestCase): @tag('regression')
table = CircuitTypeTable class CircuitTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {'actions', }
orderable_columns = [
column.name for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
class CircuitTableTest(TableTestCases.StandardTableTestCase): for col in orderable_columns:
table = CircuitTable for dir in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{dir}{col}'
class CircuitTerminationTableTest(TableTestCases.StandardTableTestCase): table.as_html(fake_request)
table = CircuitTerminationTable
class CircuitGroupTableTest(TableTestCases.StandardTableTestCase):
table = CircuitGroupTable
class CircuitGroupAssignmentTableTest(TableTestCases.StandardTableTestCase):
table = CircuitGroupAssignmentTable
class ProviderTableTest(TableTestCases.StandardTableTestCase):
table = ProviderTable
class ProviderAccountTableTest(TableTestCases.StandardTableTestCase):
table = ProviderAccountTable
class ProviderNetworkTableTest(TableTestCases.StandardTableTestCase):
table = ProviderNetworkTable
class VirtualCircuitTypeTableTest(TableTestCases.StandardTableTestCase):
table = VirtualCircuitTypeTable
class VirtualCircuitTableTest(TableTestCases.StandardTableTestCase):
table = VirtualCircuitTable
class VirtualCircuitTerminationTableTest(TableTestCases.StandardTableTestCase):
table = VirtualCircuitTerminationTable

View File

@@ -196,20 +196,6 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments', 'comments': 'New comments',
} }
def test_circuit_type_display_colored(self):
circuit_type = CircuitType.objects.first()
circuit_type.color = '12ab34'
circuit_type.save()
circuit = Circuit.objects.first()
self.add_permissions('circuits.view_circuit')
response = self.client.get(circuit.get_absolute_url())
self.assertHttpStatus(response, 200)
self.assertContains(response, circuit_type.name)
self.assertContains(response, 'background-color: #12ab34')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_terminations(self): def test_bulk_import_objects_with_terminations(self):
site = Site.objects.first() site = Site.objects.first()

View File

@@ -1,139 +0,0 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
from utilities.data import resolve_attr_path
class CircuitCircuitTerminationPanel(panels.ObjectPanel):
"""
A panel showing the CircuitTermination assigned to the object.
"""
template_name = 'circuits/panels/circuit_circuit_termination.html'
title = _('Termination')
def __init__(self, accessor=None, side=None, **kwargs):
super().__init__(**kwargs)
if accessor is not None:
self.accessor = accessor
if side is not None:
self.side = side
def get_context(self, context):
return {
**super().get_context(context),
'side': self.side,
'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'),
}
class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
"""
A panel showing all Circuit Groups attached to the object.
"""
title = _('Group Assignments')
actions = [
actions.AddObject(
'circuits.CircuitGroupAssignment',
url_params={
'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'member': lambda ctx: ctx['object'].pk,
'return_url': lambda ctx: ctx['object'].get_absolute_url(),
},
label=_('Assign Group'),
),
]
def __init__(self, **kwargs):
super().__init__(
'circuits.CircuitGroupAssignment',
filters={
'member_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'member_id': lambda ctx: ctx['object'].pk,
},
**kwargs,
)
class CircuitGroupPanel(panels.OrganizationalObjectPanel):
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class CircuitGroupAssignmentPanel(panels.ObjectAttributesPanel):
group = attrs.RelatedObjectAttr('group', linkify=True)
provider = attrs.RelatedObjectAttr('member.provider', linkify=True)
member = attrs.GenericForeignKeyAttr('member', linkify=True)
priority = attrs.ChoiceAttr('priority')
class CircuitPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
type = attrs.RelatedObjectAttr('type', linkify=True, colored=True)
status = attrs.ChoiceAttr('status')
distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
install_date = attrs.DateTimeAttr('install_date', spec='date')
termination_date = attrs.DateTimeAttr('termination_date', spec='date')
commit_rate = attrs.TemplatedAttr('commit_rate', template_name='circuits/circuit/attrs/commit_rate.html')
description = attrs.TextAttr('description')
class CircuitTypePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class ProviderPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
asns = attrs.RelatedObjectListAttr('asns', linkify=True, label=_('ASNs'))
description = attrs.TextAttr('description')
class ProviderAccountPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
account = attrs.TextAttr('account', style='font-monospace', copy_button=True)
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class ProviderNetworkPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
name = attrs.TextAttr('name')
service_id = attrs.TextAttr('service_id', label=_('Service ID'), style='font-monospace', copy_button=True)
description = attrs.TextAttr('description')
class VirtualCircuitTypePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class VirtualCircuitPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
provider_network = attrs.RelatedObjectAttr('provider_network', linkify=True)
provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
type = attrs.RelatedObjectAttr('type', linkify=True, colored=True)
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
class VirtualCircuitTerminationPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('virtual_circuit.provider', linkify=True)
provider_network = attrs.RelatedObjectAttr('virtual_circuit.provider_network', linkify=True)
provider_account = attrs.RelatedObjectAttr('virtual_circuit.provider_account', linkify=True)
virtual_circuit = attrs.RelatedObjectAttr('virtual_circuit', linkify=True)
role = attrs.ChoiceAttr('role')
class VirtualCircuitTerminationInterfacePanel(panels.ObjectAttributesPanel):
title = _('Interface')
device = attrs.RelatedObjectAttr('interface.device', linkify=True)
interface = attrs.RelatedObjectAttr('interface', linkify=True)
type = attrs.ChoiceAttr('interface.type')
description = attrs.TextAttr('interface.description')

View File

@@ -1,7 +1,6 @@
from django.urls import include, path from django.urls import include, path
from utilities.urls import get_model_urls from utilities.urls import get_model_urls
from . import views from . import views
app_name = 'circuits' app_name = 'circuits'

View File

@@ -1,29 +1,18 @@
from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView from dcim.views import PathTraceView
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from ipam.models import ASN from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
Panel,
RelatedObjectsPanel,
)
from netbox.views import generic from netbox.views import generic
from utilities.query import count_related from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import * from .models import *
from .ui import panels
# #
# Providers # Providers
# #
@register_model_view(Provider, 'list', path='', detail=False) @register_model_view(Provider, 'list', path='', detail=False)
class ProviderListView(generic.ObjectListView): class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
@@ -39,35 +28,6 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider) @register_model_view(Provider)
class ProviderView(GetRelatedModelsMixin, generic.ObjectView): class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ProviderPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.ProviderAccount',
filters={'provider_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
),
],
),
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
],
),
],
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
@@ -83,7 +43,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
'provider_id', 'provider_id',
), ),
), ),
), ),
} }
@@ -147,32 +107,6 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount) @register_model_view(ProviderAccount)
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView): class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all() queryset = ProviderAccount.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ProviderAccountPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_account_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.Circuit',
url_params={
'provider': lambda ctx: ctx['object'].provider.pk,
'provider_account': lambda ctx: ctx['object'].pk,
},
),
],
),
],
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
@@ -239,32 +173,6 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork) @register_model_view(ProviderNetwork)
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView): class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ProviderNetworkPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
),
ObjectsTablePanel(
model='circuits.VirtualCircuit',
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
),
],
),
],
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
@@ -342,17 +250,6 @@ class CircuitTypeListView(generic.ObjectListView):
@register_model_view(CircuitType) @register_model_view(CircuitType)
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView): class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitTypePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
@@ -420,20 +317,6 @@ class CircuitListView(generic.ObjectListView):
@register_model_view(Circuit) @register_model_view(Circuit)
class CircuitView(generic.ObjectView): class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitPanel(),
panels.CircuitGroupAssignmentsPanel(),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.CircuitCircuitTerminationPanel(side='A'),
panels.CircuitCircuitTerminationPanel(side='Z'),
ImageAttachmentsPanel(),
],
)
@register_model_view(Circuit, 'add', detail=False) @register_model_view(Circuit, 'add', detail=False)
@@ -506,18 +389,6 @@ class CircuitTerminationListView(generic.ObjectListView):
@register_model_view(CircuitTermination) @register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView): class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
Panel(
template_name='circuits/panels/circuit_termination.html',
title=_('Circuit Termination'),
)
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
],
)
@register_model_view(CircuitTermination, 'add', detail=False) @register_model_view(CircuitTermination, 'add', detail=False)
@@ -574,17 +445,6 @@ class CircuitGroupListView(generic.ObjectListView):
@register_model_view(CircuitGroup) @register_model_view(CircuitGroup)
class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView): class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitGroup.objects.all() queryset = CircuitGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitGroupPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
@@ -647,15 +507,6 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
@register_model_view(CircuitGroupAssignment) @register_model_view(CircuitGroupAssignment)
class CircuitGroupAssignmentView(generic.ObjectView): class CircuitGroupAssignmentView(generic.ObjectView):
queryset = CircuitGroupAssignment.objects.all() queryset = CircuitGroupAssignment.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitGroupAssignmentPanel(),
TagsPanel(),
],
right_panels=[
CustomFieldsPanel(),
],
)
@register_model_view(CircuitGroupAssignment, 'add', detail=False) @register_model_view(CircuitGroupAssignment, 'add', detail=False)
@@ -708,17 +559,6 @@ class VirtualCircuitTypeListView(generic.ObjectListView):
@register_model_view(VirtualCircuitType) @register_model_view(VirtualCircuitType)
class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView): class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualCircuitType.objects.all() queryset = VirtualCircuitType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualCircuitTypePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
@@ -786,30 +626,6 @@ class VirtualCircuitListView(generic.ObjectListView):
@register_model_view(VirtualCircuit) @register_model_view(VirtualCircuit)
class VirtualCircuitView(generic.ObjectView): class VirtualCircuitView(generic.ObjectView):
queryset = VirtualCircuit.objects.all() queryset = VirtualCircuit.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualCircuitPanel(),
TagsPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
panels.CircuitGroupAssignmentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.VirtualCircuitTermination',
title=_('Terminations'),
filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.VirtualCircuitTermination',
url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
),
],
),
],
)
@register_model_view(VirtualCircuit, 'add', detail=False) @register_model_view(VirtualCircuit, 'add', detail=False)
@@ -881,16 +697,6 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
@register_model_view(VirtualCircuitTermination) @register_model_view(VirtualCircuitTermination)
class VirtualCircuitTerminationView(generic.ObjectView): class VirtualCircuitTerminationView(generic.ObjectView):
queryset = VirtualCircuitTermination.objects.all() queryset = VirtualCircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualCircuitTerminationPanel(),
TagsPanel(),
CustomFieldsPanel(),
],
right_panels=[
panels.VirtualCircuitTerminationInterfacePanel(),
],
)
@register_model_view(VirtualCircuitTermination, 'edit') @register_model_view(VirtualCircuitTermination, 'edit')

View File

@@ -2,16 +2,10 @@ import re
import typing import typing
from collections import OrderedDict from collections import OrderedDict
from drf_spectacular.contrib.django_filters import DjangoFilterExtension from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType
from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
from drf_spectacular.openapi import AutoSchema from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import ( from drf_spectacular.plumbing import (
build_basic_type, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
build_choice_field,
build_media_type_object,
build_object_type,
follow_field_source,
get_doc,
) )
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import Direction from drf_spectacular.utils import Direction
@@ -25,29 +19,6 @@ BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
WRITABLE_ACTIONS = ("PATCH", "POST", "PUT") WRITABLE_ACTIONS = ("PATCH", "POST", "PUT")
class NetBoxDjangoFilterExtension(DjangoFilterExtension):
"""
Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where
_get_model_field() incorrectly double-appends to_field_name when field_name already ends
with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid
path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema
generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner.
See: https://github.com/netbox-community/netbox/issues/20787
https://github.com/tfranzel/drf-spectacular/issues/1475
"""
priority = 1
def _get_model_field(self, filter_field, model):
if not filter_field.field_name:
return None
path = filter_field.field_name.split('__')
to_field_name = filter_field.extra.get('to_field_name')
if to_field_name is not None and path[-1] != to_field_name:
path.append(to_field_name)
return follow_field_source(model, path, emit_warnings=False)
class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension): class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension):
target_class = 'timezone_field.rest_framework.TimeZoneSerializerField' target_class = 'timezone_field.rest_framework.TimeZoneSerializerField'
@@ -64,7 +35,7 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
if direction == 'request': if direction == 'request':
return build_cf return build_cf
if direction == "response": elif direction == "response":
value = build_cf value = build_cf
label = { label = {
**build_basic_type(OpenApiTypes.STR), **build_basic_type(OpenApiTypes.STR),
@@ -78,10 +49,6 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
} }
) )
# TODO: This function should never implicitly/explicitly return `None`
# The fallback should be well-defined (drf-spectacular expects request/response naming).
return None
def viewset_handles_bulk_create(view): def viewset_handles_bulk_create(view):
"""Check if view automatically provides list-based bulk create""" """Check if view automatically provides list-based bulk create"""
@@ -104,7 +71,8 @@ class NetBoxAutoSchema(AutoSchema):
def is_bulk_action(self): def is_bulk_action(self):
if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS: if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS:
return True return True
return False else:
return False
def get_operation_id(self): def get_operation_id(self):
""" """
@@ -344,7 +312,8 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
if direction == "response": if direction == "response":
component = auto_schema.resolve_serializer(self.target.serializer, direction) component = auto_schema.resolve_serializer(self.target.serializer, direction)
return component.ref if component else None return component.ref if component else None
return build_basic_type(OpenApiTypes.INT) else:
return build_basic_type(OpenApiTypes.INT)
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension): class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):

View File

@@ -34,14 +34,14 @@ class ObjectTypeSerializer(BaseModelSerializer):
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def get_rest_api_endpoint(self, obj): def get_rest_api_endpoint(self, obj):
if not (model := obj.model_class()): if not (model := obj.model_class()):
return None return
try: try:
return get_action_url(model, action='list', rest_api=True) return get_action_url(model, action='list', rest_api=True)
except NoReverseMatch: except NoReverseMatch:
return None return
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
def get_description(self, obj): def get_description(self, obj):
if not (model := obj.model_class()): if not (model := obj.model_class()):
return None return
return inspect.getdoc(model) return inspect.getdoc(model)

View File

@@ -2,8 +2,8 @@ from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
__all__ = ( __all__ = (
'BackgroundQueueSerializer',
'BackgroundTaskSerializer', 'BackgroundTaskSerializer',
'BackgroundQueueSerializer',
'BackgroundWorkerSerializer', 'BackgroundWorkerSerializer',
) )

View File

@@ -1,5 +1,4 @@
from netbox.api.routers import NetBoxRouter from netbox.api.routers import NetBoxRouter
from . import views from . import views
app_name = 'core-api' app_name = 'core-api'

View File

@@ -2,7 +2,7 @@ from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_redis_connection from django_rq.queues import get_redis_connection
from django_rq.settings import get_queues_list from django_rq.settings import QUEUES_LIST
from django_rq.utils import get_statistics from django_rq.utils import get_statistics
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -23,7 +23,6 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import LimitOffsetListPagination from netbox.api.pagination import LimitOffsetListPagination
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from utilities.api import IsSuperuser from utilities.api import IsSuperuser
from . import serializers from . import serializers
@@ -195,7 +194,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
return 'Background Workers' return 'Background Workers'
def get_data(self): def get_data(self):
config = get_queues_list()[0] config = QUEUES_LIST[0]
return Worker.all(get_redis_connection(config['connection_config'])) return Worker.all(get_redis_connection(config['connection_config']))
@extend_schema( @extend_schema(
@@ -205,7 +204,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
) )
def retrieve(self, request, name): def retrieve(self, request, name):
# all the RQ queues should use the same connection # all the RQ queues should use the same connection
config = get_queues_list()[0] config = QUEUES_LIST[0]
workers = Worker.all(get_redis_connection(config['connection_config'])) workers = Worker.all(get_redis_connection(config['connection_config']))
worker = next((item for item in workers if item.name == name), None) worker = next((item for item in workers if item.name == name), None)
if not worker: if not worker:
@@ -229,7 +228,7 @@ class BackgroundTaskViewSet(BaseRQViewSet):
return get_rq_jobs() return get_rq_jobs()
def get_task_from_id(self, task_id): def get_task_from_id(self, task_id):
config = get_queues_list()[0] config = QUEUES_LIST[0]
task = RQ_Job.fetch(task_id, connection=get_redis_connection(config['connection_config'])) task = RQ_Job.fetch(task_id, connection=get_redis_connection(config['connection_config']))
if not task: if not task:
raise Http404 raise Http404
@@ -285,4 +284,5 @@ class BackgroundTaskViewSet(BaseRQViewSet):
stopped_jobs = stop_rq_job(id) stopped_jobs = stop_rq_job(id)
if len(stopped_jobs) == 1: if len(stopped_jobs) == 1:
return HttpResponse(status=200) return HttpResponse(status=200)
return HttpResponse(status=204) else:
return HttpResponse(status=204)

View File

@@ -6,7 +6,7 @@ from django.db.migrations.operations import AlterModelOptions
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.events import * from core.events import *
from netbox.events import EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING, EventType from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
from utilities.migration import custom_deconstruct from utilities.migration import custom_deconstruct
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations # Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
@@ -23,10 +23,9 @@ class CoreConfig(AppConfig):
def ready(self): def ready(self):
from core.api import schema # noqa: F401 from core.api import schema # noqa: F401
from core.checks import check_duplicate_indexes # noqa: F401 from core.checks import check_duplicate_indexes # noqa: F401
from netbox import context_managers # noqa: F401
from netbox.models.features import register_models from netbox.models.features import register_models
from . import data_backends, events, search # noqa: F401 from . import data_backends, events, search # noqa: F401
from netbox import context_managers # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@@ -1,6 +1,6 @@
from django.apps import apps from django.core.checks import Error, register, Tags
from django.core.checks import Error, Tags, register
from django.db.models import Index, UniqueConstraint from django.db.models import Index, UniqueConstraint
from django.apps import apps
__all__ = ( __all__ = (
'check_duplicate_indexes', 'check_duplicate_indexes',

View File

@@ -2,11 +2,11 @@ from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
# #
# Data sources # Data sources
# #
class DataSourceStatusChoices(ChoiceSet): class DataSourceStatusChoices(ChoiceSet):
NEW = 'new' NEW = 'new'
QUEUED = 'queued' QUEUED = 'queued'

View File

@@ -15,7 +15,6 @@ from netbox.utils import register_data_backend
from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS
from utilities.proxy import resolve_proxies from utilities.proxy import resolve_proxies
from utilities.socks import ProxyPoolManager from utilities.socks import ProxyPoolManager
from .exceptions import SyncError from .exceptions import SyncError
__all__ = ( __all__ = (

View File

@@ -1,4 +1,5 @@
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime

View File

@@ -6,9 +6,8 @@ from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from users.models import User from users.models import User
from utilities.filters import MultiValueContentTypeFilter from utilities.filters import ContentTypeFilter
from utilities.filtersets import register_filterset from utilities.filtersets import register_filterset
from .choices import * from .choices import *
from .models import * from .models import *
@@ -26,17 +25,14 @@ __all__ = (
class DataSourceFilterSet(PrimaryModelFilterSet): class DataSourceFilterSet(PrimaryModelFilterSet):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=get_data_backend_choices, choices=get_data_backend_choices,
distinct=False,
null_value=None null_value=None
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=DataSourceStatusChoices, choices=DataSourceStatusChoices,
distinct=False,
null_value=None null_value=None
) )
sync_interval = django_filters.MultipleChoiceFilter( sync_interval = django_filters.MultipleChoiceFilter(
choices=JobIntervalChoices, choices=JobIntervalChoices,
distinct=False,
null_value=None null_value=None
) )
@@ -61,13 +57,11 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
) )
source_id = django_filters.ModelMultipleChoiceFilter( source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
distinct=False,
label=_('Data source (ID)'), label=_('Data source (ID)'),
) )
source = django_filters.ModelMultipleChoiceFilter( source = django_filters.ModelMultipleChoiceFilter(
field_name='source__name', field_name='source__name',
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
distinct=False,
to_field_name='name', to_field_name='name',
label=_('Data source (name)'), label=_('Data source (name)'),
) )
@@ -92,10 +86,9 @@ class JobFilterSet(BaseFilterSet):
) )
object_type_id = django_filters.ModelMultipleChoiceFilter( object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.with_feature('jobs'), queryset=ObjectType.objects.with_feature('jobs'),
distinct=False,
field_name='object_type_id', field_name='object_type_id',
) )
object_type = MultiValueContentTypeFilter() object_type = ContentTypeFilter()
created = django_filters.DateTimeFilter() created = django_filters.DateTimeFilter()
created__before = django_filters.DateTimeFilter( created__before = django_filters.DateTimeFilter(
field_name='created', field_name='created',
@@ -134,7 +127,6 @@ class JobFilterSet(BaseFilterSet):
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=JobStatusChoices, choices=JobStatusChoices,
distinct=False,
null_value=None null_value=None
) )
queue_name = django_filters.CharFilter() queue_name = django_filters.CharFilter()
@@ -188,21 +180,18 @@ class ObjectChangeFilterSet(BaseFilterSet):
label=_('Search'), label=_('Search'),
) )
time = django_filters.DateTimeFromToRangeFilter() time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = MultiValueContentTypeFilter() changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter( changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all()
distinct=False,
) )
related_object_type = MultiValueContentTypeFilter() related_object_type = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=User.objects.all(),
distinct=False,
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=User.objects.all(),
distinct=False,
to_field_name='username', to_field_name='username',
label=_('User name'), label=_('User name'),
) )

View File

@@ -9,10 +9,7 @@ from netbox.utils import get_data_backend_choices
from users.models import User from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField,
TagFilterField,
) )
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker from utilities.forms.widgets import DateTimePicker

View File

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from core.models import * from core.models import *
from netbox.config import PARAMS, get_config from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm, PrimaryModelForm from netbox.forms import NetBoxModelForm, PrimaryModelForm
from netbox.registry import registry from netbox.registry import registry
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
attrs={ attrs={
'rows': 5, 'rows': 5,
'class': 'font-monospace', 'class': 'font-monospace',
'placeholder': '.cache\n*.txt\nsubdir/*' 'placeholder': '.cache\n*.txt'
} }
), ),
} }

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Annotated from typing import Annotated, TYPE_CHECKING
import strawberry import strawberry
import strawberry_django import strawberry_django

View File

@@ -1,15 +1,14 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Annotated from typing import Annotated, TYPE_CHECKING
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.contenttypes.models import ContentType as DjangoContentType from django.contrib.contenttypes.models import ContentType as DjangoContentType
from strawberry.scalars import ID from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
from core import models from core import models
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
from .enums import * from .enums import *
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -17,10 +16,10 @@ if TYPE_CHECKING:
from users.graphql.filters import UserFilter from users.graphql.filters import UserFilter
__all__ = ( __all__ = (
'ContentTypeFilter',
'DataFileFilter', 'DataFileFilter',
'DataSourceFilter', 'DataSourceFilter',
'ObjectChangeFilter', 'ObjectChangeFilter',
'ContentTypeFilter',
) )
@@ -32,23 +31,23 @@ class DataFileFilter(BaseModelFilter):
strawberry_django.filter_field() strawberry_django.filter_field()
) )
source_id: ID | None = strawberry_django.filter_field() source_id: ID | None = strawberry_django.filter_field()
path: StrFilterLookup[str] | None = strawberry_django.filter_field() path: FilterLookup[str] | None = strawberry_django.filter_field()
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
hash: StrFilterLookup[str] | None = strawberry_django.filter_field() hash: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DataSource, lookups=True) @strawberry_django.filter_type(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilter): class DataSourceFilter(PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
type: StrFilterLookup[str] | None = strawberry_django.filter_field() type: FilterLookup[str] | None = strawberry_django.filter_field()
source_url: StrFilterLookup[str] | None = strawberry_django.filter_field() source_url: FilterLookup[str] | None = strawberry_django.filter_field()
status: ( status: (
BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field() ) = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
ignore_rules: StrFilterLookup[str] | None = strawberry_django.filter_field() ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field()
parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -62,8 +61,8 @@ class DataSourceFilter(PrimaryModelFilter):
class ObjectChangeFilter(BaseModelFilter): class ObjectChangeFilter(BaseModelFilter):
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_name: StrFilterLookup[str] | None = strawberry_django.filter_field() user_name: FilterLookup[str] | None = strawberry_django.filter_field()
request_id: StrFilterLookup[str] | None = strawberry_django.filter_field() request_id: FilterLookup[str] | None = strawberry_django.filter_field()
action: ( action: (
BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field() ) = strawberry_django.filter_field()
@@ -76,7 +75,7 @@ class ObjectChangeFilter(BaseModelFilter):
strawberry_django.filter_field() strawberry_django.filter_field()
) )
related_object_id: ID | None = strawberry_django.filter_field() related_object_id: ID | None = strawberry_django.filter_field()
object_repr: StrFilterLookup[str] | None = strawberry_django.filter_field() object_repr: FilterLookup[str] | None = strawberry_django.filter_field()
prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@@ -87,5 +86,5 @@ class ObjectChangeFilter(BaseModelFilter):
@strawberry_django.filter_type(DjangoContentType, lookups=True) @strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseModelFilter): class ContentTypeFilter(BaseModelFilter):
app_label: StrFilterLookup[str] | None = strawberry_django.filter_field() app_label: FilterLookup[str] | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field() model: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Annotated from typing import Annotated, List, TYPE_CHECKING
import strawberry import strawberry
import strawberry_django import strawberry_django
@@ -20,7 +20,7 @@ __all__ = (
class ChangelogMixin: class ChangelogMixin:
@strawberry_django.field @strawberry_django.field
def changelog(self, info: Info) -> list[Annotated['ObjectChangeType', strawberry.lazy('.types')]]: # noqa: F821 def changelog(self, info: Info) -> List[Annotated['ObjectChangeType', strawberry.lazy('.types')]]: # noqa: F821
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter( object_changes = ObjectChange.objects.filter(
changed_object_type=content_type, changed_object_type=content_type,

View File

@@ -1,3 +1,5 @@
from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
@@ -7,7 +9,7 @@ from .types import *
@strawberry.type(name="Query") @strawberry.type(name="Query")
class CoreQuery: class CoreQuery:
data_file: DataFileType = strawberry_django.field() data_file: DataFileType = strawberry_django.field()
data_file_list: list[DataFileType] = strawberry_django.field() data_file_list: List[DataFileType] = strawberry_django.field()
data_source: DataSourceType = strawberry_django.field() data_source: DataSourceType = strawberry_django.field()
data_source_list: list[DataSourceType] = strawberry_django.field() data_source_list: List[DataSourceType] = strawberry_django.field()

View File

@@ -1,4 +1,4 @@
from typing import Annotated from typing import Annotated, List
import strawberry import strawberry
import strawberry_django import strawberry_django
@@ -6,7 +6,6 @@ from django.contrib.contenttypes.models import ContentType as DjangoContentType
from core import models from core import models
from netbox.graphql.types import BaseObjectType, PrimaryObjectType from netbox.graphql.types import BaseObjectType, PrimaryObjectType
from .filters import * from .filters import *
__all__ = ( __all__ = (
@@ -34,7 +33,7 @@ class DataFileType(BaseObjectType):
pagination=True pagination=True
) )
class DataSourceType(PrimaryObjectType): class DataSourceType(PrimaryObjectType):
datafiles: list[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]] datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(

View File

@@ -13,7 +13,6 @@ from netbox.config import Config
from netbox.jobs import JobRunner, system_job from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from utilities.proxy import resolve_proxies from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices from .choices import DataSourceStatusChoices, JobIntervalChoices
from .models import DataSource from .models import DataSource

View File

@@ -144,7 +144,7 @@ class Command(BaseCommand):
# If Python code has been passed, execute it and exit. # If Python code has been passed, execute it and exit.
if options['command']: if options['command']:
exec(options['command'], namespace) exec(options['command'], namespace)
return None return
# Try to enable tab-complete # Try to enable tab-complete
try: try:

View File

@@ -4,6 +4,7 @@ from django_rq.management.commands.rqworker import Command as _Command
from netbox.registry import registry from netbox.registry import registry
DEFAULT_QUEUES = ('high', 'default', 'low') DEFAULT_QUEUES = ('high', 'default', 'low')
logger = logging.getLogger('netbox.rqworker') logger = logging.getLogger('netbox.rqworker')

View File

@@ -1,6 +1,5 @@
from django.db import migrations
import core.models.object_types import core.models.object_types
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,5 +1,4 @@
from .object_types import * # isort: split from .object_types import *
from .change_logging import * from .change_logging import *
from .config import * from .config import *
from .data import * from .data import *

View File

@@ -10,7 +10,8 @@ from mptt.models import MPTTModel
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin, has_feature from netbox.models.features import ChangeLoggingMixin
from netbox.models.features import has_feature
from utilities.data import shallow_compare_dict from utilities.data import shallow_compare_dict
__all__ = ( __all__ = (

View File

@@ -1,8 +1,7 @@
from django.core.cache import cache from django.core.cache import cache
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext from django.utils.translation import gettext, gettext_lazy as _
from django.utils.translation import gettext_lazy as _
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet

View File

@@ -19,7 +19,6 @@ from netbox.models import PrimaryModel
from netbox.models.features import JobsMixin from netbox.models.features import JobsMixin
from netbox.registry import registry from netbox.registry import registry
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from ..choices import * from ..choices import *
from ..exceptions import SyncError from ..exceptions import SyncError
@@ -69,7 +68,7 @@ class DataSource(JobsMixin, PrimaryModel):
ignore_rules = models.TextField( ignore_rules = models.TextField(
verbose_name=_('ignore rules'), verbose_name=_('ignore rules'),
blank=True, blank=True,
help_text=_("Patterns (one per line) matching files or paths to ignore when syncing") help_text=_("Patterns (one per line) matching files to ignore when syncing")
) )
parameters = models.JSONField( parameters = models.JSONField(
verbose_name=_('parameters'), verbose_name=_('parameters'),
@@ -98,7 +97,6 @@ class DataSource(JobsMixin, PrimaryModel):
def get_type_display(self): def get_type_display(self):
if backend := registry['data_backends'].get(self.type): if backend := registry['data_backends'].get(self.type):
return backend.label return backend.label
return None
def get_status_color(self): def get_status_color(self):
return DataSourceStatusChoices.colors.get(self.status) return DataSourceStatusChoices.colors.get(self.status)
@@ -258,22 +256,21 @@ class DataSource(JobsMixin, PrimaryModel):
if path.startswith('.'): if path.startswith('.'):
continue continue
for file_name in file_names: for file_name in file_names:
file_path = os.path.join(path, file_name) if not self._ignore(file_name):
if not self._ignore(file_path): paths.add(os.path.join(path, file_name))
paths.add(file_path)
logger.debug(f"Found {len(paths)} files") logger.debug(f"Found {len(paths)} files")
return paths return paths
def _ignore(self, file_path): def _ignore(self, filename):
""" """
Returns a boolean indicating whether the file should be ignored per the DataSource's configured Returns a boolean indicating whether the file should be ignored per the DataSource's configured
ignore rules. file_path is the full relative path (e.g. "subdir/file.txt"). ignore rules.
""" """
if os.path.basename(file_path).startswith('.'): if filename.startswith('.'):
return True return True
for rule in self.ignore_rules.splitlines(): for rule in self.ignore_rules.splitlines():
if fnmatchcase(file_path, rule) or fnmatchcase(os.path.basename(file_path), rule): if fnmatchcase(filename, rule):
return True return True
return False return False

View File

@@ -4,16 +4,15 @@ from functools import cached_property
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import storages
from django.db import models from django.db import models
from django.core.files.storage import storages
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from ..choices import ManagedFileRootPathChoices
from extras.storage import ScriptFileSystemStorage from extras.storage import ScriptFileSystemStorage
from netbox.models.features import SyncedDataMixin from netbox.models.features import SyncedDataMixin
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from ..choices import ManagedFileRootPathChoices
__all__ = ( __all__ = (
'ManagedFile', 'ManagedFile',
) )
@@ -79,7 +78,8 @@ class ManagedFile(SyncedDataMixin, models.Model):
'scripts': settings.SCRIPTS_ROOT, 'scripts': settings.SCRIPTS_ROOT,
'reports': settings.REPORTS_ROOT, 'reports': settings.REPORTS_ROOT,
}[self.file_root] }[self.file_root]
return "" else:
return ""
def sync_data(self): def sync_data(self):
if self.data_file: if self.data_file:
@@ -89,7 +89,6 @@ class ManagedFile(SyncedDataMixin, models.Model):
with storage.open(self.full_path, 'wb+') as new_file: with storage.open(self.full_path, 'wb+') as new_file:
new_file.write(self.data_file.data) new_file.write(self.data_file.data)
sync_data.alters_data = True
@cached_property @cached_property
def storage(self): def storage(self):

View File

@@ -146,7 +146,7 @@ class Job(models.Model):
if self.object_type: if self.object_type:
if self.object_type.model == 'reportmodule': if self.object_type.model == 'reportmodule':
return reverse('extras:report_result', kwargs={'job_pk': self.pk}) return reverse('extras:report_result', kwargs={'job_pk': self.pk})
if self.object_type.model == 'scriptmodule': elif self.object_type.model == 'scriptmodule':
return reverse('extras:script_result', kwargs={'job_pk': self.pk}) return reverse('extras:script_result', kwargs={'job_pk': self.pk})
return reverse('core:job', args=[self.pk]) return reverse('core:job', args=[self.pk])
@@ -216,7 +216,6 @@ class Job(models.Model):
# Send signal # Send signal
job_start.send(self) job_start.send(self)
start.alters_data = True
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None): def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
""" """
@@ -246,7 +245,6 @@ class Job(models.Model):
# Send signal # Send signal
job_end.send(self) job_end.send(self)
terminate.alters_data = True
def log(self, record: logging.LogRecord): def log(self, record: logging.LogRecord):
""" """

View File

@@ -218,22 +218,19 @@ class ObjectType(ContentType):
def app_verbose_name(self): def app_verbose_name(self):
if model := self.model_class(): if model := self.model_class():
return model._meta.app_config.verbose_name return model._meta.app_config.verbose_name
return None
@property @property
def model_verbose_name(self): def model_verbose_name(self):
if model := self.model_class(): if model := self.model_class():
return model._meta.verbose_name return model._meta.verbose_name
return None
@property @property
def model_verbose_name_plural(self): def model_verbose_name_plural(self):
if model := self.model_class(): if model := self.model_class():
return model._meta.verbose_name_plural return model._meta.verbose_name_plural
return None
@property @property
def is_plugin_model(self): def is_plugin_model(self):
if not (model := self.model_class()): if not (model := self.model_class()):
return None # Return null if model class is invalid return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig) return isinstance(model._meta.app_config, PluginConfig)

View File

@@ -1,6 +1,7 @@
import datetime import datetime
import importlib import importlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional
import requests import requests
from django.conf import settings from django.conf import settings
@@ -54,7 +55,7 @@ class Plugin:
tag_line: str = '' tag_line: str = ''
description_short: str = '' description_short: str = ''
slug: str = '' slug: str = ''
author: PluginAuthor | None = None author: Optional[PluginAuthor] = None
created_at: datetime.datetime = None created_at: datetime.datetime = None
updated_at: datetime.datetime = None updated_at: datetime.datetime = None
license_type: str = '' license_type: str = ''

View File

@@ -1,5 +1,4 @@
from netbox.search import SearchIndex, register_search from netbox.search import SearchIndex, register_search
from . import models from . import models

View File

@@ -3,11 +3,11 @@ from threading import local
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.signals import request_finished
from django.db.models import CASCADE, RESTRICT from django.db.models import CASCADE, RESTRICT
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
from django.dispatch import Signal, receiver from django.dispatch import receiver, Signal
from django.core.signals import request_finished
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates from django_prometheus.models import model_deletes, model_inserts, model_updates
@@ -18,11 +18,10 @@ from extras.events import enqueue_event
from extras.models import Tag from extras.models import Tag
from extras.utils import run_validators from extras.utils import run_validators
from netbox.config import get_config from netbox.config import get_config
from utilities.data import get_config_value_ci
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.data import get_config_value_ci
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .models import ConfigRevision, DataSource, ObjectChange from .models import ConfigRevision, DataSource, ObjectChange
__all__ = ( __all__ = (
@@ -210,28 +209,22 @@ def handle_deleted_object(sender, instance, **kwargs):
# for the forward direction of the relationship, ensuring that the change is recorded. # for the forward direction of the relationship, ensuring that the change is recorded.
# Similarly, for many-to-one relationships, we set the value on the related object to None # Similarly, for many-to-one relationships, we set the value on the related object to None
# and save it to trigger a change record on that object. # and save it to trigger a change record on that object.
# for relation in instance._meta.related_objects:
# Skip this for private models (e.g. CablePath) whose lifecycle is an internal if type(relation) not in [ManyToManyRel, ManyToOneRel]:
# implementation detail. Django's on_delete handlers (e.g. SET_NULL) already take continue
# care of the database integrity; recording changelog entries for the related related_model = relation.related_model
# objects would be spurious. (Ref: #21390) related_field_name = relation.remote_field.name
if not getattr(instance, '_netbox_private', False): if not issubclass(related_model, ChangeLoggingMixin):
for relation in instance._meta.related_objects: # We only care about triggering the m2m_changed signal for models which support
if type(relation) not in [ManyToManyRel, ManyToOneRel]: # change logging
continue continue
related_model = relation.related_model for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
related_field_name = relation.remote_field.name obj.snapshot() # Ensure the change record includes the "before" state
if not issubclass(related_model, ChangeLoggingMixin): if type(relation) is ManyToManyRel:
# We only care about triggering the m2m_changed signal for models which support getattr(obj, related_field_name).remove(instance)
# change logging elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
continue setattr(obj, related_field_name, None)
for obj in related_model.objects.filter(**{related_field_name: instance.pk}): obj.save()
obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
setattr(obj, related_field_name, None)
obj.save()
# Enqueue the object for event processing # Enqueue the object for event processing
queue = events_queue.get() queue = events_queue.get()

View File

@@ -2,5 +2,5 @@ from .change_logging import *
from .config import * from .config import *
from .data import * from .data import *
from .jobs import * from .jobs import *
from .plugins import *
from .tasks import * from .tasks import *
from .plugins import *

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