Compare commits

..

1 Commits

Author SHA1 Message Date
Jeremy Stretch
cb99199340 Initial POC for #21025 2026-03-03 08:17:55 -05:00
184 changed files with 62851 additions and 61013 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.5.5
placeholder: v4.5.3
validations:
required: true
- type: dropdown
+1 -1
View File
@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.5
placeholder: v4.5.3
validations:
required: true
- type: dropdown
+1 -1
View File
@@ -8,7 +8,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.5
placeholder: v4.5.3
validations:
required: true
- type: dropdown
+13 -6
View File
@@ -3,14 +3,20 @@ name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# 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')
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
@@ -27,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
@@ -35,3 +41,4 @@ jobs:
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+1 -31
View File
@@ -30,39 +30,9 @@ jobs:
with:
fetch-depth: 1
# Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
# (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
# Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
- name: Configure git remote for fork PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine PR number based on event type
if [ "${{ github.event_name }}" = "issue_comment" ]; then
PR_NUMBER="${{ github.event.issue.number }}"
elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
exit 0 # issues event — no PR branch to worry about
fi
# Fetch fork info in one API call; silently skip if this is not a PR
PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
if [ -z "$PR_INFO" ]; then
exit 0
fi
IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
if [ "$IS_FORK" = "true" ]; then
FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
fi
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+4 -4
View File
@@ -11,14 +11,14 @@ permissions:
pull-requests: write
discussions: write
concurrency:
group: lock-threads
jobs:
lock:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6.0.0
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
issue-inactive-days: 90
pr-inactive-days: 30
discussion-inactive-days: 180
issue-lock-reason: 'resolved'
-84
View File
@@ -1,84 +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.
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`.
- **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
- **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
- **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
- **Templates**: Django templates in `netbox/templates/<app>/`.
- **Tests**: Mirror the app structure in `<app>/tests/`. Use `netbox.configuration_testing` for test config.
## Coding Standards
- Follow existing Django conventions; don't reinvent patterns already present in the codebase.
- New models must include `created`, `last_updated` fields (inherit from `NetBoxModel` where appropriate).
- Every model exposed in the UI needs: model, serializer, filterset, form, table, views, URL route, and tests.
- API serializers must include a `url` field (absolute URL of the object).
- Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
- Avoid adding new dependencies without strong justification.
## Branch & PR Conventions
- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
- Use the `main` branch for patch releases; `feature` tracks work for the upcoming minor/major release.
- Every PR must reference an approved GitHub issue.
- PRs must include tests for new functionality.
## Gotchas
- `configuration.py` is gitignored — never commit it.
- `manage.py` lives in `netbox/`, NOT the repo root. Running from the wrong directory is a common mistake.
- `NETBOX_CONFIGURATION` env var controls which settings module loads; set to `netbox.configuration_testing` for tests.
- The `extras` app is a catch-all for cross-cutting features (custom fields, tags, webhooks, scripts).
- Plugins API: only documented public APIs are stable. Internal NetBox code is subject to change without notice.
- See `docs/development/` for the full contributing guide and code style details.
+2 -4
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.
* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
@@ -98,10 +96,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
greater than 80 characters in length
> [!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:
* 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.
* All new functionality must include relevant tests where applicable.
+3 -7
View File
@@ -47,8 +47,7 @@ django-rich
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
# See https://github.com/netbox-community/netbox/issues/21696
django-rq<4.0
django-rq
# Provides a variety of storage backends
# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst
@@ -99,10 +98,6 @@ jsonschema
# https://python-markdown.github.io/changelog/
Markdown
# MkDocs
# https://github.com/mkdocs/mkdocs/releases
mkdocs<2.0
# MkDocs Material theme (for documentation build)
# https://squidfunk.github.io/mkdocs-material/changelog/
mkdocs-material
@@ -162,7 +157,8 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# Blocked by #21450
strawberry-graphql-django==0.75.0
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
-2
View File
@@ -349,7 +349,6 @@
"5gbase-t",
"10gbase-br-d",
"10gbase-br-u",
"10gbase-cu",
"10gbase-cx4",
"10gbase-er",
"10gbase-lr",
@@ -368,7 +367,6 @@
"40gbase-fr4",
"40gbase-lr4",
"40gbase-sr4",
"40gbase-sr4-bd",
"50gbase-cr",
"50gbase-er",
"50gbase-fr",
+303 -445
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -215,7 +215,6 @@ if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot()
obj.property = "New Value"
obj._changelog_message = 'Example Message Text' # Optional
obj.full_clean()
obj.save()
```
+11 -15
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:
* `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).
* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
@@ -38,20 +38,18 @@ If no body template is specified, the request body will be populated with a JSON
```json
{
"event": "created",
"timestamp": "2026-03-06T15:11:23.503186+00:00",
"object_type": "dcim.site",
"timestamp": "2021-03-09 17:55:33.968016+00:00",
"model": "site",
"username": "jstretch",
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"data": {
"id": 4,
"url": "/api/dcim/sites/4/",
"display_url": "/dcim/sites/4/",
"display": "Site 1",
"id": 19,
"name": "Site 1",
"slug": "site-1",
"status": {
"status":
"value": "active",
"label": "Active"
"label": "Active",
"id": 1
},
"region": null,
...
@@ -59,10 +57,8 @@ If no body template is specified, the request body will be populated with a JSON
"snapshots": {
"prechange": null,
"postchange": {
"created": "2026-03-06T15:11:23.484Z",
"owner": null,
"description": "",
"comments": "",
"created": "2021-03-09",
"last_updated": "2021-03-09T17:55:33.851Z",
"name": "Site 1",
"slug": "site-1",
"status": "active",
+6 -9
View File
@@ -36,16 +36,13 @@ If false, synchronization will be disabled.
### 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 |
|-----------------------|------------------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
| `subdir/*` | Ignore all files within `subdir/` |
| `subdir/*/*` | Ignore all files one level deep within `subdir/` |
| `*/dev/*` | Ignore files inside any directory named `dev/` |
| Rule | Description |
|----------------|------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
### Sync Interval
+10 -10
View File
@@ -77,14 +77,14 @@ The file path to a particular certificate authority (CA) file to use when valida
## Context Data
The following context variables are available to the text and link templates.
The following context variables are available in to the text and link templates.
| Variable | Description |
|---------------|------------------------------------------------------|
| `event` | The event type (`created`, `updated`, or `deleted`) |
| `timestamp` | The time at which the event occurred |
| `object_type` | The type of object impacted (`app_label.model_name`) |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
| Variable | Description |
|--------------|----------------------------------------------------|
| `event` | The event type (`create`, `update`, or `delete`) |
| `timestamp` | The time at which the event occured |
| `model` | The type of object impacted |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
-69
View File
@@ -1,74 +1,5 @@
# NetBox v4.5
## 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
+13 -13
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DateFilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
from circuits import models
from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
@@ -62,9 +62,9 @@ class CircuitTerminationFilter(
upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
xconnect_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
pp_info: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field()
pp_info: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
# Cached relations
_provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -92,7 +92,7 @@ class CircuitFilter(
TenancyFilterMixin,
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 = (
strawberry_django.filter_field()
)
@@ -145,8 +145,8 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
@strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[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()
circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -159,18 +159,18 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
strawberry_django.filter_field()
)
provider_id: ID | None = strawberry_django.filter_field()
account: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
account: FilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
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 = (
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)
@@ -180,7 +180,7 @@ class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
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 = (
strawberry_django.filter_field()
)
@@ -218,4 +218,4 @@ class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin,
strawberry_django.filter_field()
)
interface_id: ID | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
-35
View File
@@ -347,13 +347,6 @@ class CircuitTermination(
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache original values to detect changes
self._orig_circuit_id = self.__dict__.get('circuit_id')
self._orig_term_side = self.__dict__.get('term_side')
def __str__(self):
return f'{self.circuit}: Termination {self.term_side}'
@@ -367,39 +360,11 @@ class CircuitTermination(
raise ValidationError(_("A circuit termination must attach to a terminating object."))
def save(self, *args, **kwargs):
is_new = self._state.adding
update_fields = kwargs.get('update_fields')
# Only consider circuit/term_side changes if those fields
# are actually being persisted
if update_fields is not None:
tracking_relevant = 'circuit' in update_fields or 'term_side' in update_fields
else:
tracking_relevant = True
circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
# Clear the old termination reference if circuit or term_side changed
if circuit_changed or term_side_changed:
old_termination_name = f'termination_{self._orig_term_side.lower()}'
Circuit.objects.filter(pk=self._orig_circuit_id).update(**{old_termination_name: None})
# Update the cache if this is a new termination or circuit/term_side changed
if is_new or circuit_changed or term_side_changed:
# Update the new circuit's termination reference
termination_name = f'termination_{self.term_side.lower()}'
Circuit.objects.filter(pk=self.circuit_id).update(**{termination_name: self.pk})
# Update cached values for subsequent saves
self._orig_circuit_id = self.circuit_id
self._orig_term_side = self.term_side
def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None
if self.termination_type:
+11
View File
@@ -6,6 +6,17 @@ from dcim.signals import rebuild_paths
from .models import CircuitTermination
@receiver(post_save, sender=CircuitTermination)
def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update its parent Circuit.
"""
termination_name = f'termination_{instance.term_side.lower()}'
instance.circuit.refresh_from_db()
setattr(instance.circuit, termination_name, instance)
instance.circuit.save()
@receiver((post_save, post_delete), sender=CircuitTermination)
def rebuild_cablepaths(instance, raw=False, **kwargs):
"""
-148
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)
+1 -1
View File
@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
attrs={
'rows': 5,
'class': 'font-monospace',
'placeholder': '.cache\n*.txt\nsubdir/*'
'placeholder': '.cache\n*.txt'
}
),
}
+12 -12
View File
@@ -5,7 +5,7 @@ import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType as DjangoContentType
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
from core import models
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
@@ -32,23 +32,23 @@ class DataFileFilter(BaseModelFilter):
strawberry_django.filter_field()
)
source_id: ID | None = strawberry_django.filter_field()
path: 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 = (
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)
class DataSourceFilter(PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
type: StrFilterLookup[str] | None = strawberry_django.filter_field()
source_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: FilterLookup[str] | None = strawberry_django.filter_field()
source_url: FilterLookup[str] | None = strawberry_django.filter_field()
status: (
BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | 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 = (
strawberry_django.filter_field()
)
@@ -62,8 +62,8 @@ class DataSourceFilter(PrimaryModelFilter):
class ObjectChangeFilter(BaseModelFilter):
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
request_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
user_name: FilterLookup[str] | None = strawberry_django.filter_field()
request_id: FilterLookup[str] | None = strawberry_django.filter_field()
action: (
BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field()
@@ -76,7 +76,7 @@ class ObjectChangeFilter(BaseModelFilter):
strawberry_django.filter_field()
)
related_object_id: ID | None = strawberry_django.filter_field()
object_repr: 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 = (
strawberry_django.filter_field()
)
@@ -87,5 +87,5 @@ class ObjectChangeFilter(BaseModelFilter):
@strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseModelFilter):
app_label: StrFilterLookup[str] | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
+7 -8
View File
@@ -69,7 +69,7 @@ class DataSource(JobsMixin, PrimaryModel):
ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True,
help_text=_("Patterns (one per line) matching files or paths to ignore when syncing")
help_text=_("Patterns (one per line) matching files to ignore when syncing")
)
parameters = models.JSONField(
verbose_name=_('parameters'),
@@ -258,22 +258,21 @@ class DataSource(JobsMixin, PrimaryModel):
if path.startswith('.'):
continue
for file_name in file_names:
file_path = os.path.join(path, file_name)
if not self._ignore(file_path):
paths.add(file_path)
if not self._ignore(file_name):
paths.add(os.path.join(path, file_name))
logger.debug(f"Found {len(paths)} files")
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
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
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 False
-20
View File
@@ -10,26 +10,6 @@ from dcim.models import Device, Location, Site
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
class DataSourceIgnoreRulesTestCase(TestCase):
def test_no_ignore_rules(self):
ds = DataSource(ignore_rules='')
self.assertFalse(ds._ignore('README.md'))
self.assertFalse(ds._ignore('subdir/file.py'))
def test_ignore_by_filename(self):
ds = DataSource(ignore_rules='*.txt')
self.assertTrue(ds._ignore('notes.txt'))
self.assertTrue(ds._ignore('subdir/notes.txt'))
self.assertFalse(ds._ignore('notes.py'))
def test_ignore_by_subdirectory(self):
ds = DataSource(ignore_rules='dev/*')
self.assertTrue(ds._ignore('dev/README.md'))
self.assertTrue(ds._ignore('dev/script.py'))
self.assertFalse(ds._ignore('prod/script.py'))
class DataSourceChangeLoggingTestCase(TestCase):
def test_password_added_on_create(self):
-3
View File
@@ -84,9 +84,6 @@ class CablePathSerializer(serializers.ModelSerializer):
def get_path(self, obj):
ret = []
for nodes in obj.path_objects:
if not nodes:
# The path contains an invalid object
return []
serializer = get_serializer_for_model(nodes[0])
context = {'request': self.context['request']}
ret.append(serializer(nodes, nested=True, many=True, context=context).data)
+2 -8
View File
@@ -12,7 +12,7 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from extras.api.mixins import RenderConfigMixin
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
@@ -398,14 +398,8 @@ class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# Devices/modules
#
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
RenderConfigMixin,
NetBoxModelViewSet
):
class DeviceViewSet(SequentialBulkCreatesMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', # Referenced by Device.__str__() for unnamed devices
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
)
filterset_class = filtersets.DeviceFilterSet
+5 -2
View File
@@ -306,9 +306,12 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
# Extend `search()` to include querying on Location.facility
# extended in order to include querying on Location.facility
queryset = super().search(queryset, name, value)
if value.strip():
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
return queryset
+2 -6
View File
@@ -1386,7 +1386,6 @@ class MACAddressImportForm(PrimaryModelImportForm):
# Assign the MAC address as primary for its interface, if designated as such
if interface and self.cleaned_data['is_primary'] and self.instance.pk:
interface.snapshot()
interface.primary_mac_address = self.instance
interface.save()
@@ -1529,11 +1528,8 @@ class CableImportForm(PrimaryModelImportForm):
model = content_type.model_class()
try:
if (
device.virtual_chassis and
device.virtual_chassis.master == device and
not model.objects.filter(device=device, name=name).exists()
):
if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
-4
View File
@@ -15,10 +15,6 @@ def get_cable_form(a_type, b_type):
def __new__(mcs, name, bases, attrs):
# NOTE: Cable.clone() mirrors the parent selector mapping below:
# termination_{end}_device / termination_{end}_powerpanel / termination_{end}_circuit
# This supports both the "Clone" and "Create & Add Another" workflows.
# If you change the mapping here, update Cable.clone() accordingly.
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
# Device component
+1 -40
View File
@@ -121,52 +121,13 @@ class ScopedImportForm(forms.Form):
required=False,
label=_('Scope type (app & model)')
)
scope_name = forms.CharField(
required=False,
label=_('Scope name'),
help_text=_('Name of the assigned scope object (if not using ID)')
)
def clean(self):
super().clean()
scope_id = self.cleaned_data.get('scope_id')
scope_name = self.cleaned_data.get('scope_name')
scope_type = self.cleaned_data.get('scope_type')
# Cannot specify both scope_name and scope_id
if scope_name and scope_id:
raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
# Must specify scope_type with scope_name or scope_id
if scope_name and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_name"))
if scope_id and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_id"))
# Look up the scope object by name
if scope_type and scope_name:
model = scope_type.model_class()
try:
scope_obj = model.objects.get(name=scope_name)
except model.DoesNotExist:
raise ValidationError({
'scope_name': _('{scope_type} "{name}" not found.').format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name
)
})
except model.MultipleObjectsReturned:
raise ValidationError({
'scope_name': _(
'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
).format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name,
)
})
self.cleaned_data['scope_id'] = scope_obj.pk
elif scope_type and not scope_id:
if scope_type and not scope_id:
raise ValidationError({
'scope_id': _(
"Please select a {scope_type}."
+7 -7
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import ID
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, FilterLookup
from core.graphql.filters import ContentTypeFilter
@@ -66,9 +66,9 @@ class ComponentModelFilterMixin:
)
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
label: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
@@ -96,9 +96,9 @@ class ComponentTemplateFilterMixin:
strawberry_django.filter_field()
)
device_type_id: ID | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
label: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
+131 -142
View File
@@ -4,7 +4,7 @@ import strawberry
import strawberry_django
from django.db.models import Q
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
from dcim import models
from dcim.constants import *
@@ -114,7 +114,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
label: FilterLookup[str] | None = strawberry_django.filter_field()
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -141,20 +141,6 @@ class CableTerminationFilter(ChangeLoggedModelFilter):
)
termination_id: ID | None = strawberry_django.filter_field()
# Cached relations
_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
name='device'
)
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
name='rack'
)
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='location')
)
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
name='site'
)
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
@@ -210,9 +196,9 @@ class DeviceFilter(
platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -267,32 +253,32 @@ class DeviceFilter(
longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -339,7 +325,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
@@ -356,13 +342,13 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
default_platform_id: ID | None = strawberry_django.filter_field()
part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -383,36 +369,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='inventory_item_templates')
)
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@@ -479,7 +465,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
@strawberry_django.filter_type(models.MACAddress, lookups=True)
class MACAddressFilter(PrimaryModelFilter):
mac_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -525,7 +511,7 @@ class InterfaceFilter(
duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
wwn: StrFilterLookup[str] | None = strawberry_django.filter_field()
wwn: FilterLookup[str] | None = strawberry_django.filter_field()
parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -645,9 +631,9 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -665,7 +651,7 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
facility: FilterLookup[str] | None = strawberry_django.filter_field()
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -694,34 +680,34 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -734,17 +720,17 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
strawberry_django.filter_field()
)
parent_id: ID | None = strawberry_django.filter_field()
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleType, lookups=True)
@@ -757,41 +743,44 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
strawberry_django.filter_field()
)
profile_id: ID | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@@ -815,7 +804,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
power_panel_id: ID | None = strawberry_django.filter_field()
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_id: ID | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -886,7 +875,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | 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.PowerPort, lookups=True)
@@ -924,8 +913,8 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@@ -946,8 +935,8 @@ class RackFilter(
strawberry_django.filter_field()
)
rack_type_id: ID | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
facility_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
facility_id: FilterLookup[str] | None = strawberry_django.filter_field()
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -961,8 +950,8 @@ class RackFilter(
)
role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
role_id: ID | None = strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -980,7 +969,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
)
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_id: ID | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -1031,8 +1020,8 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
@strawberry_django.filter_type(models.Site, lookups=True)
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -1046,11 +1035,11 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
facility: FilterLookup[str] | None = strawberry_django.filter_field()
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
time_zone: StrFilterLookup[str] | None = strawberry_django.filter_field()
physical_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
shipping_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
time_zone: FilterLookup[str] | None = strawberry_django.filter_field()
physical_address: FilterLookup[str] | None = strawberry_django.filter_field()
shipping_address: FilterLookup[str] | None = strawberry_django.filter_field()
latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -1079,8 +1068,8 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
class VirtualChassisFilter(PrimaryModelFilter):
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
master_id: ID | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
domain: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
domain: FilterLookup[str] | None = strawberry_django.filter_field()
members: (
Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
@@ -1091,7 +1080,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
status: (
BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None
) = (
@@ -1108,7 +1097,7 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
strawberry_django.filter_field()
)
primary_ip6_id: ID | None = strawberry_django.filter_field()
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
interfaces: (
Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0226_modulebay_rebuild_tree'),
]
operations = [
migrations.AddField(
model_name='device',
name='config_context_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='module',
name='config_context_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]
+7 -82
View File
@@ -293,6 +293,7 @@ class Cable(PrimaryModel):
self._pk = self.pk
if self._orig_profile != self.profile:
print(f'profile changed from {self._orig_profile} to {self.profile}')
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations()
@@ -304,50 +305,6 @@ class Cable(PrimaryModel):
except UnsupportedCablePath as e:
raise AbortRequest(e)
def clone(self):
"""
Return attributes suitable for cloning this cable.
In addition to the fields defined in `clone_fields`, include the termination
type and parent selector fields used by dcim.forms.connections.get_cable_form().
"""
attrs = super().clone()
# Mirror dcim.forms.connections.get_cable_form() parent-field logic
for cable_end, terminations in (('a', self.a_terminations), ('b', self.b_terminations)):
if not terminations:
continue
term_cls = type(terminations[0])
term_label = term_cls._meta.label_lower
# Matches CableForm choices: "<app_label>.<model>"
attrs[f'{cable_end}_terminations_type'] = term_label
# Device component
if hasattr(term_cls, 'device'):
device_ids = sorted({t.device_id for t in terminations if t.device_id})
if device_ids:
attrs[f'termination_{cable_end}_device'] = device_ids
# PowerFeed
elif term_label == 'dcim.powerfeed':
powerpanel_ids = sorted({t.power_panel_id for t in terminations if t.power_panel_id})
if powerpanel_ids:
attrs[f'termination_{cable_end}_powerpanel'] = powerpanel_ids
# CircuitTermination
elif term_label == 'circuits.circuittermination':
circuit_ids = sorted({t.circuit_id for t in terminations if t.circuit_id})
if circuit_ids:
attrs[f'termination_{cable_end}_circuit'] = circuit_ids
# Never clone the actual terminations, as they are already occupied
attrs.pop('a_terminations', None)
attrs.pop('b_terminations', None)
return attrs
def serialize_object(self, exclude=None):
data = serialize_object(self, exclude=exclude or [])
@@ -402,15 +359,6 @@ class Cable(PrimaryModel):
"""
a_terminations, b_terminations = self.get_terminations()
# When force-recreating terminations (e.g. after a profile change), cache the termination objects
# from the database before deleting, so they are available for recreation. Without this, the
# a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
if force:
if not hasattr(self, '_a_terminations'):
self._a_terminations = list(a_terminations.keys())
if not hasattr(self, '_b_terminations'):
self._b_terminations = list(b_terminations.keys())
# Delete any stale CableTerminations
for termination, ct in a_terminations.items():
if force or (termination.pk and termination not in self.a_terminations):
@@ -820,9 +768,9 @@ class CablePath(models.Model):
path.append([
object_to_path_node(t) for t in terminations
])
# If not null, push cable positions onto the stack
# If not null, push cable position onto the stack
if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
position_stack.append(list(terminations[0].cable_positions))
position_stack.append([terminations[0].cable_positions[0]])
# Step 2: Determine the attached links (Cable or WirelessLink), if any
links = list(dict.fromkeys(
@@ -863,33 +811,10 @@ class CablePath(models.Model):
# Profile-based tracing
if links[0].profile:
cable_profile = links[0].profile_class()
positions = position_stack.pop() if position_stack else [None]
remote_terminations = []
new_positions = []
# Build (termination, position) pairs by matching stacked positions
# to each termination's cable_positions. This correctly handles
# multiple terminations on different connectors of the same cable.
remaining = list(positions)
term_position_pairs = []
for term in terminations:
if term.cable_positions:
for cp in term.cable_positions:
if cp in remaining:
term_position_pairs.append((term, cp))
remaining.remove(cp)
# Fallback for when positions don't match cable_positions
# (e.g., empty position stack yielding [None])
if not term_position_pairs:
term_position_pairs = [(terminations[0], pos) for pos in positions]
for term, pos in term_position_pairs:
peer, new_pos = cable_profile.get_peer_termination(term, pos)
if peer not in remote_terminations:
remote_terminations.append(peer)
new_positions.append(new_pos)
position_stack.append(new_positions)
position = position_stack.pop()[0] if position_stack else None
term, position = cable_profile.get_peer_termination(terminations[0], position)
remote_terminations = [term]
position_stack.append([position])
# Legacy (positionless) behavior
else:
+1 -2
View File
@@ -1205,8 +1205,7 @@ class MACAddressTable(PrimaryModelTable):
verbose_name=_('Parent')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary'),
orderable=False,
verbose_name=_('Primary')
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
+1 -1
View File
@@ -218,7 +218,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
-120
View File
@@ -2614,126 +2614,6 @@ class CableTest(APIViewTestCases.APIViewTestCase):
},
]
def test_graphql_cable_termination_cached_filters(self):
"""
Validate filtering cables by cached CableTermination relations via GraphQL:
cable_list(filters: { terminations: { <relation>: {...}, DISTINCT: true } })
Also asserts deduplication when both ends match (cable between two interfaces
on the same device/rack/location/site).
"""
self.add_permissions(
'dcim.view_cable',
'dcim.view_device',
'dcim.view_interface',
'dcim.view_rack',
'dcim.view_location',
'dcim.view_site',
)
# Reuse existing fixtures from setUpTestData()
devicetype = DeviceType.objects.get(slug='device-type-1')
role = DeviceRole.objects.get(slug='device-role-1')
# Create an isolated topology for this test
site_a = Site.objects.create(name='GQL Site A', slug='gql-site-a')
site_b = Site.objects.create(name='GQL Site B', slug='gql-site-b')
location_a = Location.objects.create(
site=site_a,
name='GQL Location A',
slug='gql-location-a',
status=LocationStatusChoices.STATUS_ACTIVE,
)
location_b = Location.objects.create(
site=site_b,
name='GQL Location B',
slug='gql-location-b',
status=LocationStatusChoices.STATUS_ACTIVE,
)
rack_a = Rack.objects.create(site=site_a, location=location_a, name='GQL Rack A', u_height=42)
rack_b = Rack.objects.create(site=site_b, location=location_b, name='GQL Rack B', u_height=42)
device_a = Device.objects.create(
device_type=devicetype,
role=role,
name='GQL Device A',
site=site_a,
location=location_a,
rack=rack_a,
)
device_b = Device.objects.create(
device_type=devicetype,
role=role,
name='GQL Device B',
site=site_b,
location=location_b,
rack=rack_b,
)
a0 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
a1 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth1')
a2 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth2')
b0 = Interface.objects.create(device=device_b, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
# Both ends on Device A (duplication risk without DISTINCT)
cable_same_device = Cable(a_terminations=[a0], b_terminations=[a1], label='GQL Cable Same Device')
cable_same_device.save()
# Cross to Device B
cable_cross = Cable(a_terminations=[a2], b_terminations=[b0], label='GQL Cable Cross')
cable_cross.save()
expected_a = {str(cable_same_device.pk), str(cable_cross.pk)}
expected_b = {str(cable_cross.pk)}
url = reverse('graphql')
test_cases = (
# Device (ID + name)
(f'device: {{ id: {{ exact: "{device_a.pk}" }} }}', expected_a),
(f'device: {{ name: {{ exact: "{device_a.name}" }} }}', expected_a),
(f'device: {{ id: {{ exact: "{device_b.pk}" }} }}', expected_b),
(f'device: {{ name: {{ exact: "{device_b.name}" }} }}', expected_b),
# Rack (ID + name)
(f'rack: {{ id: {{ exact: "{rack_a.pk}" }} }}', expected_a),
(f'rack: {{ name: {{ exact: "{rack_a.name}" }} }}', expected_a),
(f'rack: {{ id: {{ exact: "{rack_b.pk}" }} }}', expected_b),
(f'rack: {{ name: {{ exact: "{rack_b.name}" }} }}', expected_b),
# Location (ID + name)
(f'location: {{ id: {{ exact: "{location_a.pk}" }} }}', expected_a),
(f'location: {{ name: {{ exact: "{location_a.name}" }} }}', expected_a),
(f'location: {{ id: {{ exact: "{location_b.pk}" }} }}', expected_b),
(f'location: {{ name: {{ exact: "{location_b.name}" }} }}', expected_b),
# Site (ID + slug)
(f'site: {{ id: {{ exact: "{site_a.pk}" }} }}', expected_a),
(f'site: {{ slug: {{ exact: "{site_a.slug}" }} }}', expected_a),
(f'site: {{ id: {{ exact: "{site_b.pk}" }} }}', expected_b),
(f'site: {{ slug: {{ exact: "{site_b.slug}" }} }}', expected_b),
)
for inner_filter, expected in test_cases:
with self.subTest(filter=inner_filter):
query = f"""{{
cable_list(filters: {{ terminations: {{ {inner_filter} DISTINCT: true }} }})
{{ id }}
}}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
rows = data['data']['cable_list']
ids = [row['id'] for row in rows]
# Ensure DISTINCT is actually effective (no duplicate cables when both ends match)
self.assertEqual(len(ids), len(set(ids)), f'Duplicate cables returned for: {inner_filter}')
self.assertSetEqual(set(ids), expected)
class CableTerminationTest(
APIViewTestCases.GetObjectViewTestCase,
-426
View File
@@ -797,432 +797,6 @@ class CablePathTests(CablePathTestCase):
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_107_duplex_interface_profiled_patch_through_trunk_with_splices(self):
"""
Tests that a duplex interface (cable_positions=[1,2]) traces both positions through
profiled cables and splice pass-throughs, producing a single CablePath with both
strands visible.
[IF1] -C1(1C2P)- [FP1(p=2)][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
[FP2] -C3- [FP4][RP3(p=2)] -C4(1C2P)- [RP4(p=2)][FP6(p=2)]
-C5(1C2P)- [IF2] / [FP3] -C6- [FP5]
Cable profiles: C1=1C2P, C2=1C2P, C3/C6=unprofiled splices, C4=1C2P, C5=1C2P
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2), # Panel A duplex
FrontPort.objects.create(device=self.device, name='Front Port 2'), # Splice A strand 1
FrontPort.objects.create(device=self.device, name='Front Port 3'), # Splice A strand 2
FrontPort.objects.create(device=self.device, name='Front Port 4'), # Splice B strand 1
FrontPort.objects.create(device=self.device, name='Front Port 5'), # Splice B strand 2
FrontPort.objects.create(device=self.device, name='Front Port 6', positions=2), # Panel B duplex
]
PortMapping.objects.bulk_create([
# Panel A: duplex FP1(pos=2) -> RP1(pos=2)
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=2,
rear_port=rear_ports[0], rear_port_position=2,
),
# Splice A: FP2, FP3 -> RP2(pos=2)
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[2], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=2,
),
# Splice B: FP4, FP5 -> RP3(pos=2)
PortMapping(
device=self.device, front_port=front_ports[3], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[4], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=2,
),
# Panel B: duplex FP6(pos=2) -> RP4(pos=2)
PortMapping(
device=self.device, front_port=front_ports[5], front_port_position=1,
rear_port=rear_ports[3], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[5], front_port_position=2,
rear_port=rear_ports[3], rear_port_position=2,
),
])
# Create cables
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0]],
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[0]],
b_terminations=[rear_ports[1]],
)
cable2.clean()
cable2.save()
cable3 = Cable(
a_terminations=[front_ports[1]],
b_terminations=[front_ports[3]],
)
cable3.clean()
cable3.save()
cable4 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[2]],
b_terminations=[rear_ports[3]],
)
cable4.clean()
cable4.save()
cable5 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[front_ports[5]],
b_terminations=[interfaces[1]],
)
cable5.clean()
cable5.save()
cable6 = Cable(
a_terminations=[front_ports[2]],
b_terminations=[front_ports[4]],
)
cable6.clean()
cable6.save()
# Verify forward path: IF1 -> IF2 (both strands through splice)
self.assertPathExists(
(
interfaces[0], cable1, front_ports[0],
rear_ports[0], cable2, rear_ports[1],
[front_ports[1], front_ports[2]], [cable3, cable6], [front_ports[3], front_ports[4]],
rear_ports[2], cable4, rear_ports[3],
front_ports[5], cable5, interfaces[1],
),
is_complete=True,
is_active=True
)
# Verify reverse path: IF2 -> IF1
self.assertPathExists(
(
interfaces[1], cable5, front_ports[5],
rear_ports[3], cable4, rear_ports[2],
[front_ports[3], front_ports[4]], [cable3, cable6], [front_ports[1], front_ports[2]],
rear_ports[1], cable2, rear_ports[0],
front_ports[0], cable1, interfaces[0],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Verify cable positions on interfaces
for iface in interfaces:
iface.refresh_from_db()
self.assertEqual(interfaces[0].cable_connector, 1)
self.assertEqual(interfaces[0].cable_positions, [1, 2])
self.assertEqual(interfaces[1].cable_connector, 1)
self.assertEqual(interfaces[1].cable_positions, [1, 2])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_108_single_interface_two_frontports_unprofiled_through_trunk_with_splices(self):
"""
Tests that positions seeded by PortMapping (not cable_positions) are preserved
when crossing profiled cables.
[IF1] -C1- [FP1,FP2][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
[FP3] -C3- [FP5][RP3(p=2)] -C4(1C2P)- [RP4(p=2)]
[FP7,FP8] -C5- [IF2] / [FP4] -C6- [FP6]
PortMappings: FP1->RP1p1, FP2->RP1p2, FP3->RP2p1, FP4->RP2p2,
FP5->RP3p1, FP6->RP3p2, FP7->RP4p1, FP8->RP4p2
C1 is unprofiled (1 IF -> 2 FPs), C2/C4 are 1C2P trunks,
C3/C6 are unprofiled splices, C5 is unprofiled (2 FPs -> 1 IF).
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1'), # Panel A strand 1
FrontPort.objects.create(device=self.device, name='Front Port 2'), # Panel A strand 2
FrontPort.objects.create(device=self.device, name='Front Port 3'), # Splice A strand 1
FrontPort.objects.create(device=self.device, name='Front Port 4'), # Splice A strand 2
FrontPort.objects.create(device=self.device, name='Front Port 5'), # Splice B strand 1
FrontPort.objects.create(device=self.device, name='Front Port 6'), # Splice B strand 2
FrontPort.objects.create(device=self.device, name='Front Port 7'), # Panel B strand 1
FrontPort.objects.create(device=self.device, name='Front Port 8'), # Panel B strand 2
]
PortMapping.objects.bulk_create([
# Panel A: FP1, FP2 -> RP1(pos=2)
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=2,
),
# Splice A: FP3, FP4 -> RP2(pos=2)
PortMapping(
device=self.device, front_port=front_ports[2], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[3], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=2,
),
# Splice B: FP5, FP6 -> RP3(pos=2)
PortMapping(
device=self.device, front_port=front_ports[4], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[5], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=2,
),
# Panel B: FP7, FP8 -> RP4(pos=2)
PortMapping(
device=self.device, front_port=front_ports[6], front_port_position=1,
rear_port=rear_ports[3], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[7], front_port_position=1,
rear_port=rear_ports[3], rear_port_position=2,
),
])
# Create cables
cable1 = Cable(
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0], front_ports[1]],
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[0]],
b_terminations=[rear_ports[1]],
)
cable2.clean()
cable2.save()
cable3 = Cable(
a_terminations=[front_ports[2]],
b_terminations=[front_ports[4]],
)
cable3.clean()
cable3.save()
cable4 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[2]],
b_terminations=[rear_ports[3]],
)
cable4.clean()
cable4.save()
cable5 = Cable(
a_terminations=[front_ports[6], front_ports[7]],
b_terminations=[interfaces[1]],
)
cable5.clean()
cable5.save()
cable6 = Cable(
a_terminations=[front_ports[3]],
b_terminations=[front_ports[5]],
)
cable6.clean()
cable6.save()
# Verify forward path: IF1 -> IF2 (both strands through splice)
self.assertPathExists(
(
interfaces[0], cable1, [front_ports[0], front_ports[1]],
rear_ports[0], cable2, rear_ports[1],
[front_ports[2], front_ports[3]], [cable3, cable6], [front_ports[4], front_ports[5]],
rear_ports[2], cable4, rear_ports[3],
[front_ports[6], front_ports[7]], cable5, interfaces[1],
),
is_complete=True,
is_active=True
)
# Verify reverse path: IF2 -> IF1
self.assertPathExists(
(
interfaces[1], cable5, [front_ports[6], front_ports[7]],
rear_ports[3], cable4, rear_ports[2],
[front_ports[4], front_ports[5]], [cable3, cable6], [front_ports[2], front_ports[3]],
rear_ports[1], cable2, rear_ports[0],
[front_ports[0], front_ports[1]], cable1, interfaces[0],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Verify cable positions are not set (unprofiled patch cables)
for iface in interfaces:
iface.refresh_from_db()
self.assertIsNone(interfaces[0].cable_connector)
self.assertIsNone(interfaces[0].cable_positions)
self.assertIsNone(interfaces[1].cable_connector)
self.assertIsNone(interfaces[1].cable_positions)
def test_109_multiconnector_trunk_through_patch_panel(self):
"""
Tests that a 4-position interface traces correctly through a patch panel
that fans out to both connectors of a Trunk2C2P cable.
[IF1] --C1(1C4P)-- [FP1(p=4)][RP1(p=2)] --C3(Trunk2C2P)-- [RP3(p=2)][FP5(p=4)] --C5(1C4P)-- [IF2]
[RP2(p=2)] [RP4(p=2)]
PortMappings (Panel A): FP1p1->RP1p1, FP1p2->RP1p2, FP1p3->RP2p1, FP1p4->RP2p2
PortMappings (Panel B): FP5p1->RP3p1, FP5p2->RP3p2, FP5p3->RP4p1, FP5p4->RP4p2
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1', positions=4),
FrontPort.objects.create(device=self.device, name='Front Port 5', positions=4),
]
PortMapping.objects.bulk_create([
# Panel A: FP1(p=4) -> RP1(p=2) and RP2(p=2)
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=2,
rear_port=rear_ports[0], rear_port_position=2,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=3,
rear_port=rear_ports[1], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=4,
rear_port=rear_ports[1], rear_port_position=2,
),
# Panel B: FP5(p=4) -> RP3(p=2) and RP4(p=2)
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=2,
rear_port=rear_ports[2], rear_port_position=2,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=3,
rear_port=rear_ports[3], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=4,
rear_port=rear_ports[3], rear_port_position=2,
),
])
# Create cables
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C4P,
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0]],
)
cable1.clean()
cable1.save()
cable3 = Cable(
profile=CableProfileChoices.TRUNK_2C2P,
a_terminations=[rear_ports[0], rear_ports[1]],
b_terminations=[rear_ports[2], rear_ports[3]],
)
cable3.clean()
cable3.save()
cable5 = Cable(
profile=CableProfileChoices.SINGLE_1C4P,
a_terminations=[front_ports[1]],
b_terminations=[interfaces[1]],
)
cable5.clean()
cable5.save()
# Verify forward path: IF1 -> IF2 (all 4 positions through trunk)
self.assertPathExists(
(
interfaces[0], cable1, front_ports[0],
[rear_ports[0], rear_ports[1]], cable3, [rear_ports[2], rear_ports[3]],
front_ports[1], cable5, interfaces[1],
),
is_complete=True,
is_active=True
)
# Verify reverse path: IF2 -> IF1
self.assertPathExists(
(
interfaces[1], cable5, front_ports[1],
[rear_ports[2], rear_ports[3]], cable3, [rear_ports[0], rear_ports[1]],
front_ports[0], cable1, interfaces[0],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Verify cable positions
for iface in interfaces:
iface.refresh_from_db()
self.assertEqual(interfaces[0].cable_connector, 1)
self.assertEqual(interfaces[0].cable_positions, [1, 2, 3, 4])
self.assertEqual(interfaces[1].cable_connector, 1)
self.assertEqual(interfaces[1].cable_positions, [1, 2, 3, 4])
# Verify rear port connector assignments
for rp in rear_ports:
rp.refresh_from_db()
self.assertEqual(rear_ports[0].cable_connector, 1)
self.assertEqual(rear_ports[0].cable_positions, [1, 2])
self.assertEqual(rear_ports[1].cable_connector, 2)
self.assertEqual(rear_ports[1].cable_positions, [1, 2])
self.assertEqual(rear_ports[2].cable_connector, 1)
self.assertEqual(rear_ports[2].cable_positions, [1, 2])
self.assertEqual(rear_ports[3].cable_connector, 2)
self.assertEqual(rear_ports[3].cable_positions, [1, 2])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_202_single_path_via_pass_through_with_breakouts(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
-29
View File
@@ -1201,35 +1201,6 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_profile_change_preserves_terminations(self):
"""
When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
bulk edit), the existing termination points must be preserved.
"""
cable = Cable.objects.first()
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
# Verify initial state: cable has terminations and no profile
self.assertEqual(cable.profile, '')
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
# Crucially, do NOT set a_terminations or b_terminations on the instance.
cable_from_db = Cable.objects.get(pk=cable.pk)
cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
cable_from_db.save()
# Verify terminations are preserved
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Verify the correct interfaces are still terminated
cable_from_db.refresh_from_db()
a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
self.assertEqual(a_terms, [interface1])
self.assertEqual(b_terms, [interface2])
class VirtualDeviceContextTestCase(TestCase):
-31
View File
@@ -137,12 +137,6 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
class DeviceRolePanel(panels.NestedGroupObjectPanel):
color = attrs.ColorAttr('color')
vm_role = attrs.BooleanAttr('vm_role', label=_('VM role'))
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
class DeviceTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model')
@@ -159,36 +153,11 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
rear_image = attrs.ImageAttr('rear_image')
class ModulePanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
device_type = attrs.RelatedObjectAttr('device.device_type', linkify=True, grouped_by='manufacturer')
module_bay = attrs.NestedObjectAttr('module_bay', linkify=True)
status = attrs.ChoiceAttr('status')
description = attrs.TextAttr('description')
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class ModuleTypePanel(panels.ObjectAttributesPanel):
profile = attrs.RelatedObjectAttr('profile', linkify=True)
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model', label=_('Model name'))
part_number = attrs.TextAttr('part_number')
description = attrs.TextAttr('description')
airflow = attrs.ChoiceAttr('airflow')
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
class PlatformPanel(panels.NestedGroupObjectPanel):
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
class VirtualChassisMembersPanel(panels.ObjectPanel):
"""
A panel which lists all members of a virtual chassis.
+32 -79
View File
@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
from ipam.tables import VLANTranslationRuleTable
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.ui import actions, layout
from netbox.ui.panels import (
@@ -25,7 +25,6 @@ from netbox.ui.panels import (
NestedGroupObjectPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
Panel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -389,7 +388,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
@@ -1668,22 +1667,6 @@ class ModuleTypeListView(generic.ObjectListView):
@register_model_view(ModuleType)
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ModuleTypePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
Panel(
title=_('Attributes'),
template_name='dcim/panels/module_type_attributes.html',
),
RelatedObjectsPanel(),
CustomFieldsPanel(),
ImageAttachmentsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -2323,27 +2306,6 @@ class DeviceRoleListView(generic.ObjectListView):
@register_model_view(DeviceRole)
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceRole.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.DeviceRolePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.DeviceRole',
title=_('Child Device Roles'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.DeviceRole', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
)
def get_extra_context(self, request, instance):
return {
@@ -2423,27 +2385,6 @@ class PlatformListView(generic.ObjectListView):
@register_model_view(Platform)
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Platform.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PlatformPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.Platform',
title=_('Child Platforms'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Platform', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
)
def get_extra_context(self, request, instance):
return {
@@ -2742,7 +2683,7 @@ class DeviceInventoryView(DeviceComponentsView):
@register_model_view(Device, 'configcontext', path='config-context')
class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.annotate_config_context_data()
queryset = Device.objects.all()
base_template = 'dcim/device/base.html'
tab = ViewTab(
label=_('Config Context'),
@@ -2792,7 +2733,6 @@ class DeviceBulkImportView(generic.BulkImportView):
# For child devices, save the reverse relation to the parent device bay
if parent_bay:
device_bay = parent_bay
device_bay.snapshot()
device_bay.installed_device = obj
device_bay.save()
@@ -2837,21 +2777,6 @@ class ModuleListView(generic.ObjectListView):
@register_model_view(Module)
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ModulePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
Panel(
title=_('Module Type'),
template_name='dcim/panels/module_type.html',
),
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -3230,6 +3155,21 @@ class InterfaceView(generic.ObjectView):
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
# Get VLAN translation rules
vlan_translation_table = None
if instance.vlan_translation_policy:
@@ -3245,6 +3185,7 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}
@@ -3971,6 +3912,19 @@ class CableEditView(generic.ObjectEditView):
return super().alter_object(obj, request, url_args, url_kwargs)
def get_extra_addanother_params(self, request):
params = {
'a_terminations_type': request.GET.get('a_terminations_type'),
'b_terminations_type': request.GET.get('b_terminations_type')
}
for key in request.POST:
if 'device' in key or 'power_panel' in key or 'circuit' in key:
params.update({key: request.POST.get(key)})
return params
@register_model_view(Cable, 'delete')
class CableDeleteView(generic.ObjectDeleteView):
@@ -4145,7 +4099,6 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
members = formset.save(commit=False)
devices = Device.objects.filter(pk__in=[m.pk for m in members])
for device in devices:
device.snapshot()
device.vc_position = None
device.save()
for member in members:
-23
View File
@@ -10,34 +10,11 @@ from netbox.api.renderers import TextRenderer
from .serializers import ConfigTemplateSerializer
__all__ = (
'ConfigContextQuerySetMixin',
'ConfigTemplateRenderMixin',
'RenderConfigMixin',
)
class ConfigContextQuerySetMixin:
"""
Used by views that work with config context models (device and virtual machine).
Provides a get_queryset() method which deals with adding the config context
data annotation or not.
"""
def get_queryset(self):
"""
Build the proper queryset based on the request context
If the `brief` query param equates to True or the `exclude` query param
includes `config_context` as a value, return the base queryset.
Else, return the queryset annotated with config context data
"""
queryset = super().get_queryset()
request = self.get_serializer_context()['request']
if self.brief or 'config_context' in request.query_params.get('exclude', []):
return queryset
return queryset.annotate_config_context_data()
class ConfigTemplateRenderMixin:
"""
Provides a method to return a rendered ConfigTemplate as REST API data.
+53 -53
View File
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, FilterLookup
from extras import models
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
@@ -50,11 +50,11 @@ __all__ = (
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -107,22 +107,22 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] = strawberry_django.filter_field()
description: StrFilterLookup[str] = strawberry_django.filter_field()
name: FilterLookup[str] = strawberry_django.filter_field()
description: FilterLookup[str] = strawberry_django.filter_field()
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
file_name: FilterLookup[str] | None = strawberry_django.filter_field()
file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -137,10 +137,10 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
label: FilterLookup[str] | None = strawberry_django.filter_field()
group_name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
required: FilterLookup[bool] | None = strawberry_django.filter_field()
unique: FilterLookup[bool] | None = strawberry_django.filter_field()
search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -166,7 +166,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
validation_regex: StrFilterLookup[str] | None = strawberry_django.filter_field()
validation_regex: FilterLookup[str] | None = strawberry_django.filter_field()
choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -182,13 +182,13 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
strawberry_django.filter_field()
)
is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
base_choices: (
BaseFilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None
) = (
@@ -202,14 +202,14 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.CustomLink, lookups=True)
class CustomLinkFilter(ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
link_text: StrFilterLookup[str] | None = strawberry_django.filter_field()
link_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
link_text: FilterLookup[str] | None = strawberry_django.filter_field()
link_url: FilterLookup[str] | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
group_name: FilterLookup[str] | None = strawberry_django.filter_field()
button_class: (
BaseFilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None
) = (
@@ -220,15 +220,15 @@ class CustomLinkFilter(ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
template_code: FilterLookup[str] | None = strawberry_django.filter_field()
environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
file_name: FilterLookup[str] | None = strawberry_django.filter_field()
file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -244,7 +244,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter):
image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.JournalEntry, lookups=True)
@@ -260,22 +260,22 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
kind: BaseFilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
class NotificationGroupFilter(ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.SavedFilter, lookups=True)
class SavedFilterFilter(ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_id: ID | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -290,8 +290,8 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.TableConfig, lookups=True)
class TableConfigFilter(ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_id: ID | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -303,30 +303,30 @@ class TableConfigFilter(ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.Tag, lookups=True)
class TagFilter(ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Webhook, lookups=True)
class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
payload_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
http_method: (
BaseFilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None
) = (
strawberry_django.filter_field()
)
http_content_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
additional_headers: StrFilterLookup[str] | None = strawberry_django.filter_field()
body_template: StrFilterLookup[str] | None = strawberry_django.filter_field()
secret: StrFilterLookup[str] | None = strawberry_django.filter_field()
http_content_type: FilterLookup[str] | None = strawberry_django.filter_field()
additional_headers: FilterLookup[str] | None = strawberry_django.filter_field()
body_template: FilterLookup[str] | None = strawberry_django.filter_field()
secret: FilterLookup[str] | None = strawberry_django.filter_field()
ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
ca_file_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field()
events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -334,8 +334,8 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
@strawberry_django.filter_type(models.EventRule, lookups=True)
class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -346,10 +346,10 @@ class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedMode
action_type: BaseFilterLookup[Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
action_object_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
action_object_type: FilterLookup[str] | None = strawberry_django.filter_field()
action_object_type_id: ID | None = strawberry_django.filter_field()
action_object_id: ID | None = strawberry_django.filter_field()
action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
+1 -13
View File
@@ -22,19 +22,7 @@ if TYPE_CHECKING:
@strawberry.type
class ConfigContextMixin:
@classmethod
def get_queryset(cls, queryset, info: Info, **kwargs):
queryset = super().get_queryset(queryset, info, **kwargs)
# If `config_context` is requested, call annotate_config_context_data() on the queryset
selected = {f.name for f in info.selected_fields[0].selections}
if 'config_context' in selected and hasattr(queryset, 'annotate_config_context_data'):
return queryset.annotate_config_context_data()
return queryset
# Ensure `local_context_data` is fetched when `config_context` is requested
@strawberry_django.field(only=['local_context_data'])
@strawberry_django.field(only=['config_context_data', 'local_context_data'])
def config_context(self) -> strawberry.scalars.JSON:
return self.get_config_context()
@@ -0,0 +1,40 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Rebuild pre-rendered config context data for all devices and/or virtual machines'
def add_arguments(self, parser):
parser.add_argument(
'--devices-only',
action='store_true',
help='Only rebuild config context data for devices',
)
parser.add_argument(
'--vms-only',
action='store_true',
help='Only rebuild config context data for virtual machines',
)
def handle(self, *args, **options):
devices_only = options['devices_only']
vms_only = options['vms_only']
with connection.cursor() as cursor:
if not vms_only:
self.stdout.write('Rebuilding config context data for devices...')
cursor.execute(
'UPDATE dcim_device SET config_context_data = compute_config_context_for_device(id)'
)
self.stdout.write(self.style.SUCCESS(f' Updated {cursor.rowcount} devices'))
if not devices_only:
self.stdout.write('Rebuilding config context data for virtual machines...')
cursor.execute(
'UPDATE virtualization_virtualmachine '
'SET config_context_data = compute_config_context_for_vm(id)'
)
self.stdout.write(self.style.SUCCESS(f' Updated {cursor.rowcount} virtual machines'))
self.stdout.write(self.style.SUCCESS('Done.'))
@@ -81,7 +81,7 @@ class Command(BaseCommand):
logger.error(f'\t{field}: {error.get("message")}')
raise CommandError()
# Remove extra fields from ScriptForm before passing data to script
# Remove extra fields from ScriptForm before passng data to script
form.cleaned_data.pop('_schedule_at')
form.cleaned_data.pop('_interval')
form.cleaned_data.pop('_commit')
@@ -94,12 +94,10 @@ class Command(BaseCommand):
data=form.cleaned_data,
request=NetBoxFakeRequest({
'META': {},
'COOKIES': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'method': 'POST',
'path': '',
'id': uuid.uuid4()
}),
-67
View File
@@ -1,67 +0,0 @@
from django.db import router
from django.db.models import signals
from taggit.managers import _TaggableManager
from taggit.utils import require_instance_manager
__all__ = (
'NetBoxTaggableManager',
)
class NetBoxTaggableManager(_TaggableManager):
"""
Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a
single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags.
"""
@require_instance_manager
def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs):
self._remove_prefetched_objects()
if tag_kwargs is None:
tag_kwargs = {}
db = router.db_for_write(self.through, instance=self.instance)
tag_objs = self._to_tag_model_instances(tags, tag_kwargs)
new_ids = {t.pk for t in tag_objs}
# Determine which tags are not already assigned to this object
lookup = self._lookup_kwargs()
vals = set(
self.through._default_manager.using(db)
.values_list("tag_id", flat=True)
.filter(**lookup, tag_id__in=new_ids)
)
new_ids -= vals
if not new_ids:
return
signals.m2m_changed.send(
sender=self.through,
action="pre_add",
instance=self.instance,
reverse=False,
model=self.through.tag_model(),
pk_set=new_ids,
using=db,
)
# Use a single bulk INSERT instead of one get_or_create per tag.
self.through._default_manager.using(db).bulk_create(
[
self.through(tag=tag, **lookup, **(through_defaults or {}))
for tag in tag_objs
if tag.pk in new_ids
],
ignore_conflicts=True,
)
signals.m2m_changed.send(
sender=self.through,
action="post_add",
instance=self.instance,
reverse=False,
model=self.through.tag_model(),
pk_set=new_ids,
using=db,
)
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -225,6 +225,11 @@ class ConfigContextModel(models.Model):
"Local config context data takes precedence over source contexts in the final rendered config context"
)
)
config_context_data = models.JSONField(
blank=True,
null=True,
editable=False,
)
class Meta:
abstract = True
@@ -234,19 +239,21 @@ class ConfigContextModel(models.Model):
Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
Return the rendered configuration context for a device or VM.
"""
data = {}
# Use pre-rendered cached field if available
if self.config_context_data is not None:
return self.config_context_data
if not hasattr(self, 'config_context_data'):
# The annotation is not available, so we fall back to manually querying for the config context objects
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
# Fall back to annotation if queryset was annotated
data = {}
if hasattr(self, '_annotated_config_context_data'):
config_context_data = self._annotated_config_context_data or []
else:
# The attribute may exist, but the annotated value could be None if there is no config context data
config_context_data = self.config_context_data or []
# Last resort: compute on-the-fly
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) or []
for context in config_context_data:
data = deepmerge(data, context)
# If the object has local config context data defined, merge it last
if self.local_context_data:
data = deepmerge(data, self.local_context_data)
+1 -1
View File
@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
"""
from extras.models import ConfigContext
return self.annotate(
config_context_data=Subquery(
_annotated_config_context_data=Subquery(
ConfigContext.objects.filter(
self._get_config_context_filters()
).annotate(
+4 -1
View File
@@ -206,6 +206,7 @@ class ConfigContextTest(TestCase):
"b": 456,
"c": 777
}
device.refresh_from_db()
self.assertEqual(device.get_config_context(), expected_data)
def test_name_ordering_after_weight(self):
@@ -235,6 +236,7 @@ class ConfigContextTest(TestCase):
"b": 456,
"c": 789
}
device.refresh_from_db()
self.assertEqual(device.get_config_context(), expected_data)
def test_schema_validation(self):
@@ -303,6 +305,7 @@ class ConfigContextTest(TestCase):
)
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
device.refresh_from_db()
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
@@ -666,7 +669,7 @@ class ConfigContextTest(TestCase):
self.assertFalse(queryset.query.distinct)
# Check that tag subqueries DO use DISTINCT by inspecting the annotation
config_annotation = queryset.query.annotations.get('config_context_data')
config_annotation = queryset.query.annotations.get('_annotated_config_context_data')
self.assertIsNotNone(config_annotation)
def find_tag_subqueries(where_node):
+12 -33
View File
@@ -210,8 +210,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
class Meta:
model = Prefix
fields = (
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name',
'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
@@ -424,36 +424,17 @@ class IPAddressImportForm(PrimaryModelImportForm):
# Set as primary for device/VM
if self.cleaned_data.get('is_primary') is not None:
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
if self.cleaned_data.get('is_primary'):
parent.snapshot()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress
parent.save()
else:
# Only clear the primary IP if this IP is currently set as primary
if self.instance.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.snapshot()
parent.primary_ip4 = None
parent.save()
elif self.instance.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.snapshot()
parent.primary_ip6 = None
parent.save()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress if self.cleaned_data.get('is_primary') else None
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob') is not None:
parent = self.cleaned_data.get('device')
if self.cleaned_data.get('is_oob'):
parent.snapshot()
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
# Only clear OOB if this IP is currently set as the OOB IP
parent.snapshot()
parent.oob_ip = None
parent.save()
parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
parent.save()
return ipaddress
@@ -474,8 +455,7 @@ class FHRPGroupImportForm(PrimaryModelImportForm):
fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags')
class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
# Override ScopedImportForm.scope_type to set custom queryset
class VLANGroupImportForm(OrganizationalModelImportForm):
scope_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
@@ -495,11 +475,10 @@ class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
class Meta:
model = VLANGroup
fields = (
'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner',
'comments', 'tags',
'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags'
)
labels = {
'scope_id': _('Scope ID'),
'scope_id': 'Scope ID',
}
+20 -20
View File
@@ -7,7 +7,7 @@ import strawberry_django
from django.db.models import Q
from netaddr.core import AddrFormatError
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
from dcim.graphql.filter_mixins import ScopedFilterMixin
from dcim.models import Device
@@ -70,8 +70,8 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.ASNRange, lookups=True)
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -84,7 +84,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
@strawberry_django.filter_type(models.Aggregate, lookups=True)
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
@@ -120,14 +120,14 @@ class FHRPGroupFilter(PrimaryModelFilter):
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
protocol: BaseFilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
auth_type: BaseFilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
auth_key: StrFilterLookup[str] | None = strawberry_django.filter_field()
auth_key: FilterLookup[str] | None = strawberry_django.filter_field()
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -138,7 +138,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
interface_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
interface_id: FilterLookup[str] | None = strawberry_django.filter_field()
group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -174,7 +174,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.IPAddress, lookups=True)
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
address: StrFilterLookup[str] | None = strawberry_django.filter_field()
address: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
vrf_id: ID | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
@@ -195,7 +195,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
strawberry_django.filter_field()
)
nat_outside_id: ID | None = strawberry_django.filter_field()
dns_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_field()
def assigned(self, value: bool, prefix) -> Q:
@@ -225,8 +225,8 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
@strawberry_django.filter_type(models.IPRange, lookups=True)
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
start_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
end_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -279,7 +279,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.Prefix, lookups=True)
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
vrf_id: ID | None = strawberry_django.filter_field()
vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -328,7 +328,7 @@ class RoleFilter(OrganizationalModelFilter):
@strawberry_django.filter_type(models.RouteTarget, lookups=True)
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -345,7 +345,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.Service, lookups=True)
class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -357,7 +357,7 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLAN, lookups=True)
@@ -371,7 +371,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -401,7 +401,7 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
class VLANTranslationPolicyFilter(PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
@@ -410,7 +410,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
strawberry_django.filter_field()
)
policy_id: ID | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -421,8 +421,8 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
@strawberry_django.filter_type(models.VRF, lookups=True)
class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
rd: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
rd: FilterLookup[str] | None = strawberry_django.filter_field()
enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
+3 -5
View File
@@ -432,11 +432,9 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
])
available_ips = prefix - child_ips - child_ranges
# Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
if (
self.is_pool
or (self.family == 4 and self.prefix.prefixlen >= 31)
or (self.family == 6 and self.prefix.prefixlen >= 127)
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
self.family == 4 and self.prefix.prefixlen >= 31
):
return available_ips
+44 -1
View File
@@ -1,17 +1,19 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin
from tenancy.tables import TenancyColumnsMixin, TenantColumn
from virtualization.models import VMInterface
from .template_code import *
__all__ = (
'InterfaceVLANTable',
'VLANDevicesTable',
'VLANGroupTable',
'VLANMembersTable',
@@ -196,6 +198,47 @@ class VLANVirtualMachinesTable(VLANMembersTable):
exclude = ('id', )
class InterfaceVLANTable(NetBoxTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name=_('VID')
)
tagged = columns.BooleanColumn(
verbose_name=_('Tagged'),
false_mark=None
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name=_('Group')
)
tenant = TenantColumn(
verbose_name=_('Tenant'),
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
role = tables.Column(
verbose_name=_('Role'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
exclude = ('id', )
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)
#
# VLAN Translation
#
+1 -56
View File
@@ -1,10 +1,8 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
from dcim.models import Location, Region, Site, SiteGroup
from ipam.forms import PrefixForm
from ipam.forms.bulk_import import IPAddressImportForm
class PrefixFormTestCase(TestCase):
@@ -43,56 +41,3 @@ class PrefixFormTestCase(TestCase):
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
class IPAddressImportFormTestCase(TestCase):
"""Tests for IPAddressImportForm bulk import behavior."""
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
cls.device = Device.objects.create(
name='Device 1',
site=site,
device_type=device_type,
role=device_role,
)
cls.interface = Interface.objects.create(
device=cls.device,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
)
def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
"""
Regression test for #21440: importing a second IP with is_oob=False should
not clear the OOB IP set by a previous row with is_oob=True.
"""
form1 = IPAddressImportForm(data={
'address': '10.10.10.1/24',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': True,
})
self.assertTrue(form1.is_valid(), form1.errors)
ip1 = form1.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1)
form2 = IPAddressImportForm(data={
'address': '2001:db8::1/64',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': False,
})
self.assertTrue(form2.is_valid(), form2.errors)
form2.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
-129
View File
@@ -39,132 +39,3 @@ class AnnotatedIPAddressTableTest(TestCase):
iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
self.assertEqual(iprange_checkbox_count, 0)
def test_annotate_ip_space_ipv4_non_pool_excludes_network_and_broadcast(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('192.0.2.0/29'), # 8 addresses total
status='active',
is_pool=False,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# /29 non-pool: exclude .0 (network) and .7 (broadcast)
self.assertEqual(available.first_ip, '192.0.2.1/29')
self.assertEqual(available.size, 6)
def test_annotate_ip_space_ipv4_pool_includes_network_and_broadcast(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('192.0.2.8/29'), # 8 addresses total
status='active',
is_pool=True,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# Pool: all addresses are usable, including network/broadcast
self.assertEqual(available.first_ip, '192.0.2.8/29')
self.assertEqual(available.size, 8)
def test_annotate_ip_space_ipv4_31_includes_all_ips(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('192.0.2.16/31'), # 2 addresses total
status='active',
is_pool=False,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# /31: fully usable
self.assertEqual(available.first_ip, '192.0.2.16/31')
self.assertEqual(available.size, 2)
def test_annotate_ip_space_ipv4_32_includes_single_ip(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('192.0.2.100/32'), # 1 address total
status='active',
is_pool=False,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# /32: single usable address
self.assertEqual(available.first_ip, '192.0.2.100/32')
self.assertEqual(available.size, 1)
def test_annotate_ip_space_ipv6_non_pool_excludes_anycast_first_ip(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('2001:db8::/126'), # 4 addresses total
status='active',
is_pool=False,
)
data = annotate_ip_space(prefix)
# No child records -> expect one AvailableIPSpace entry
self.assertEqual(len(data), 1)
available = data[0]
# For IPv6 non-pool prefixes (except /127-/128), the first address is reserved (subnet-router anycast)
self.assertEqual(available.first_ip, '2001:db8::1/126')
self.assertEqual(available.size, 3) # 4 total - 1 reserved anycast
def test_annotate_ip_space_ipv6_127_includes_all_ips(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('2001:db8::/127'), # 2 addresses total
status='active',
is_pool=False,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# /127 is fully usable (no anycast exclusion)
self.assertEqual(available.first_ip, '2001:db8::/127')
self.assertEqual(available.size, 2)
def test_annotate_ip_space_ipv6_128_includes_single_ip(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('2001:db8::1/128'), # 1 address total
status='active',
is_pool=False,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# /128 is fully usable (single host address)
self.assertEqual(available.first_ip, '2001:db8::1/128')
self.assertEqual(available.size, 1)
def test_annotate_ip_space_ipv6_pool_includes_anycast_first_ip(self):
prefix = Prefix.objects.create(
prefix=IPNetwork('2001:db8:1::/126'), # 4 addresses total
status='active',
is_pool=True,
)
data = annotate_ip_space(prefix)
self.assertEqual(len(data), 1)
available = data[0]
# Pools are fully usable
self.assertEqual(available.first_ip, '2001:db8:1::/126')
self.assertEqual(available.size, 4)
+13 -55
View File
@@ -435,21 +435,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
site = sites[0]
cls.csv_data = {
'default': (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}",
),
'scope_name': (
"vrf,prefix,status,scope_type,scope_name",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}",
),
}
site = sites[0].pk
cls.csv_data = (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
)
cls.csv_update_data = (
"id,description,status",
@@ -540,32 +532,6 @@ scope_id: {site.pk}
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_scope_name(self):
"""
Test YAML-based import using scope_name instead of scope_id.
"""
site = Site.objects.get(name='Site 1')
IMPORT_DATA = """
prefix: 10.1.3.0/24
status: active
scope_type: dcim.site
scope_name: Site 1
"""
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.3.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
@@ -918,20 +884,12 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = {
'default': (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
),
'scope_name': (
"name,slug,scope_type,scope_name,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group",
),
}
cls.csv_data = (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
)
cls.csv_update_data = (
"id,name,description",
+2 -11
View File
@@ -78,21 +78,12 @@ def annotate_ip_space(prefix):
records = sorted(records, key=lambda x: x[0])
# Determine the first & last valid IP addresses in the prefix
if (
prefix.is_pool
or (prefix.family == 4 and prefix.mask_length >= 31)
or (prefix.family == 6 and prefix.mask_length >= 127)
):
# Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
elif prefix.family == 4:
if prefix.family == 4 and prefix.mask_length < 31 and not prefix.is_pool:
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
else:
# For IPv6 prefixes, omit the Subnet-Router anycast address (RFC 4291)
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
if not records:
@@ -53,11 +53,8 @@ class TaggableModelSerializer(serializers.Serializer):
def _save_tags(self, instance, tags):
if tags:
# Cache tags on instance so serialize_object() can reuse them without a DB query
instance._tags = tags
instance.tags.set([t.name for t in tags])
else:
instance._tags = []
instance.tags.clear()
return instance
+1 -11
View File
@@ -15,7 +15,6 @@ from strawberry_django import (
DatetimeFilterLookup,
FilterLookup,
RangeLookup,
StrFilterLookup,
TimeFilterLookup,
process_filters,
)
@@ -41,7 +40,7 @@ SKIP_MSG = 'Filter will be skipped on `null` value'
@strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
class JSONLookup:
string_lookup: StrFilterLookup[str] | None = strawberry_django.filter_field()
string_lookup: FilterLookup[str] | None = strawberry_django.filter_field()
int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
@@ -79,9 +78,6 @@ class IntegerLookup:
if not filters:
return queryset, Q()
if isinstance(filters, RangeLookup):
prefix = f'{prefix}range__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
@@ -105,9 +101,6 @@ class BigIntegerLookup:
if not filters:
return queryset, Q()
if isinstance(filters, RangeLookup):
prefix = f'{prefix}range__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
@@ -131,9 +124,6 @@ class FloatLookup:
if not filters:
return queryset, Q()
if isinstance(filters, RangeLookup):
prefix = f'{prefix}range__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated, TypeVar
import strawberry
import strawberry_django
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
__all__ = (
'DistanceFilterMixin',
@@ -48,7 +48,7 @@ class SyncedDataFilterMixin:
strawberry_django.filter_field()
)
data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
data_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
data_path: FilterLookup[str] | None = strawberry_django.filter_field()
auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+10 -10
View File
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
import strawberry_django
from strawberry import ID
from strawberry_django import ComparisonFilterLookup, StrFilterLookup
from strawberry_django import ComparisonFilterLookup, FilterLookup
from core.graphql.filter_mixins import ChangeLoggingMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
@@ -42,21 +42,21 @@ class NetBoxModelFilter(
@dataclass
class NestedGroupModelFilter(NetBoxModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
parent_id: ID | None = strawberry_django.filter_field()
@dataclass
class OrganizationalModelFilter(NetBoxModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
class PrimaryModelFilter(NetBoxModelFilter):
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
-12
View File
@@ -2,8 +2,6 @@ import strawberry
from strawberry.types.unset import UNSET
from strawberry_django.pagination import _QS, apply
from netbox.config import get_config
__all__ = (
'OffsetPaginationInfo',
'OffsetPaginationInput',
@@ -49,14 +47,4 @@ def apply_pagination(
# Ignore `offset` when `start` is set
pagination.offset = 0
# Enforce MAX_PAGE_SIZE on the pagination limit
max_page_size = get_config().MAX_PAGE_SIZE
if max_page_size:
if pagination is None:
pagination = OffsetPaginationInput(limit=max_page_size)
elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
pagination.limit = max_page_size
elif pagination.limit <= 0:
pagination.limit = max_page_size
return apply(pagination, queryset, related_field_id=related_field_id)
+9 -18
View File
@@ -40,24 +40,15 @@ class CoreMiddleware:
with apply_request_processors(request):
response = self.get_response(request)
# Set or renew the language cookie based on the user's preference. This handles two cases:
# 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
# the request so we set the cookie here on the login response.
# 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
# the session expiry.
if hasattr(request, '_language_cookie'):
language = request._language_cookie
elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
language = request.user.config.get('locale.language')
else:
language = None
if language:
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id
+1 -3
View File
@@ -15,7 +15,6 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.models import ObjectType
from extras.choices import *
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.managers import NetBoxTaggableManager
from extras.utils import is_taggable
from netbox.config import get_config
from netbox.constants import CORE_APPS
@@ -488,12 +487,11 @@ class JournalingMixin(models.Model):
class TagsMixin(models.Model):
"""
Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
which is a `NetBoxTaggableManager` instance.
which is a `TaggableManager` instance.
"""
tags = TaggableManager(
through='extras.TaggedItem',
ordering=('weight', 'name'),
manager=NetBoxTaggableManager,
)
class Meta:
+1 -82
View File
@@ -5,7 +5,7 @@ from django.urls import reverse
from rest_framework import status
from dcim.choices import LocationStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
from dcim.models import Location, Site
from utilities.testing import APITestCase, TestCase, disable_warnings
@@ -138,40 +138,6 @@ class GraphQLAPITestCase(APITestCase):
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site']['locations']), 0)
def test_graphql_integer_range_lookup(self):
"""
Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
"""
self.add_permissions('dcim.view_device')
url = reverse('graphql')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
device_role = DeviceRole.objects.create(name='Test Role', slug='test-role')
site = Site.objects.first()
vc = VirtualChassis.objects.create(name='Test VC')
devices = [
Device(name=f'Device {i}', device_type=device_type, role=device_role, site=site,
virtual_chassis=vc, vc_position=i)
for i in range(1, 6)
]
Device.objects.bulk_create(devices)
# range_lookup should return devices with vc_position between 2 and 4 inclusive
query = """
{
device_list(filters: {vc_position: {range_lookup: {start: 2, end: 4}}}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['device_list']), 3)
def test_offset_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
@@ -283,53 +249,6 @@ class GraphQLAPITestCase(APITestCase):
self.assertEqual(len(data['data']['site_list']), 1)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
@override_settings(MAX_PAGE_SIZE=3)
def test_max_page_size(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
# Request without explicit limit should be capped by MAX_PAGE_SIZE
query = """
{
site_list {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
# Request with limit exceeding MAX_PAGE_SIZE should be capped
query = """
{
site_list(pagination: {limit: 100}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
# Request with limit under MAX_PAGE_SIZE should be respected
query = """
{
site_list(pagination: {limit: 2}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 2)
def test_pagination_conflict(self):
url = reverse('graphql')
query = """
-21
View File
@@ -10,7 +10,6 @@ __all__ = (
'BooleanAttr',
'ChoiceAttr',
'ColorAttr',
'DateTimeAttr',
'GPSCoordinatesAttr',
'GenericForeignKeyAttr',
'ImageAttr',
@@ -368,26 +367,6 @@ class GPSCoordinatesAttr(ObjectAttribute):
})
class DateTimeAttr(ObjectAttribute):
"""
A date or datetime attribute.
Parameters:
spec (str): Controls the rendering format. Use 'date' for date-only rendering,
or 'seconds'/'minutes' for datetime rendering with the given precision.
"""
template_name = 'ui/attrs/datetime.html'
def __init__(self, *args, spec='seconds', **kwargs):
super().__init__(*args, **kwargs)
self.spec = spec
def get_context(self, obj, context):
return {
'spec': self.spec,
}
class TimezoneAttr(ObjectAttribute):
"""
A timezone value. Includes the numeric offset from UTC.
+4 -6
View File
@@ -44,18 +44,15 @@ class Panel:
Parameters:
title (str): The human-friendly title of the panel
actions (list): An iterable of PanelActions to include in the panel header
template_name (str): Overrides the default template name, if defined
"""
template_name = None
title = None
actions = None
def __init__(self, title=None, actions=None, template_name=None):
def __init__(self, title=None, actions=None):
if title is not None:
self.title = title
self.actions = actions or self.actions or []
if template_name is not None:
self.template_name = template_name
def get_context(self, context):
"""
@@ -320,8 +317,9 @@ class TemplatePanel(Panel):
Parameters:
template_name (str): The name of the template to render
"""
def __init__(self, template_name):
super().__init__(template_name=template_name)
def __init__(self, template_name, **kwargs):
super().__init__(**kwargs)
self.template_name = template_name
def render(self, context):
# Pass the entire context to the template
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10 -13
View File
@@ -31,36 +31,33 @@
"gridstack": "12.4.2",
"htmx.org": "2.0.8",
"query-string": "9.3.1",
"sass": "1.98.0",
"tom-select": "2.5.2",
"sass": "1.97.3",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"
},
"devDependencies": {
"@eslint/compat": "^2.0.3",
"@eslint/eslintrc": "^3.3.5",
"@eslint/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@types/bootstrap": "5.2.10",
"@types/cookie": "^1.0.0",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^8.57.0",
"esbuild": "^0.27.4",
"esbuild-sass-plugin": "^3.7.0",
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"esbuild": "^0.27.3",
"esbuild-sass-plugin": "^3.6.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.5",
"globals": "^17.4.0",
"globals": "^17.3.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
},
"resolutions": {
"@types/bootstrap/**/@popperjs/core": "^2.11.6",
"eslint/**/minimatch": "^3.1.3",
"eslint-plugin-import/**/minimatch": "^3.1.3",
"**/markdown-it": "^14.1.1"
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+6 -1
View File
@@ -20,7 +20,12 @@ function storeColorMode(mode: ColorMode): void {
}
function updateElements(targetMode: ColorMode): void {
document.documentElement.setAttribute('data-bs-theme', targetMode);
const body = document.querySelector('body');
if (body && targetMode == 'dark') {
body.setAttribute('data-bs-theme', 'dark');
} else if (body) {
body.setAttribute('data-bs-theme', 'light');
}
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const svg = elevation.firstElementChild ?? null;
@@ -1,16 +1,17 @@
import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
import { RecursivePartial, TomOption, TomSettings } from 'tom-select/dist/types/types';
import { TomInput } from 'tom-select/dist/cjs/types/core';
import { addClasses } from 'tom-select/src/vanilla.ts';
import queryString from 'query-string';
import TomSelect from 'tom-select';
import type { Stringifiable } from 'query-string';
import { DynamicParamsMap } from './dynamicParamsMap';
import { NetBoxTomSelect } from './netboxTomSelect';
// Transitional
import { QueryFilter, PathFilter } from '../types';
import { getElement, replaceAll } from '../../util';
// Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends NetBoxTomSelect {
// Extends TomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends TomSelect {
public readonly nullOption: Nullable<TomOption> = null;
// Transitional code from APISelect
@@ -71,7 +72,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
this.addEventListeners();
}
load(value: string, preserveValue?: string | string[]) {
load(value: string) {
const self = this;
// Automatically clear any cached options. (Only options included
@@ -107,14 +108,6 @@ export class DynamicTomSelect extends NetBoxTomSelect {
// Pass the options to the callback function
.then(options => {
self.loadCallback(options, []);
// Restore the previous selection if it is still valid under the new filter.
if (preserveValue !== undefined) {
const values = Array.isArray(preserveValue) ? preserveValue : [preserveValue];
const validValues = values.filter(v => v !== '' && v in self.options);
if (validValues.length > 0) {
self.setValue(validValues.length === 1 ? validValues[0] : validValues, true);
}
}
})
.catch(() => {
self.loadCallback([], []);
@@ -346,9 +339,6 @@ export class DynamicTomSelect extends NetBoxTomSelect {
private handleEvent(event: Event): void {
const target = event.target as HTMLSelectElement;
// Save the current selection so we can restore it after loading if it remains valid.
const previousValue = this.getValue();
// Update the element's URL after any changes to a dependency.
this.updateQueryParams(target.name);
this.updatePathValues(target.name);
@@ -356,8 +346,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
// Clear any previous selection(s) as the parent filter has changed
this.clear();
// Load new data, restoring the previous selection if it is still valid under the new filter.
const preserve = previousValue !== '' && previousValue !== null ? previousValue : undefined;
this.load(this.lastValue, preserve);
// Load new data.
this.load(this.lastValue);
}
}
@@ -1,39 +0,0 @@
import TomSelect from 'tom-select';
/**
* Extends TomSelect to work around a browser autofill bug where Edge's "last used" autofill
* simultaneously focuses multiple inputs, triggering a cascading focus/open/blur loop between
* TomSelect instances.
*
* Root cause: TomSelect's open() method calls focus(), which synchronously moves browser focus
* to this instance's control input, then schedules setTimeout(onFocus, 0). When Edge autofill
* has moved focus to a *different* select before the timeout fires, the delayed onFocus() call
* re-steals browser focus back, causing the other instance to blur and close. Each instance's
* deferred callback then repeats this, creating an infinite ping-pong loop.
*
* Fix: in the setTimeout callback, only proceed with onFocus() if this instance's element is
* still the active element. If focus has already moved elsewhere, skip the call.
*
* Upstream bug: https://github.com/orchidjs/tom-select/issues/806
* NetBox issue: https://github.com/netbox-community/netbox/issues/20077
*/
export class NetBoxTomSelect extends TomSelect {
focus(): void {
if (this.isDisabled || this.isReadOnly) return;
this.ignoreFocus = true;
const focusTarget = this.control_input.offsetWidth ? this.control_input : this.focus_node;
focusTarget.focus();
setTimeout(() => {
this.ignoreFocus = false;
// Only proceed if this instance's element is still the active element. If Edge autofill
// (or anything else) has moved focus to a different element in the interim, calling
// onFocus() here would steal focus back and restart the cascade loop.
if (document.activeElement === focusTarget || this.control.contains(document.activeElement)) {
this.onFocus();
}
}, 0);
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { TomOption } from 'tom-select/src/types';
import TomSelect from 'tom-select';
import { escape_html } from 'tom-select/src/utils';
import { NetBoxTomSelect } from './classes/netboxTomSelect';
import { getPlugins } from './config';
import { getElements } from '../util';
@@ -9,7 +9,7 @@ export function initStaticSelects(): void {
for (const select of getElements<HTMLSelectElement>(
'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
)) {
new NetBoxTomSelect(select, {
new TomSelect(select, {
...getPlugins(select),
maxOptions: undefined,
});
@@ -25,7 +25,7 @@ export function initColorSelects(): void {
}
for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
new NetBoxTomSelect(select, {
new TomSelect(select, {
...getPlugins(select),
maxOptions: undefined,
render: {
@@ -112,7 +112,7 @@ img.plugin-icon {
}
html[data-bs-theme=dark] {
body[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {
filter: grayscale(100%) invert(100%) brightness(80%);
@@ -93,7 +93,7 @@ pre {
}
// Dark mode overrides
html[data-bs-theme=dark] {
body[data-bs-theme=dark] {
// Override background color alpha value
::selection {
background-color: rgba(var(--tblr-primary-rgb),.48);
@@ -174,11 +174,16 @@ pre code {
}
// Theme-based visibility utilities
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {
// Tabler's .hide-theme-* utilities expect data-bs-theme on :root, but NetBox applies
// it to body. These overrides use higher specificity selectors to ensure theme-based
// visibility works correctly. The :root:not(.dummy) pattern provides the additional
// specificity needed to override Tabler's :root:not() rules.
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-dark {
display: none !important;
}
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-dark {
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-dark {
display: inline-flex !important;
}
@@ -77,13 +77,13 @@
}
// Light theme styling
html[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
body[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
// Background Gradient
background: linear-gradient(180deg, rgba(0, 133, 125, 0.00) 0%, rgba(0, 133, 125, 0.10) 100%), #FFF;
}
// Dark theme styling
html[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
// Background Gradient
background: linear-gradient(180deg, rgba(0, 242, 212, 0.00) 0%, rgba(0, 242, 212, 0.10) 100%), #001423;
@@ -59,7 +59,7 @@ table th.orderable a {
color: var(--#{$prefix}body-color);
}
html[data-bs-theme=dark] {
body[data-bs-theme=dark] {
// Adjust table header background color
.table thead th, .markdown>table thead th {
background: $rich-black !important;
+261 -280
View File
@@ -24,135 +24,135 @@
dependencies:
tslib "^2.4.0"
"@esbuild/aix-ppc64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz#4c585002f7ad694d38fe0e8cbf5cfd939ccff327"
integrity sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==
"@esbuild/aix-ppc64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
"@esbuild/android-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz#7625d0952c3b402d3ede203a16c9f2b78f8a4827"
integrity sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==
"@esbuild/android-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8"
integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
"@esbuild/android-arm@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.4.tgz#9a0cf1d12997ec46dddfb32ce67e9bca842381ac"
integrity sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==
"@esbuild/android-arm@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b"
integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
"@esbuild/android-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.4.tgz#06e1fdc6283fccd6bc6aadd6754afce6cf96f42e"
integrity sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==
"@esbuild/android-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac"
integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
"@esbuild/darwin-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz#6c550ee6c0273bcb0fac244478ff727c26755d80"
integrity sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==
"@esbuild/darwin-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd"
integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
"@esbuild/darwin-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz#ed7a125e9f25ce0091b9aff783ee943f6ba6cb86"
integrity sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==
"@esbuild/darwin-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a"
integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
"@esbuild/freebsd-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz#597dc8e7161dba71db4c1656131c1f1e9d7660c6"
integrity sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==
"@esbuild/freebsd-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b"
integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
"@esbuild/freebsd-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz#ea171f9f4f00efaa8e9d3fe8baa1b75d757d1b36"
integrity sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==
"@esbuild/freebsd-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead"
integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
"@esbuild/linux-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz#e52d57f202369386e6dbcb3370a17a0491ab1464"
integrity sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==
"@esbuild/linux-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6"
integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
"@esbuild/linux-arm@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz#5e0c0b634908adbce0a02cebeba8b3acac263fb6"
integrity sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==
"@esbuild/linux-arm@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11"
integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
"@esbuild/linux-ia32@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz#5f90f01f131652473ec06b038a14c49683e14ec7"
integrity sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==
"@esbuild/linux-ia32@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29"
integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
"@esbuild/linux-loong64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz#63bacffdb99574c9318f9afbd0dd4fff76a837e3"
integrity sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==
"@esbuild/linux-loong64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed"
integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
"@esbuild/linux-mips64el@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz#c4b6952eca6a8efff67fee3671a3536c8e67b7eb"
integrity sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==
"@esbuild/linux-mips64el@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1"
integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
"@esbuild/linux-ppc64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz#6dea67d3d98c6986f1b7769e4f1848e5ae47ad58"
integrity sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==
"@esbuild/linux-ppc64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78"
integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
"@esbuild/linux-riscv64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz#9ad2b4c3c0502c6bada9c81997bb56c597853489"
integrity sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==
"@esbuild/linux-riscv64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d"
integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
"@esbuild/linux-s390x@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz#c43d3cfd073042ca6f5c52bb9bc313ed2066ce28"
integrity sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==
"@esbuild/linux-s390x@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d"
integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
"@esbuild/linux-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz#45fa173e0591ac74d80d3cf76704713e14e2a4a6"
integrity sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==
"@esbuild/linux-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5"
integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
"@esbuild/netbsd-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz#366b0ef40cdb986fc751cbdad16e8c25fe1ba879"
integrity sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==
"@esbuild/netbsd-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7"
integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
"@esbuild/netbsd-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz#e985d49a3668fd2044343071d52e1ae815112b3e"
integrity sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==
"@esbuild/netbsd-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b"
integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
"@esbuild/openbsd-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz#6fb4ab7b73f7e5572ce5ec9cf91c13ff6dd44842"
integrity sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==
"@esbuild/openbsd-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5"
integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
"@esbuild/openbsd-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz#641f052040a0d79843d68898f5791638a026d983"
integrity sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==
"@esbuild/openbsd-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b"
integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
"@esbuild/openharmony-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz#fc1d33eac9d81ae0a433b3ed1dd6171a20d4e317"
integrity sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==
"@esbuild/openharmony-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e"
integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
"@esbuild/sunos-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz#af2cd5ca842d6d057121f66a192d4f797de28f53"
integrity sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==
"@esbuild/sunos-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537"
integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
"@esbuild/win32-arm64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz#78ec7e59bb06404583d4c9511e621db31c760de3"
integrity sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==
"@esbuild/win32-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e"
integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
"@esbuild/win32-ia32@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz#0e616aa488b7ee5d2592ab070ff9ec06a9fddf11"
integrity sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==
"@esbuild/win32-ia32@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c"
integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
"@esbuild/win32-x64@0.27.4":
version "0.27.4"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz#1f7ba71a3d6155d44a6faa8dbe249c62ab3e408c"
integrity sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==
"@esbuild/win32-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
"@eslint-community/eslint-utils@^4.8.0":
version "4.9.0"
@@ -173,12 +173,12 @@
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/compat@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.3.tgz#860bdd23d0df1c71a8d751f0aa1430e05bc056dd"
integrity sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==
"@eslint/compat@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.2.tgz#fc1495688664861870f5e7ee56999dc252b6dd52"
integrity sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==
dependencies:
"@eslint/core" "^1.1.1"
"@eslint/core" "^1.1.0"
"@eslint/config-array@^0.21.1":
version "0.21.1"
@@ -203,14 +203,14 @@
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/core@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32"
integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==
"@eslint/core@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/eslintrc@^3.3.1":
"@eslint/eslintrc@^3.3.1", "@eslint/eslintrc@^3.3.3":
version "3.3.3"
resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz"
integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==
@@ -225,21 +225,6 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/eslintrc@^3.3.5":
version "3.3.5"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60"
integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==
dependencies:
ajv "^6.14.0"
debug "^4.3.2"
espree "^10.0.1"
globals "^14.0.0"
ignore "^5.2.0"
import-fresh "^3.2.1"
js-yaml "^4.1.1"
minimatch "^3.1.5"
strip-json-comments "^3.1.1"
"@eslint/js@9.39.2", "@eslint/js@^9.39.2":
version "9.39.2"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599"
@@ -950,100 +935,100 @@
dependencies:
"@types/estree" "*"
"@typescript-eslint/eslint-plugin@^8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz#6e4085604ab63f55b3dcc61ce2c16965b2c36374"
integrity sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==
"@typescript-eslint/eslint-plugin@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.57.0"
"@typescript-eslint/type-utils" "8.57.0"
"@typescript-eslint/utils" "8.57.0"
"@typescript-eslint/visitor-keys" "8.57.0"
"@typescript-eslint/scope-manager" "8.56.0"
"@typescript-eslint/type-utils" "8.56.0"
"@typescript-eslint/utils" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.4.0"
"@typescript-eslint/parser@^8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.57.0.tgz#444c57a943e8b04f255cda18a94c8e023b46b08c"
integrity sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==
"@typescript-eslint/parser@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
dependencies:
"@typescript-eslint/scope-manager" "8.57.0"
"@typescript-eslint/types" "8.57.0"
"@typescript-eslint/typescript-estree" "8.57.0"
"@typescript-eslint/visitor-keys" "8.57.0"
"@typescript-eslint/scope-manager" "8.56.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/typescript-estree" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.0.tgz#2014ed527bcd0eff8aecb7e44879ae3150604ab3"
integrity sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==
"@typescript-eslint/project-service@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.57.0"
"@typescript-eslint/types" "^8.57.0"
"@typescript-eslint/tsconfig-utils" "^8.56.0"
"@typescript-eslint/types" "^8.56.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz#7d2a2aeaaef2ae70891b21939fadb4cb0b19f840"
integrity sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==
"@typescript-eslint/scope-manager@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
dependencies:
"@typescript-eslint/types" "8.57.0"
"@typescript-eslint/visitor-keys" "8.57.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
"@typescript-eslint/tsconfig-utils@8.57.0", "@typescript-eslint/tsconfig-utils@^8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz#cf2f2822af3887d25dd325b6bea6c3f60a83a0b4"
integrity sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==
"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
"@typescript-eslint/type-utils@8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz#2877af4c2e8f0998b93a07dad1c34ce1bb669448"
integrity sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==
"@typescript-eslint/type-utils@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
dependencies:
"@typescript-eslint/types" "8.57.0"
"@typescript-eslint/typescript-estree" "8.57.0"
"@typescript-eslint/utils" "8.57.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/typescript-estree" "8.56.0"
"@typescript-eslint/utils" "8.56.0"
debug "^4.4.3"
ts-api-utils "^2.4.0"
"@typescript-eslint/types@8.57.0", "@typescript-eslint/types@^8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.0.tgz#4fa5385ffd1cd161fa5b9dce93e0493d491b8dc6"
integrity sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==
"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
"@typescript-eslint/typescript-estree@8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz#e0e4a89bfebb207de314826df876e2dabc7dea04"
integrity sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==
"@typescript-eslint/typescript-estree@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
dependencies:
"@typescript-eslint/project-service" "8.57.0"
"@typescript-eslint/tsconfig-utils" "8.57.0"
"@typescript-eslint/types" "8.57.0"
"@typescript-eslint/visitor-keys" "8.57.0"
"@typescript-eslint/project-service" "8.56.0"
"@typescript-eslint/tsconfig-utils" "8.56.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
debug "^4.4.3"
minimatch "^10.2.2"
minimatch "^9.0.5"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.4.0"
"@typescript-eslint/utils@8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.0.tgz#c7193385b44529b788210d20c94c11de79ad3498"
integrity sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==
"@typescript-eslint/utils@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.57.0"
"@typescript-eslint/types" "8.57.0"
"@typescript-eslint/typescript-estree" "8.57.0"
"@typescript-eslint/scope-manager" "8.56.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/typescript-estree" "8.56.0"
"@typescript-eslint/visitor-keys@8.57.0":
version "8.57.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz#23aea662279bb66209700854453807a119350f85"
integrity sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==
"@typescript-eslint/visitor-keys@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
dependencies:
"@typescript-eslint/types" "8.57.0"
"@typescript-eslint/types" "8.56.0"
eslint-visitor-keys "^5.0.0"
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1163,16 +1148,6 @@ ajv@^6.12.4:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ajv@^6.14.0:
version "6.14.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a"
integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
@@ -1299,11 +1274,6 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
balanced-match@^4.0.2:
version "4.0.4"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
bootstrap@5.3.7:
version "5.3.7"
resolved "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz"
@@ -1322,12 +1292,12 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
brace-expansion@^5.0.2:
version "5.0.4"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336"
integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==
brace-expansion@^2.0.1:
version "2.0.2"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
dependencies:
balanced-match "^4.0.2"
balanced-match "^1.0.0"
braces@^3.0.3:
version "3.0.3"
@@ -1794,45 +1764,45 @@ es-to-primitive@^1.3.0:
is-date-object "^1.0.5"
is-symbol "^1.0.4"
esbuild-sass-plugin@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-3.7.0.tgz#58883053252390b4ef9e4b044baf84daec97b698"
integrity sha512-vxNSXFx3/0ZFApKo9036ek2iRfsT+yVO99qIYqa+JaDSuJuId2/N4s1TY+xfK+5LRpAMQkfdBVUTxb/1r2bq1A==
esbuild-sass-plugin@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-3.6.0.tgz#6e93d0aec87b6ab7bde2e459c5f1ab472088bd41"
integrity sha512-lzPJQSEXcnj5amBPPib5lBjsDNPzvdMnX+1Rf7eha9BIpLSM5Ad2pi+Rqg5CAlWMduCgLntS2hLAqG7v1fxWGw==
dependencies:
resolve "^1.22.11"
sass "^1.97.3"
sass "^1.97.2"
esbuild@^0.27.4:
version "0.27.4"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.4.tgz#b9591dd7e0ab803a11c9c3b602850403bef22f00"
integrity sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==
esbuild@^0.27.3:
version "0.27.3"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8"
integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
optionalDependencies:
"@esbuild/aix-ppc64" "0.27.4"
"@esbuild/android-arm" "0.27.4"
"@esbuild/android-arm64" "0.27.4"
"@esbuild/android-x64" "0.27.4"
"@esbuild/darwin-arm64" "0.27.4"
"@esbuild/darwin-x64" "0.27.4"
"@esbuild/freebsd-arm64" "0.27.4"
"@esbuild/freebsd-x64" "0.27.4"
"@esbuild/linux-arm" "0.27.4"
"@esbuild/linux-arm64" "0.27.4"
"@esbuild/linux-ia32" "0.27.4"
"@esbuild/linux-loong64" "0.27.4"
"@esbuild/linux-mips64el" "0.27.4"
"@esbuild/linux-ppc64" "0.27.4"
"@esbuild/linux-riscv64" "0.27.4"
"@esbuild/linux-s390x" "0.27.4"
"@esbuild/linux-x64" "0.27.4"
"@esbuild/netbsd-arm64" "0.27.4"
"@esbuild/netbsd-x64" "0.27.4"
"@esbuild/openbsd-arm64" "0.27.4"
"@esbuild/openbsd-x64" "0.27.4"
"@esbuild/openharmony-arm64" "0.27.4"
"@esbuild/sunos-x64" "0.27.4"
"@esbuild/win32-arm64" "0.27.4"
"@esbuild/win32-ia32" "0.27.4"
"@esbuild/win32-x64" "0.27.4"
"@esbuild/aix-ppc64" "0.27.3"
"@esbuild/android-arm" "0.27.3"
"@esbuild/android-arm64" "0.27.3"
"@esbuild/android-x64" "0.27.3"
"@esbuild/darwin-arm64" "0.27.3"
"@esbuild/darwin-x64" "0.27.3"
"@esbuild/freebsd-arm64" "0.27.3"
"@esbuild/freebsd-x64" "0.27.3"
"@esbuild/linux-arm" "0.27.3"
"@esbuild/linux-arm64" "0.27.3"
"@esbuild/linux-ia32" "0.27.3"
"@esbuild/linux-loong64" "0.27.3"
"@esbuild/linux-mips64el" "0.27.3"
"@esbuild/linux-ppc64" "0.27.3"
"@esbuild/linux-riscv64" "0.27.3"
"@esbuild/linux-s390x" "0.27.3"
"@esbuild/linux-x64" "0.27.3"
"@esbuild/netbsd-arm64" "0.27.3"
"@esbuild/netbsd-x64" "0.27.3"
"@esbuild/openbsd-arm64" "0.27.3"
"@esbuild/openbsd-x64" "0.27.3"
"@esbuild/openharmony-arm64" "0.27.3"
"@esbuild/sunos-x64" "0.27.3"
"@esbuild/win32-arm64" "0.27.3"
"@esbuild/win32-ia32" "0.27.3"
"@esbuild/win32-x64" "0.27.3"
escape-string-regexp@^4.0.0:
version "4.0.0"
@@ -2219,10 +2189,10 @@ globals@^14.0.0:
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.4.0:
version "17.4.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.4.0.tgz#33d7d297ed1536b388a0e2f4bcd0ff19c8ff91b5"
integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==
globals@^17.3.0:
version "17.3.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9"
integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==
globalthis@^1.0.3, globalthis@^1.0.4:
version "1.0.4"
@@ -2353,10 +2323,10 @@ ignore@^7.0.5:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
immutable@^5.1.5:
version "5.1.5"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165"
integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==
immutable@^5.0.2:
version "5.0.3"
resolved "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz"
integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==
import-fresh@^3.2.1:
version "3.3.1"
@@ -2779,10 +2749,10 @@ loose-envify@^1.1.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
markdown-it@^14.1.0, markdown-it@^14.1.1:
version "14.1.1"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.1.tgz#856f90b66fc39ae70affd25c1b18b581d7deee1f"
integrity sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==
markdown-it@^14.1.0:
version "14.1.0"
resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz"
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
dependencies:
argparse "^2.0.1"
entities "^4.4.0"
@@ -2814,20 +2784,20 @@ micromatch@^4.0.5:
braces "^3.0.3"
picomatch "^2.3.1"
minimatch@^10.2.2:
version "10.2.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
dependencies:
brace-expansion "^5.0.2"
minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"
minimatch@^9.0.5:
version "9.0.5"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
@@ -3207,13 +3177,24 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
sass@1.98.0, sass@^1.97.3:
version "1.98.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.98.0.tgz#924ce85a3745ccaccd976262fdc1bc0c13aa8e57"
integrity sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==
sass@1.97.3:
version "1.97.3"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2"
integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==
dependencies:
chokidar "^4.0.0"
immutable "^5.1.5"
immutable "^5.0.2"
source-map-js ">=0.6.2 <2.0.0"
optionalDependencies:
"@parcel/watcher" "^2.4.1"
sass@^1.97.2:
version "1.97.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"
source-map-js ">=0.6.2 <2.0.0"
optionalDependencies:
"@parcel/watcher" "^2.4.1"
@@ -3474,10 +3455,10 @@ toggle-selection@^1.0.6:
resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
tom-select@2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.5.2.tgz#77dd4bc780b1ea72905337b24f04ce19dc6d2ca1"
integrity sha512-VAlGj5MBWVLMJje2NwA3XSmxa7CUFpp1tdzFZ8wymCkcLeP0NwF4ARmSuUK4BWbmSN1fETlSazWkMIxEpP4GdQ==
tom-select@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
dependencies:
"@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2"
+2 -2
View File
@@ -1,3 +1,3 @@
version: "4.5.5"
version: "4.5.3"
edition: "Community"
published: "2026-03-17"
published: "2026-02-17"
+1 -1
View File
@@ -28,7 +28,7 @@
</div>
</div>
<div class="card table-responsive">
<div class="card">
{% render_table table %}
</div>
{% endblock content %}
+64
View File
@@ -15,3 +15,67 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Device Role" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Color" %}</th>
<td>
<span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td>
</tr>
<tr>
<th scope="row">{% trans "VM Role" %}</th>
<td>{% checkmark object.vm_role %}</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Device Roles" %}
{% if perms.dcim.add_devicerole %}
<div class="card-actions">
<a href="{% url 'dcim:devicerole_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device Role" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:devicerole_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+1 -9
View File
@@ -86,11 +86,6 @@
<th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
<td>{{ object.qinq_svlan|linkify|placeholder }}</td>
</tr>
{% elif object.mode %}
<tr>
<th scope="row">{% trans "Untagged VLAN" %}</th>
<td>{{ object.untagged_vlan|linkify|placeholder }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Transmit power (dBm)" %}</th>
@@ -416,10 +411,7 @@
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "VLANs" %}</h2>
{% htmx_table 'ipam:vlan_list' interface_id=object.pk %}
</div>
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
</div>
</div>
{% if object.is_lag %}
+72
View File
@@ -46,3 +46,75 @@
</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device Type" %}</th>
<td>{{ object.device.device_type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Module Bay" %}</th>
<td>{% nested_tree object.module_bay %}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.module_type.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model" %}</th>
<td>{{ object.module_type|linkify }}</td>
</tr>
{% for k, v in object.module_type.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>{{ v|placeholder }}</td>
</tr>
{% endfor %}
</table>
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+91 -1
View File
@@ -1,4 +1,7 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
@@ -11,5 +14,92 @@
{% endblock %}
{% block extra_controls %}
{% include 'dcim/inc/moduletype_buttons.html' %}
{% include 'dcim/inc/moduletype_buttons.html' %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Profile" %}</th>
<td>{{ object.profile|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model Name" %}</th>
<td>{{ object.model }}</td>
</tr>
<tr>
<th scope="row">{% trans "Part Number" %}</th>
<td>{{ object.part_number|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Attributes" %}</h2>
{% if not object.profile %}
<div class="card-body text-muted">
{% trans "No profile assigned" %}
</div>
{% elif object.attributes %}
<table class="table table-hover attr-table">
{% for k, v in object.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>
{% if v is True or v is False %}
{% checkmark v %}
{% else %}
{{ v|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "None" %}
</div>
{% endif %}
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,27 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.module_type.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model" %}</th>
<td>{{ object.module_type|linkify }}</td>
</tr>
{% for k, v in object.module_type.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>
{% if v is True or v is False %}
{% checkmark v %}
{% else %}
{{ v|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock panel_content %}
@@ -1,29 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
{% if not object.profile %}
<div class="card-body text-muted">
{% trans "No profile assigned" %}
</div>
{% elif object.attributes %}
<table class="table table-hover attr-table">
{% for k, v in object.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>
{% if v is True or v is False %}
{% checkmark v %}
{% else %}
{{ v|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "None" %}
</div>
{% endif %}
{% endblock panel_content %}
+58
View File
@@ -18,3 +18,61 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Platform" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Platforms" %}
{% if perms.dcim.add_platform %}
<div class="card-actions">
<a href="{% url 'dcim:platform_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Platform" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:platform_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+1 -3
View File
@@ -15,9 +15,7 @@
{% endblock tabs %}
{% block controls %}
{% if perms.core.add_managedfile and perms.extras.add_scriptmodule %}
{% add_button model %}
{% endif %}
{% add_button model %}
{% endblock controls %}
{% block content %}
+1 -1
View File
@@ -92,7 +92,7 @@ Context:
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" id="object-list-return-url" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Warn of any missing prerequisite objects #}
{% if prerequisite_model %}
-5
View File
@@ -32,9 +32,4 @@
{% action_buttons actions model multi=True %}
</div>
{% endif %}
{# Update the return_url to reflect any changed query parameters (e.g. per_page) #}
{% if not table.embedded %}
<input type="hidden" id="object-list-return-url" name="return_url" value="{{ request.get_full_path }}" hx-swap-oob="outerHTML:#object-list-return-url" />
{% endif %}
{% endif %}
-1
View File
@@ -1 +0,0 @@
{% load helpers %}{% if spec == 'date' %}{{ value|isodate }}{% else %}{{ value|isodatetime:spec }}{% endif %}
@@ -1 +0,0 @@
{% load helpers %}{{ object.get_full_name|placeholder }}
+57
View File
@@ -1,3 +1,60 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
<div class="list-group list-group-flush">
{% for perm in object.object_permissions.all %}
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Owner Membership" %}</h2>
<div class="list-group list-group-flush">
{% for owner in object.owners.all %}
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -1,5 +1,93 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Permission" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Actions" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{% checkmark object.can_view %}</td>
</tr>
<tr>
<th scope="row">{% trans "Add" %}</th>
<td>{% checkmark object.can_add %}</td>
</tr>
<tr>
<th scope="row">{% trans "Change" %}</th>
<td>{% checkmark object.can_change %}</td>
</tr>
<tr>
<th scope="row">{% trans "Delete" %}</th>
<td>{% checkmark object.can_delete %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Constraints" %}</h2>
<div class="card-body">
{% if object.constraints %}
<pre>{{ object.constraints|json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Object Types" %}</h2>
<ul class="list-group list-group-flush">
{% for user in object.object_types.all %}
<li class="list-group-item">{{ user }}</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
+47
View File
@@ -11,3 +11,50 @@
{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Owner" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.user_groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='owner_id' %}
</div>
</div>
{% endblock %}
+43
View File
@@ -1,3 +1,46 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block subtitle %}{% endblock %}
{% block extra_controls %}
{% if perms.users.add_owner %}
<a href="{% url 'users:owner_add' %}?group={{ object.pk }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Owner" %}
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Members" %}</h2>
<div class="list-group list-group-flush">
{% for owner in object.members.all %}
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

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