Sfoglia il codice sorgente

Merge branch 'main' into feature

Jeremy Stretch 1 giorno fa
parent
commit
53ea48efa9
81 ha cambiato i file con 6343 aggiunte e 5747 eliminazioni
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  4. 44 0
      .github/workflows/claude-code-review.yml
  5. 50 0
      .github/workflows/claude.yml
  6. 84 0
      CLAUDE.md
  7. 5 2
      base_requirements.txt
  8. 2 0
      contrib/generated_schema.json
  9. 151 107
      contrib/openapi.json
  10. 29 0
      docs/release-notes/version-4.5.md
  11. 13 13
      netbox/circuits/graphql/filters.py
  12. 25 0
      netbox/core/api/schema.py
  13. 12 12
      netbox/core/graphql/filters.py
  14. 3 0
      netbox/dcim/api/serializers_/cables.py
  15. 4 0
      netbox/dcim/choices.py
  16. 1 0
      netbox/dcim/forms/bulk_import.py
  17. 4 0
      netbox/dcim/forms/connections.py
  18. 7 7
      netbox/dcim/graphql/filter_mixins.py
  19. 55 41
      netbox/dcim/graphql/filters.py
  20. 44 0
      netbox/dcim/models/cables.py
  21. 120 0
      netbox/dcim/tests/test_api.py
  22. 2 13
      netbox/dcim/views.py
  23. 53 53
      netbox/extras/graphql/filters.py
  24. 13 1
      netbox/extras/graphql/mixins.py
  25. 2 0
      netbox/ipam/forms/bulk_import.py
  26. 20 20
      netbox/ipam/graphql/filters.py
  27. 5 3
      netbox/ipam/models/ip.py
  28. 129 0
      netbox/ipam/tests/test_tables.py
  29. 11 2
      netbox/ipam/utils.py
  30. 2 1
      netbox/netbox/graphql/filter_lookups.py
  31. 2 2
      netbox/netbox/graphql/filter_mixins.py
  32. 10 10
      netbox/netbox/graphql/filters.py
  33. 0 0
      netbox/project-static/dist/netbox.css
  34. 0 0
      netbox/project-static/dist/netbox.js
  35. 0 0
      netbox/project-static/dist/netbox.js.map
  36. 5 5
      netbox/project-static/package.json
  37. 1 2
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  38. 120 83
      netbox/project-static/yarn.lock
  39. 2 2
      netbox/release.yaml
  40. 8 6
      netbox/templates/dcim/device/attrs/ipaddress.html
  41. 9 9
      netbox/tenancy/graphql/filters.py
  42. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  43. 343 347
      netbox/translations/cs/LC_MESSAGES/django.po
  44. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  45. 343 347
      netbox/translations/da/LC_MESSAGES/django.po
  46. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  47. 343 347
      netbox/translations/de/LC_MESSAGES/django.po
  48. 104 104
      netbox/translations/en/LC_MESSAGES/django.po
  49. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  50. 343 347
      netbox/translations/es/LC_MESSAGES/django.po
  51. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  52. 343 347
      netbox/translations/fr/LC_MESSAGES/django.po
  53. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  54. 343 347
      netbox/translations/it/LC_MESSAGES/django.po
  55. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  56. 342 346
      netbox/translations/ja/LC_MESSAGES/django.po
  57. BIN
      netbox/translations/lv/LC_MESSAGES/django.mo
  58. 343 347
      netbox/translations/lv/LC_MESSAGES/django.po
  59. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  60. 343 347
      netbox/translations/nl/LC_MESSAGES/django.po
  61. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  62. 343 347
      netbox/translations/pl/LC_MESSAGES/django.po
  63. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  64. 343 347
      netbox/translations/pt/LC_MESSAGES/django.po
  65. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  66. 343 347
      netbox/translations/ru/LC_MESSAGES/django.po
  67. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  68. 343 347
      netbox/translations/tr/LC_MESSAGES/django.po
  69. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  70. 343 347
      netbox/translations/uk/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  72. 343 347
      netbox/translations/zh/LC_MESSAGES/django.po
  73. 11 11
      netbox/users/graphql/filters.py
  74. 2 0
      netbox/utilities/templates/builtins/customfield_value.html
  75. 3 3
      netbox/virtualization/graphql/filter_mixins.py
  76. 4 4
      netbox/virtualization/graphql/filters.py
  77. 10 10
      netbox/vpn/graphql/filters.py
  78. 2 2
      netbox/wireless/graphql/filter_mixins.py
  79. 3 3
      netbox/wireless/graphql/filters.py
  80. 1 1
      pyproject.toml
  81. 9 8
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/01-feature_request.yaml

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

+ 1 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

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

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

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

+ 44 - 0
.github/workflows/claude-code-review.yml

@@ -0,0 +1,44 @@
+name: Claude Code Review
+
+on:
+  pull_request:
+    types: [opened, synchronize, ready_for_review, reopened]
+    # Optional: Only run on specific file changes
+    # paths:
+    #   - "src/**/*.ts"
+    #   - "src/**/*.tsx"
+    #   - "src/**/*.js"
+    #   - "src/**/*.jsx"
+
+jobs:
+  claude-review:
+    # Optional: Filter by PR author
+    # if: |
+    #   github.event.pull_request.user.login == 'external-contributor' ||
+    #   github.event.pull_request.user.login == 'new-developer' ||
+    #   github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: read
+      issues: read
+      id-token: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 1
+
+      - name: Run Claude Code Review
+        id: claude-review
+        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'
+          plugins: 'code-review@claude-code-plugins'
+          prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+          # or https://code.claude.com/docs/en/cli-reference for available options
+

+ 50 - 0
.github/workflows/claude.yml

@@ -0,0 +1,50 @@
+name: Claude Code
+
+on:
+  issue_comment:
+    types: [created]
+  pull_request_review_comment:
+    types: [created]
+  issues:
+    types: [opened, assigned]
+  pull_request_review:
+    types: [submitted]
+
+jobs:
+  claude:
+    if: |
+      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: read
+      issues: read
+      id-token: write
+      actions: read # Required for Claude to read CI results on PRs
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 1
+
+      - name: Run Claude Code
+        id: claude
+        uses: anthropics/claude-code-action@v1
+        with:
+          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+          # This is an optional setting that allows Claude to read CI results on PRs
+          additional_permissions: |
+            actions: read
+
+          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+          # prompt: 'Update the pull request description to include a summary of changes.'
+
+          # Optional: Add claude_args to customize behavior and configuration
+          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+          # or https://code.claude.com/docs/en/cli-reference for available options
+          # claude_args: '--allowed-tools Bash(gh pr:*)'
+

+ 84 - 0
CLAUDE.md

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

+ 5 - 2
base_requirements.txt

@@ -98,6 +98,10 @@ jsonschema
 # https://python-markdown.github.io/changelog/
 Markdown
 
+# MkDocs
+# https://github.com/mkdocs/mkdocs/releases
+mkdocs<2.0
+
 # MkDocs Material theme (for documentation build)
 # https://squidfunk.github.io/mkdocs-material/changelog/
 mkdocs-material
@@ -157,8 +161,7 @@ strawberry-graphql
 
 # Strawberry GraphQL Django extension
 # https://github.com/strawberry-graphql/strawberry-django/releases
-# Blocked by #21450
-strawberry-graphql-django==0.75.0
+strawberry-graphql-django
 
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

+ 2 - 0
contrib/generated_schema.json

@@ -349,6 +349,7 @@
                         "5gbase-t",
                         "10gbase-br-d",
                         "10gbase-br-u",
+                        "10gbase-cu",
                         "10gbase-cx4",
                         "10gbase-er",
                         "10gbase-lr",
@@ -367,6 +368,7 @@
                         "40gbase-fr4",
                         "40gbase-lr4",
                         "40gbase-sr4",
+                        "40gbase-sr4-bd",
                         "50gbase-cr",
                         "50gbase-er",
                         "50gbase-fr",

File diff suppressed because it is too large
+ 151 - 107
contrib/openapi.json


+ 29 - 0
docs/release-notes/version-4.5.md

@@ -1,5 +1,34 @@
 # NetBox v4.5
 
+## v4.5.4 (2026-03-03)
+
+### Enhancements
+
+* [#21369](https://github.com/netbox-community/netbox/issues/21369) - Support lazy-loading of image attachments
+* [#21385](https://github.com/netbox-community/netbox/issues/21385) - Add contact assignment support for virtual circuits
+* [#21394](https://github.com/netbox-community/netbox/issues/21394) - Add 10GBASE-CU and 40GBASE-SR4 BiDi interface types
+* [#21477](https://github.com/netbox-community/netbox/issues/21477) - Extend GraphQL API filters for cables
+
+### Performance Improvements
+
+* [#21456](https://github.com/netbox-community/netbox/issues/21456) - Improve performance of config context resolution via GraphQL API
+* [#21459](https://github.com/netbox-community/netbox/issues/21459) - Avoid prefetching data for hidden table columns
+
+### Bug Fixes
+
+* [#20490](https://github.com/netbox-community/netbox/issues/20490) - Restrict visibility of scripts in list view to users with view permission
+* [#20911](https://github.com/netbox-community/netbox/issues/20911) - Sort module bay options alphabetically when installing a module
+* [#21347](https://github.com/netbox-community/netbox/issues/21347) - The allocation of IPv6 addresses from a non-pool prefix should start at one, not zero
+* [#21429](https://github.com/netbox-community/netbox/issues/21429) - Termination type should persist when employing "create & add another" workflow for cables
+* [#21478](https://github.com/netbox-community/netbox/issues/21478) - Fix GraphQL union type resolution for connected console ports
+* [#21481](https://github.com/netbox-community/netbox/issues/21481) - Fix display of facility ID on rack view
+* [#21518](https://github.com/netbox-community/netbox/issues/21518) - Fix decimal custom field displaying as unset when value is zero
+* [#21524](https://github.com/netbox-community/netbox/issues/21524) - Avoid `IndexError` exception when encountering stale cable paths
+* [#21527](https://github.com/netbox-community/netbox/issues/21527) - Fix display of primary IP address with associated NAT IP on device view
+* [#21550](https://github.com/netbox-community/netbox/issues/21550) - Ensure pre-change snapshots are recorded for related objects
+
+---
+
 ## v4.5.3 (2026-02-17)
 
 ### Enhancements

+ 13 - 13
netbox/circuits/graphql/filters.py

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, DateFilterLookup, StrFilterLookup
 
 from circuits import models
 from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
@@ -62,9 +62,9 @@ class CircuitTerminationFilter(
     upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field()
-    pp_info: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    xconnect_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    pp_info: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
     # Cached relations
     _provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -92,7 +92,7 @@ class CircuitFilter(
     TenancyFilterMixin,
     PrimaryModelFilter
 ):
-    cid: FilterLookup[str] | None = strawberry_django.filter_field()
+    cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -145,8 +145,8 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
 
 @strawberry_django.filter_type(models.Provider, lookups=True)
 class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -159,18 +159,18 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
         strawberry_django.filter_field()
     )
     provider_id: ID | None = strawberry_django.filter_field()
-    account: FilterLookup[str] | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    account: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
 class ProviderNetworkFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
     provider_id: ID | None = strawberry_django.filter_field()
-    service_id: FilterLookup[str] | None = strawberry_django.filter_field()
+    service_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
@@ -180,7 +180,7 @@ class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter
 
 @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
 class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
-    cid: FilterLookup[str] | None = strawberry_django.filter_field()
+    cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -218,4 +218,4 @@ class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin,
         strawberry_django.filter_field()
     )
     interface_id: ID | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 25 - 0
netbox/core/api/schema.py

@@ -2,6 +2,7 @@ import re
 import typing
 from collections import OrderedDict
 
+from drf_spectacular.contrib.django_filters import DjangoFilterExtension
 from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
 from drf_spectacular.openapi import AutoSchema
 from drf_spectacular.plumbing import (
@@ -9,6 +10,7 @@ from drf_spectacular.plumbing import (
     build_choice_field,
     build_media_type_object,
     build_object_type,
+    follow_field_source,
     get_doc,
 )
 from drf_spectacular.types import OpenApiTypes
@@ -23,6 +25,29 @@ BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
 WRITABLE_ACTIONS = ("PATCH", "POST", "PUT")
 
 
+class NetBoxDjangoFilterExtension(DjangoFilterExtension):
+    """
+    Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where
+    _get_model_field() incorrectly double-appends to_field_name when field_name already ends
+    with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid
+    path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema
+    generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner.
+
+    See: https://github.com/netbox-community/netbox/issues/20787
+         https://github.com/tfranzel/drf-spectacular/issues/1475
+    """
+    priority = 1
+
+    def _get_model_field(self, filter_field, model):
+        if not filter_field.field_name:
+            return None
+        path = filter_field.field_name.split('__')
+        to_field_name = filter_field.extra.get('to_field_name')
+        if to_field_name is not None and path[-1] != to_field_name:
+            path.append(to_field_name)
+        return follow_field_source(model, path, emit_warnings=False)
+
+
 class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension):
     target_class = 'timezone_field.rest_framework.TimeZoneSerializerField'
 

+ 12 - 12
netbox/core/graphql/filters.py

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

+ 3 - 0
netbox/dcim/api/serializers_/cables.py

@@ -84,6 +84,9 @@ class CablePathSerializer(serializers.ModelSerializer):
     def get_path(self, obj):
         ret = []
         for nodes in obj.path_objects:
+            if not nodes:
+                # The path contains an invalid object
+                return []
             serializer = get_serializer_for_model(nodes[0])
             context = {'request': self.context['request']}
             ret.append(serializer(nodes, nested=True, many=True, context=context).data)

+ 4 - 0
netbox/dcim/choices.py

@@ -921,6 +921,7 @@ class InterfaceTypeChoices(ChoiceSet):
     # 10 Gbps Ethernet
     TYPE_10GE_BR_D = '10gbase-br-d'
     TYPE_10GE_BR_U = '10gbase-br-u'
+    TYPE_10GE_CU = '10gbase-cu'
     TYPE_10GE_CX4 = '10gbase-cx4'
     TYPE_10GE_ER = '10gbase-er'
     TYPE_10GE_LR = '10gbase-lr'
@@ -943,6 +944,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_40GE_FR4 = '40gbase-fr4'
     TYPE_40GE_LR4 = '40gbase-lr4'
     TYPE_40GE_SR4 = '40gbase-sr4'
+    TYPE_40GE_SR4_BD = '40gbase-sr4-bd'
 
     # 50 Gbps Ethernet
     TYPE_50GE_CR = '50gbase-cr'
@@ -1192,6 +1194,7 @@ class InterfaceTypeChoices(ChoiceSet):
             (
                 (TYPE_10GE_BR_D, '10GBASE-BR-D (10GE BiDi Down)'),
                 (TYPE_10GE_BR_U, '10GBASE-BR-U (10GE BiDi Up)'),
+                (TYPE_10GE_CU, '10GBASE-CU (10GE DAC Passive Twinax)'),
                 (TYPE_10GE_CX4, '10GBASE-CX4 (10GE DAC)'),
                 (TYPE_10GE_ER, '10GBASE-ER (10GE)'),
                 (TYPE_10GE_LR, '10GBASE-LR (10GE)'),
@@ -1220,6 +1223,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_40GE_FR4, '40GBASE-FR4 (40GE)'),
                 (TYPE_40GE_LR4, '40GBASE-LR4 (40GE)'),
                 (TYPE_40GE_SR4, '40GBASE-SR4 (40GE)'),
+                (TYPE_40GE_SR4_BD, '40GBASE-SR4 (40GE BiDi)'),
             )
         ),
         (

+ 1 - 0
netbox/dcim/forms/bulk_import.py

@@ -1386,6 +1386,7 @@ class MACAddressImportForm(PrimaryModelImportForm):
 
         # Assign the MAC address as primary for its interface, if designated as such
         if interface and self.cleaned_data['is_primary'] and self.instance.pk:
+            interface.snapshot()
             interface.primary_mac_address = self.instance
             interface.save()
 

+ 4 - 0
netbox/dcim/forms/connections.py

@@ -15,6 +15,10 @@ def get_cable_form(a_type, b_type):
 
         def __new__(mcs, name, bases, attrs):
 
+            # NOTE: Cable.clone() mirrors the parent selector mapping below:
+            # termination_{end}_device / termination_{end}_powerpanel / termination_{end}_circuit
+            # This supports both the "Clone" and "Create & Add Another" workflows.
+            # If you change the mapping here, update Cable.clone() accordingly.
             for cable_end, term_cls in (('a', a_type), ('b', b_type)):
 
                 # Device component

+ 7 - 7
netbox/dcim/graphql/filter_mixins.py

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry import ID
-from strawberry_django import BaseFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
 
 from core.graphql.filters import ContentTypeFilter
 
@@ -66,9 +66,9 @@ class ComponentModelFilterMixin:
     )
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    label: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @dataclass
@@ -96,9 +96,9 @@ class ComponentTemplateFilterMixin:
         strawberry_django.filter_field()
     )
     device_type_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    label: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @dataclass

+ 55 - 41
netbox/dcim/graphql/filters.py

@@ -4,7 +4,7 @@ import strawberry
 import strawberry_django
 from django.db.models import Q
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup, StrFilterLookup
 
 from dcim import models
 from dcim.constants import *
@@ -114,7 +114,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    label: FilterLookup[str] | None = strawberry_django.filter_field()
+    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -141,6 +141,20 @@ class CableTerminationFilter(ChangeLoggedModelFilter):
     )
     termination_id: ID | None = strawberry_django.filter_field()
 
+    # Cached relations
+    _device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
+        name='device'
+    )
+    _rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
+        name='rack'
+    )
+    _location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='location')
+    )
+    _site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
+        name='site'
+    )
+
 
 @strawberry_django.filter_type(models.ConsolePort, lookups=True)
 class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
@@ -196,9 +210,9 @@ class DeviceFilter(
     platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    serial: FilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -325,7 +339,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
         strawberry_django.filter_field()
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    part_id: FilterLookup[str] | None = strawberry_django.filter_field()
+    part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.DeviceRole, lookups=True)
@@ -342,13 +356,13 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
         strawberry_django.filter_field()
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    model: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
     default_platform_id: ID | None = strawberry_django.filter_field()
-    part_number: FilterLookup[str] | None = strawberry_django.filter_field()
+    part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
     instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -465,7 +479,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
 
 @strawberry_django.filter_type(models.MACAddress, lookups=True)
 class MACAddressFilter(PrimaryModelFilter):
-    mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
+    mac_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -511,7 +525,7 @@ class InterfaceFilter(
     duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    wwn: FilterLookup[str] | None = strawberry_django.filter_field()
+    wwn: StrFilterLookup[str] | None = strawberry_django.filter_field()
     parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -631,9 +645,9 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
         strawberry_django.filter_field()
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    part_id: FilterLookup[str] | None = strawberry_django.filter_field()
-    serial: FilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+    part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
     discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
@@ -651,7 +665,7 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
     status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    facility: FilterLookup[str] | None = strawberry_django.filter_field()
+    facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -680,8 +694,8 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
     status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    serial: FilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
     console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -720,17 +734,17 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
         strawberry_django.filter_field()
     )
     parent_id: ID | None = strawberry_django.filter_field()
-    position: FilterLookup[str] | None = strawberry_django.filter_field()
+    position: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
 class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
-    position: FilterLookup[str] | None = strawberry_django.filter_field()
+    position: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
 class ModuleTypeProfileFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ModuleType, lookups=True)
@@ -743,8 +757,8 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
         strawberry_django.filter_field()
     )
     profile_id: ID | None = strawberry_django.filter_field()
-    model: FilterLookup[str] | None = strawberry_django.filter_field()
-    part_number: FilterLookup[str] | None = strawberry_django.filter_field()
+    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
     instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -804,7 +818,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
     power_panel_id: ID | None = strawberry_django.filter_field()
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -875,7 +889,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.PowerPort, lookups=True)
@@ -913,8 +927,8 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
         strawberry_django.filter_field()
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    model: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
@@ -935,8 +949,8 @@ class RackFilter(
         strawberry_django.filter_field()
     )
     rack_type_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    facility_id: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    facility_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -950,8 +964,8 @@ class RackFilter(
     )
     role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     role_id: ID | None = strawberry_django.filter_field()
-    serial: FilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
     airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -969,7 +983,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -1020,8 +1034,8 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
 
 @strawberry_django.filter_type(models.Site, lookups=True)
 class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -1035,11 +1049,11 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
     group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    facility: FilterLookup[str] | None = strawberry_django.filter_field()
+    facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
-    time_zone: FilterLookup[str] | None = strawberry_django.filter_field()
-    physical_address: FilterLookup[str] | None = strawberry_django.filter_field()
-    shipping_address: FilterLookup[str] | None = strawberry_django.filter_field()
+    time_zone: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    physical_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    shipping_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
     latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
@@ -1068,8 +1082,8 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
 class VirtualChassisFilter(PrimaryModelFilter):
     master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     master_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    domain: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    domain: StrFilterLookup[str] | None = strawberry_django.filter_field()
     members: (
         Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
     ) = strawberry_django.filter_field()
@@ -1080,7 +1094,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
 class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: (
         BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None
     ) = (
@@ -1097,7 +1111,7 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
         strawberry_django.filter_field()
     )
     primary_ip6_id: ID | None = strawberry_django.filter_field()
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
     interfaces: (
         Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
     ) = strawberry_django.filter_field()

+ 44 - 0
netbox/dcim/models/cables.py

@@ -305,6 +305,50 @@ class Cable(PrimaryModel):
         except UnsupportedCablePath as e:
             raise AbortRequest(e)
 
+    def clone(self):
+        """
+        Return attributes suitable for cloning this cable.
+
+        In addition to the fields defined in `clone_fields`, include the termination
+        type and parent selector fields used by dcim.forms.connections.get_cable_form().
+        """
+        attrs = super().clone()
+
+        # Mirror dcim.forms.connections.get_cable_form() parent-field logic
+        for cable_end, terminations in (('a', self.a_terminations), ('b', self.b_terminations)):
+            if not terminations:
+                continue
+
+            term_cls = type(terminations[0])
+            term_label = term_cls._meta.label_lower
+
+            # Matches CableForm choices: "<app_label>.<model>"
+            attrs[f'{cable_end}_terminations_type'] = term_label
+
+            # Device component
+            if hasattr(term_cls, 'device'):
+                device_ids = sorted({t.device_id for t in terminations if t.device_id})
+                if device_ids:
+                    attrs[f'termination_{cable_end}_device'] = device_ids
+
+            # PowerFeed
+            elif term_label == 'dcim.powerfeed':
+                powerpanel_ids = sorted({t.power_panel_id for t in terminations if t.power_panel_id})
+                if powerpanel_ids:
+                    attrs[f'termination_{cable_end}_powerpanel'] = powerpanel_ids
+
+            # CircuitTermination
+            elif term_label == 'circuits.circuittermination':
+                circuit_ids = sorted({t.circuit_id for t in terminations if t.circuit_id})
+                if circuit_ids:
+                    attrs[f'termination_{cable_end}_circuit'] = circuit_ids
+
+        # Never clone the actual terminations, as they are already occupied
+        attrs.pop('a_terminations', None)
+        attrs.pop('b_terminations', None)
+
+        return attrs
+
     def serialize_object(self, exclude=None):
         data = serialize_object(self, exclude=exclude or [])
 

+ 120 - 0
netbox/dcim/tests/test_api.py

@@ -2614,6 +2614,126 @@ class CableTest(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    def test_graphql_cable_termination_cached_filters(self):
+        """
+        Validate filtering cables by cached CableTermination relations via GraphQL:
+
+          cable_list(filters: { terminations: { <relation>: {...}, DISTINCT: true } })
+
+        Also asserts deduplication when both ends match (cable between two interfaces
+        on the same device/rack/location/site).
+        """
+        self.add_permissions(
+            'dcim.view_cable',
+            'dcim.view_device',
+            'dcim.view_interface',
+            'dcim.view_rack',
+            'dcim.view_location',
+            'dcim.view_site',
+        )
+
+        # Reuse existing fixtures from setUpTestData()
+        devicetype = DeviceType.objects.get(slug='device-type-1')
+        role = DeviceRole.objects.get(slug='device-role-1')
+
+        # Create an isolated topology for this test
+        site_a = Site.objects.create(name='GQL Site A', slug='gql-site-a')
+        site_b = Site.objects.create(name='GQL Site B', slug='gql-site-b')
+
+        location_a = Location.objects.create(
+            site=site_a,
+            name='GQL Location A',
+            slug='gql-location-a',
+            status=LocationStatusChoices.STATUS_ACTIVE,
+        )
+        location_b = Location.objects.create(
+            site=site_b,
+            name='GQL Location B',
+            slug='gql-location-b',
+            status=LocationStatusChoices.STATUS_ACTIVE,
+        )
+
+        rack_a = Rack.objects.create(site=site_a, location=location_a, name='GQL Rack A', u_height=42)
+        rack_b = Rack.objects.create(site=site_b, location=location_b, name='GQL Rack B', u_height=42)
+
+        device_a = Device.objects.create(
+            device_type=devicetype,
+            role=role,
+            name='GQL Device A',
+            site=site_a,
+            location=location_a,
+            rack=rack_a,
+        )
+        device_b = Device.objects.create(
+            device_type=devicetype,
+            role=role,
+            name='GQL Device B',
+            site=site_b,
+            location=location_b,
+            rack=rack_b,
+        )
+
+        a0 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
+        a1 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth1')
+        a2 = Interface.objects.create(device=device_a, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth2')
+        b0 = Interface.objects.create(device=device_b, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth0')
+
+        # Both ends on Device A (duplication risk without DISTINCT)
+        cable_same_device = Cable(a_terminations=[a0], b_terminations=[a1], label='GQL Cable Same Device')
+        cable_same_device.save()
+
+        # Cross to Device B
+        cable_cross = Cable(a_terminations=[a2], b_terminations=[b0], label='GQL Cable Cross')
+        cable_cross.save()
+
+        expected_a = {str(cable_same_device.pk), str(cable_cross.pk)}
+        expected_b = {str(cable_cross.pk)}
+
+        url = reverse('graphql')
+
+        test_cases = (
+            # Device (ID + name)
+            (f'device: {{ id: {{ exact: "{device_a.pk}" }} }}', expected_a),
+            (f'device: {{ name: {{ exact: "{device_a.name}" }} }}', expected_a),
+            (f'device: {{ id: {{ exact: "{device_b.pk}" }} }}', expected_b),
+            (f'device: {{ name: {{ exact: "{device_b.name}" }} }}', expected_b),
+            # Rack (ID + name)
+            (f'rack: {{ id: {{ exact: "{rack_a.pk}" }} }}', expected_a),
+            (f'rack: {{ name: {{ exact: "{rack_a.name}" }} }}', expected_a),
+            (f'rack: {{ id: {{ exact: "{rack_b.pk}" }} }}', expected_b),
+            (f'rack: {{ name: {{ exact: "{rack_b.name}" }} }}', expected_b),
+            # Location (ID + name)
+            (f'location: {{ id: {{ exact: "{location_a.pk}" }} }}', expected_a),
+            (f'location: {{ name: {{ exact: "{location_a.name}" }} }}', expected_a),
+            (f'location: {{ id: {{ exact: "{location_b.pk}" }} }}', expected_b),
+            (f'location: {{ name: {{ exact: "{location_b.name}" }} }}', expected_b),
+            # Site (ID + slug)
+            (f'site: {{ id: {{ exact: "{site_a.pk}" }} }}', expected_a),
+            (f'site: {{ slug: {{ exact: "{site_a.slug}" }} }}', expected_a),
+            (f'site: {{ id: {{ exact: "{site_b.pk}" }} }}', expected_b),
+            (f'site: {{ slug: {{ exact: "{site_b.slug}" }} }}', expected_b),
+        )
+
+        for inner_filter, expected in test_cases:
+            with self.subTest(filter=inner_filter):
+                query = f"""{{
+                  cable_list(filters: {{ terminations: {{ {inner_filter} DISTINCT: true }} }})
+                  {{ id }}
+                }}"""
+
+                response = self.client.post(url, data={'query': query}, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                data = response.json()
+                self.assertNotIn('errors', data)
+
+                rows = data['data']['cable_list']
+                ids = [row['id'] for row in rows]
+
+                # Ensure DISTINCT is actually effective (no duplicate cables when both ends match)
+                self.assertEqual(len(ids), len(set(ids)), f'Duplicate cables returned for: {inner_filter}')
+
+                self.assertSetEqual(set(ids), expected)
+
 
 class CableTerminationTest(
     APIViewTestCases.GetObjectViewTestCase,

+ 2 - 13
netbox/dcim/views.py

@@ -2733,6 +2733,7 @@ class DeviceBulkImportView(generic.BulkImportView):
         # For child devices, save the reverse relation to the parent device bay
         if parent_bay:
             device_bay = parent_bay
+            device_bay.snapshot()
             device_bay.installed_device = obj
             device_bay.save()
 
@@ -3912,19 +3913,6 @@ class CableEditView(generic.ObjectEditView):
 
         return super().alter_object(obj, request, url_args, url_kwargs)
 
-    def get_extra_addanother_params(self, request):
-
-        params = {
-            'a_terminations_type': request.GET.get('a_terminations_type'),
-            'b_terminations_type': request.GET.get('b_terminations_type')
-        }
-
-        for key in request.POST:
-            if 'device' in key or 'power_panel' in key or 'circuit' in key:
-                params.update({key: request.POST.get(key)})
-
-        return params
-
 
 @register_model_view(Cable, 'delete')
 class CableDeleteView(generic.ObjectDeleteView):
@@ -4099,6 +4087,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
                 members = formset.save(commit=False)
                 devices = Device.objects.filter(pk__in=[m.pk for m in members])
                 for device in devices:
+                    device.snapshot()
                     device.vc_position = None
                     device.save()
                 for member in members:

+ 53 - 53
netbox/extras/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
 
 from extras import models
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
@@ -50,11 +50,11 @@ __all__ = (
 
 @strawberry_django.filter_type(models.ConfigContext, lookups=True)
 class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
     regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -107,22 +107,22 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
 
 @strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
 class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] = strawberry_django.filter_field()
-    description: FilterLookup[str] = strawberry_django.filter_field()
+    name: StrFilterLookup[str] = strawberry_django.filter_field()
+    description: StrFilterLookup[str] = strawberry_django.filter_field()
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    template_code: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
-    file_name: FilterLookup[str] | None = strawberry_django.filter_field()
-    file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
+    mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
@@ -137,10 +137,10 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
     related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    label: FilterLookup[str] | None = strawberry_django.filter_field()
-    group_name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     required: FilterLookup[bool] | None = strawberry_django.filter_field()
     unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -166,7 +166,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
     validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    validation_regex: FilterLookup[str] | None = strawberry_django.filter_field()
+    validation_regex: StrFilterLookup[str] | None = strawberry_django.filter_field()
     choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -182,13 +182,13 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
         strawberry_django.filter_field()
     )
     is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
 class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     base_choices: (
         BaseFilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None
     ) = (
@@ -202,14 +202,14 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
 
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
 class CustomLinkFilter(ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
-    link_text: FilterLookup[str] | None = strawberry_django.filter_field()
-    link_url: FilterLookup[str] | None = strawberry_django.filter_field()
+    link_text: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    link_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    group_name: FilterLookup[str] | None = strawberry_django.filter_field()
+    group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     button_class: (
         BaseFilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None
     ) = (
@@ -220,15 +220,15 @@ class CustomLinkFilter(ChangeLoggedModelFilter):
 
 @strawberry_django.filter_type(models.ExportTemplate, lookups=True)
 class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    template_code: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
-    file_name: FilterLookup[str] | None = strawberry_django.filter_field()
-    file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
+    mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
@@ -244,7 +244,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter):
     image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.JournalEntry, lookups=True)
@@ -260,22 +260,22 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
     kind: BaseFilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
 class NotificationGroupFilter(ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.SavedFilter, lookups=True)
 class SavedFilterFilter(ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -290,8 +290,8 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
 
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
 class TableConfigFilter(ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -303,30 +303,30 @@ class TableConfigFilter(ChangeLoggedModelFilter):
 
 @strawberry_django.filter_type(models.Tag, lookups=True)
 class TagFilter(ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.Webhook, lookups=True)
 class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    payload_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
     http_method: (
         BaseFilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None
     ) = (
         strawberry_django.filter_field()
     )
-    http_content_type: FilterLookup[str] | None = strawberry_django.filter_field()
-    additional_headers: FilterLookup[str] | None = strawberry_django.filter_field()
-    body_template: FilterLookup[str] | None = strawberry_django.filter_field()
-    secret: FilterLookup[str] | None = strawberry_django.filter_field()
+    http_content_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    additional_headers: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    body_template: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    secret: StrFilterLookup[str] | None = strawberry_django.filter_field()
     ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
-    ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field()
+    ca_file_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
     events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -334,8 +334,8 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
 
 @strawberry_django.filter_type(models.EventRule, lookups=True)
 class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
@@ -346,10 +346,10 @@ class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedMode
     action_type: BaseFilterLookup[Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    action_object_type: FilterLookup[str] | None = strawberry_django.filter_field()
+    action_object_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
     action_object_type_id: ID | None = strawberry_django.filter_field()
     action_object_id: ID | None = strawberry_django.filter_field()
     action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 13 - 1
netbox/extras/graphql/mixins.py

@@ -22,7 +22,19 @@ if TYPE_CHECKING:
 @strawberry.type
 class ConfigContextMixin:
 
-    @strawberry_django.field
+    @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'])
     def config_context(self) -> strawberry.scalars.JSON:
         return self.get_config_context()
 

+ 2 - 0
netbox/ipam/forms/bulk_import.py

@@ -424,6 +424,7 @@ 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')
+            parent.snapshot()
             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:
@@ -433,6 +434,7 @@ class IPAddressImportForm(PrimaryModelImportForm):
         # Set as OOB for device
         if self.cleaned_data.get('is_oob') is not None:
             parent = self.cleaned_data.get('device')
+            parent.snapshot()
             parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
             parent.save()
 

+ 20 - 20
netbox/ipam/graphql/filters.py

@@ -7,7 +7,7 @@ import strawberry_django
 from django.db.models import Q
 from netaddr.core import AddrFormatError
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup, StrFilterLookup
 
 from dcim.graphql.filter_mixins import ScopedFilterMixin
 from dcim.models import Device
@@ -70,8 +70,8 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.ASNRange, lookups=True)
 class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -84,7 +84,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
 
 @strawberry_django.filter_type(models.Aggregate, lookups=True)
 class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    prefix: FilterLookup[str] | None = strawberry_django.filter_field()
+    prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
@@ -120,14 +120,14 @@ class FHRPGroupFilter(PrimaryModelFilter):
     group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     protocol: BaseFilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
     auth_type: BaseFilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    auth_key: FilterLookup[str] | None = strawberry_django.filter_field()
+    auth_key: StrFilterLookup[str] | None = strawberry_django.filter_field()
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -138,7 +138,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
     interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    interface_id: FilterLookup[str] | None = strawberry_django.filter_field()
+    interface_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
     group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -174,7 +174,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
 
 @strawberry_django.filter_type(models.IPAddress, lookups=True)
 class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    address: FilterLookup[str] | None = strawberry_django.filter_field()
+    address: StrFilterLookup[str] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
@@ -195,7 +195,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
         strawberry_django.filter_field()
     )
     nat_outside_id: ID | None = strawberry_django.filter_field()
-    dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
+    dns_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
     @strawberry_django.filter_field()
     def assigned(self, value: bool, prefix) -> Q:
@@ -225,8 +225,8 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
 
 @strawberry_django.filter_type(models.IPRange, lookups=True)
 class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    start_address: FilterLookup[str] | None = strawberry_django.filter_field()
-    end_address: FilterLookup[str] | None = strawberry_django.filter_field()
+    start_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    end_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
@@ -279,7 +279,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.Prefix, lookups=True)
 class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    prefix: FilterLookup[str] | None = strawberry_django.filter_field()
+    prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -328,7 +328,7 @@ class RoleFilter(OrganizationalModelFilter):
 
 @strawberry_django.filter_type(models.RouteTarget, lookups=True)
 class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -345,7 +345,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.Service, lookups=True)
 class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -357,7 +357,7 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
 class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.VLAN, lookups=True)
@@ -371,7 +371,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
     vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -401,7 +401,7 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
 
 @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
 class VLANTranslationPolicyFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
@@ -410,7 +410,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
         strawberry_django.filter_field()
     )
     policy_id: ID | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
@@ -421,8 +421,8 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
 
 @strawberry_django.filter_type(models.VRF, lookups=True)
 class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    rd: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    rd: StrFilterLookup[str] | None = strawberry_django.filter_field()
     enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()

+ 5 - 3
netbox/ipam/models/ip.py

@@ -432,9 +432,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         ])
         available_ips = prefix - child_ips - child_ranges
 
-        # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
-        if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
-                self.family == 4 and self.prefix.prefixlen >= 31
+        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
+        if (
+            self.is_pool
+            or (self.family == 4 and self.prefix.prefixlen >= 31)
+            or (self.family == 6 and self.prefix.prefixlen >= 127)
         ):
             return available_ips
 

+ 129 - 0
netbox/ipam/tests/test_tables.py

@@ -39,3 +39,132 @@ class AnnotatedIPAddressTableTest(TestCase):
 
         iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
         self.assertEqual(iprange_checkbox_count, 0)
+
+    def test_annotate_ip_space_ipv4_non_pool_excludes_network_and_broadcast(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),  # 8 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /29 non-pool: exclude .0 (network) and .7 (broadcast)
+        self.assertEqual(available.first_ip, '192.0.2.1/29')
+        self.assertEqual(available.size, 6)
+
+    def test_annotate_ip_space_ipv4_pool_includes_network_and_broadcast(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.8/29'),  # 8 addresses total
+            status='active',
+            is_pool=True,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # Pool: all addresses are usable, including network/broadcast
+        self.assertEqual(available.first_ip, '192.0.2.8/29')
+        self.assertEqual(available.size, 8)
+
+    def test_annotate_ip_space_ipv4_31_includes_all_ips(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.16/31'),  # 2 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /31: fully usable
+        self.assertEqual(available.first_ip, '192.0.2.16/31')
+        self.assertEqual(available.size, 2)
+
+    def test_annotate_ip_space_ipv4_32_includes_single_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.100/32'),  # 1 address total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /32: single usable address
+        self.assertEqual(available.first_ip, '192.0.2.100/32')
+        self.assertEqual(available.size, 1)
+
+    def test_annotate_ip_space_ipv6_non_pool_excludes_anycast_first_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),  # 4 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        # No child records -> expect one AvailableIPSpace entry
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # For IPv6 non-pool prefixes (except /127-/128), the first address is reserved (subnet-router anycast)
+        self.assertEqual(available.first_ip, '2001:db8::1/126')
+        self.assertEqual(available.size, 3)  # 4 total - 1 reserved anycast
+
+    def test_annotate_ip_space_ipv6_127_includes_all_ips(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/127'),  # 2 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /127 is fully usable (no anycast exclusion)
+        self.assertEqual(available.first_ip, '2001:db8::/127')
+        self.assertEqual(available.size, 2)
+
+    def test_annotate_ip_space_ipv6_128_includes_single_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::1/128'),  # 1 address total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /128 is fully usable (single host address)
+        self.assertEqual(available.first_ip, '2001:db8::1/128')
+        self.assertEqual(available.size, 1)
+
+    def test_annotate_ip_space_ipv6_pool_includes_anycast_first_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8:1::/126'),  # 4 addresses total
+            status='active',
+            is_pool=True,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # Pools are fully usable
+        self.assertEqual(available.first_ip, '2001:db8:1::/126')
+        self.assertEqual(available.size, 4)

+ 11 - 2
netbox/ipam/utils.py

@@ -78,12 +78,21 @@ def annotate_ip_space(prefix):
     records = sorted(records, key=lambda x: x[0])
 
     # Determine the first & last valid IP addresses in the prefix
-    if prefix.family == 4 and prefix.mask_length < 31 and not prefix.is_pool:
+    if (
+        prefix.is_pool
+        or (prefix.family == 4 and prefix.mask_length >= 31)
+        or (prefix.family == 6 and prefix.mask_length >= 127)
+    ):
+        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
+        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
+        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
+    elif prefix.family == 4:
         # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
         first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
         last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
     else:
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
+        # For IPv6 prefixes, omit the Subnet-Router anycast address (RFC 4291)
+        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
         last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
 
     if not records:

+ 2 - 1
netbox/netbox/graphql/filter_lookups.py

@@ -15,6 +15,7 @@ from strawberry_django import (
     DatetimeFilterLookup,
     FilterLookup,
     RangeLookup,
+    StrFilterLookup,
     TimeFilterLookup,
     process_filters,
 )
@@ -40,7 +41,7 @@ SKIP_MSG = 'Filter will be skipped on `null` value'
 
 @strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
 class JSONLookup:
-    string_lookup: FilterLookup[str] | None = strawberry_django.filter_field()
+    string_lookup: StrFilterLookup[str] | None = strawberry_django.filter_field()
     int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
     int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()

+ 2 - 2
netbox/netbox/graphql/filter_mixins.py

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated, TypeVar
 
 import strawberry
 import strawberry_django
-from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
 
 __all__ = (
     'DistanceFilterMixin',
@@ -48,7 +48,7 @@ class SyncedDataFilterMixin:
         strawberry_django.filter_field()
     )
     data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
-    data_path: FilterLookup[str] | None = strawberry_django.filter_field()
+    data_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
     auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
 

+ 10 - 10
netbox/netbox/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
 
 import strawberry_django
 from strawberry import ID
-from strawberry_django import ComparisonFilterLookup, FilterLookup
+from strawberry_django import ComparisonFilterLookup, StrFilterLookup
 
 from core.graphql.filter_mixins import ChangeLoggingMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
@@ -42,21 +42,21 @@ class NetBoxModelFilter(
 
 @dataclass
 class NestedGroupModelFilter(NetBoxModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     parent_id: ID | None = strawberry_django.filter_field()
 
 
 @dataclass
 class OrganizationalModelFilter(NetBoxModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @dataclass
 class PrimaryModelFilter(NetBoxModelFilter):
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()

File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.css


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 5 - 5
netbox/project-static/package.json

@@ -32,19 +32,19 @@
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
     "sass": "1.97.3",
-    "tom-select": "2.4.3",
+    "tom-select": "2.5.2",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
   },
   "devDependencies": {
     "@eslint/compat": "^2.0.2",
-    "@eslint/eslintrc": "^3.3.3",
+    "@eslint/eslintrc": "^3.3.4",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.56.0",
-    "@typescript-eslint/parser": "^8.56.0",
+    "@typescript-eslint/eslint-plugin": "^8.56.1",
+    "@typescript-eslint/parser": "^8.56.1",
     "esbuild": "^0.27.3",
     "esbuild-sass-plugin": "^3.6.0",
     "eslint": "^9.39.2",
@@ -52,7 +52,7 @@
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.5",
-    "globals": "^17.3.0",
+    "globals": "^17.4.0",
     "prettier": "^3.8.1",
     "typescript": "^5.9.3"
   },

+ 1 - 2
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -1,5 +1,4 @@
-import { RecursivePartial, TomOption, TomSettings } from 'tom-select/dist/types/types';
-import { TomInput } from 'tom-select/dist/cjs/types/core';
+import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
 import { addClasses } from 'tom-select/src/vanilla.ts';
 import queryString from 'query-string';
 import TomSelect from 'tom-select';

+ 120 - 83
netbox/project-static/yarn.lock

@@ -210,7 +210,7 @@
   dependencies:
     "@types/json-schema" "^7.0.15"
 
-"@eslint/eslintrc@^3.3.1", "@eslint/eslintrc@^3.3.3":
+"@eslint/eslintrc@^3.3.1":
   version "3.3.3"
   resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz"
   integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==
@@ -225,6 +225,21 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
+"@eslint/eslintrc@^3.3.4":
+  version "3.3.4"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.4.tgz#e402b1920f7c1f5a15342caa432b1348cacbb641"
+  integrity sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==
+  dependencies:
+    ajv "^6.14.0"
+    debug "^4.3.2"
+    espree "^10.0.1"
+    globals "^14.0.0"
+    ignore "^5.2.0"
+    import-fresh "^3.2.1"
+    js-yaml "^4.1.1"
+    minimatch "^3.1.3"
+    strip-json-comments "^3.1.1"
+
 "@eslint/js@9.39.2", "@eslint/js@^9.39.2":
   version "9.39.2"
   resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599"
@@ -935,100 +950,100 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
-  integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
+"@typescript-eslint/eslint-plugin@^8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz#b1ce606d87221daec571e293009675992f0aae76"
+  integrity sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.56.0"
-    "@typescript-eslint/type-utils" "8.56.0"
-    "@typescript-eslint/utils" "8.56.0"
-    "@typescript-eslint/visitor-keys" "8.56.0"
+    "@typescript-eslint/scope-manager" "8.56.1"
+    "@typescript-eslint/type-utils" "8.56.1"
+    "@typescript-eslint/utils" "8.56.1"
+    "@typescript-eslint/visitor-keys" "8.56.1"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/parser@^8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
-  integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
+"@typescript-eslint/parser@^8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.1.tgz#21d13b3d456ffb08614c1d68bb9a4f8d9237cdc7"
+  integrity sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==
   dependencies:
-    "@typescript-eslint/scope-manager" "8.56.0"
-    "@typescript-eslint/types" "8.56.0"
-    "@typescript-eslint/typescript-estree" "8.56.0"
-    "@typescript-eslint/visitor-keys" "8.56.0"
+    "@typescript-eslint/scope-manager" "8.56.1"
+    "@typescript-eslint/types" "8.56.1"
+    "@typescript-eslint/typescript-estree" "8.56.1"
+    "@typescript-eslint/visitor-keys" "8.56.1"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
-  integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
+"@typescript-eslint/project-service@8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.1.tgz#65c8d645f028b927bfc4928593b54e2ecd809244"
+  integrity sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.56.0"
-    "@typescript-eslint/types" "^8.56.0"
+    "@typescript-eslint/tsconfig-utils" "^8.56.1"
+    "@typescript-eslint/types" "^8.56.1"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
-  integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
+"@typescript-eslint/scope-manager@8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz#254df93b5789a871351335dd23e20bc164060f24"
+  integrity sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==
   dependencies:
-    "@typescript-eslint/types" "8.56.0"
-    "@typescript-eslint/visitor-keys" "8.56.0"
+    "@typescript-eslint/types" "8.56.1"
+    "@typescript-eslint/visitor-keys" "8.56.1"
 
-"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
-  integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
+"@typescript-eslint/tsconfig-utils@8.56.1", "@typescript-eslint/tsconfig-utils@^8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz#1afa830b0fada5865ddcabdc993b790114a879b7"
+  integrity sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==
 
-"@typescript-eslint/type-utils@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
-  integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
+"@typescript-eslint/type-utils@8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz#7a6c4fabf225d674644931e004302cbbdd2f2e24"
+  integrity sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==
   dependencies:
-    "@typescript-eslint/types" "8.56.0"
-    "@typescript-eslint/typescript-estree" "8.56.0"
-    "@typescript-eslint/utils" "8.56.0"
+    "@typescript-eslint/types" "8.56.1"
+    "@typescript-eslint/typescript-estree" "8.56.1"
+    "@typescript-eslint/utils" "8.56.1"
     debug "^4.4.3"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
-  integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
+"@typescript-eslint/types@8.56.1", "@typescript-eslint/types@^8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.1.tgz#975e5942bf54895291337c91b9191f6eb0632ab9"
+  integrity sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==
 
-"@typescript-eslint/typescript-estree@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
-  integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
+"@typescript-eslint/typescript-estree@8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz#3b9e57d8129a860c50864c42188f761bdef3eab0"
+  integrity sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==
   dependencies:
-    "@typescript-eslint/project-service" "8.56.0"
-    "@typescript-eslint/tsconfig-utils" "8.56.0"
-    "@typescript-eslint/types" "8.56.0"
-    "@typescript-eslint/visitor-keys" "8.56.0"
+    "@typescript-eslint/project-service" "8.56.1"
+    "@typescript-eslint/tsconfig-utils" "8.56.1"
+    "@typescript-eslint/types" "8.56.1"
+    "@typescript-eslint/visitor-keys" "8.56.1"
     debug "^4.4.3"
-    minimatch "^9.0.5"
+    minimatch "^10.2.2"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/utils@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
-  integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
+"@typescript-eslint/utils@8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.1.tgz#5a86acaf9f1b4c4a85a42effb217f73059f6deb7"
+  integrity sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.56.0"
-    "@typescript-eslint/types" "8.56.0"
-    "@typescript-eslint/typescript-estree" "8.56.0"
+    "@typescript-eslint/scope-manager" "8.56.1"
+    "@typescript-eslint/types" "8.56.1"
+    "@typescript-eslint/typescript-estree" "8.56.1"
 
-"@typescript-eslint/visitor-keys@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
-  integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
+"@typescript-eslint/visitor-keys@8.56.1":
+  version "8.56.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz#50e03475c33a42d123dc99e63acf1841c0231f87"
+  integrity sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==
   dependencies:
-    "@typescript-eslint/types" "8.56.0"
+    "@typescript-eslint/types" "8.56.1"
     eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1148,6 +1163,16 @@ ajv@^6.12.4:
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
+ajv@^6.14.0:
+  version "6.14.0"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a"
+  integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
 ansi-styles@^4.1.0:
   version "4.3.0"
   resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
@@ -1274,6 +1299,11 @@ balanced-match@^1.0.0:
   resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
+balanced-match@^4.0.2:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
+  integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
+
 bootstrap@5.3.7:
   version "5.3.7"
   resolved "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz"
@@ -1292,12 +1322,12 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-brace-expansion@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz"
-  integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
+brace-expansion@^5.0.2:
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336"
+  integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==
   dependencies:
-    balanced-match "^1.0.0"
+    balanced-match "^4.0.2"
 
 braces@^3.0.3:
   version "3.0.3"
@@ -2189,10 +2219,10 @@ globals@^14.0.0:
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
-globals@^17.3.0:
-  version "17.3.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9"
-  integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==
+globals@^17.4.0:
+  version "17.4.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.4.0.tgz#33d7d297ed1536b388a0e2f4bcd0ff19c8ff91b5"
+  integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -2784,6 +2814,13 @@ 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:
   version "3.1.2"
   resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
@@ -2791,12 +2828,12 @@ minimatch@^3.1.2:
   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==
+minimatch@^3.1.3:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
+  integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
   dependencies:
-    brace-expansion "^2.0.1"
+    brace-expansion "^1.1.7"
 
 minimist@^1.2.0, minimist@^1.2.6:
   version "1.2.8"
@@ -3455,10 +3492,10 @@ toggle-selection@^1.0.6:
   resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
   integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
 
-tom-select@2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
-  integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
+tom-select@2.5.2:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.5.2.tgz#77dd4bc780b1ea72905337b24f04ce19dc6d2ca1"
+  integrity sha512-VAlGj5MBWVLMJje2NwA3XSmxa7CUFpp1tdzFZ8wymCkcLeP0NwF4ARmSuUK4BWbmSN1fETlSazWkMIxEpP4GdQ==
   dependencies:
     "@orchidjs/sifter" "^1.1.0"
     "@orchidjs/unicode-variants" "^1.1.2"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.5.3"
+version: "4.5.4"
 edition: "Community"
-published: "2026-02-17"
+published: "2026-03-03"

+ 8 - 6
netbox/templates/dcim/device/attrs/ipaddress.html

@@ -1,10 +1,12 @@
 {% load i18n %}
-<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
-{% if value.nat_inside %}
-  ({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
-{% elif value.nat_outside.exists %}
-  ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
-{% endif %}
+<span>
+  <a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
+  {% if value.nat_inside %}
+    ({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
+  {% elif value.nat_outside.exists %}
+    ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+  {% endif %}
+</span>
 <a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
   <i class="mdi mdi-content-copy"></i>
 </a>

+ 9 - 9
netbox/tenancy/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, StrFilterLookup
 
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from netbox.graphql.filters import (
@@ -60,8 +60,8 @@ __all__ = (
 
 @strawberry_django.filter_type(models.Tenant, lookups=True)
 class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -153,12 +153,12 @@ class TenantGroupFilter(OrganizationalModelFilter):
 
 @strawberry_django.filter_type(models.Contact, lookups=True)
 class ContactFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    title: FilterLookup[str] | None = strawberry_django.filter_field()
-    phone: FilterLookup[str] | None = strawberry_django.filter_field()
-    email: FilterLookup[str] | None = strawberry_django.filter_field()
-    address: FilterLookup[str] | None = strawberry_django.filter_field()
-    link: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    title: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    phone: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    email: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    link: StrFilterLookup[str] | None = strawberry_django.filter_field()
     groups: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/de/LC_MESSAGES/django.po


+ 104 - 104
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-02-21 05:16+0000\n"
+"POT-Creation-Date: 2026-03-03 05:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -41,9 +41,9 @@ msgstr ""
 
 #: netbox/circuits/choices.py:21 netbox/dcim/choices.py:20
 #: netbox/dcim/choices.py:102 netbox/dcim/choices.py:204
-#: netbox/dcim/choices.py:257 netbox/dcim/choices.py:1929
-#: netbox/dcim/choices.py:1987 netbox/dcim/choices.py:2054
-#: netbox/dcim/choices.py:2076 netbox/virtualization/choices.py:20
+#: netbox/dcim/choices.py:257 netbox/dcim/choices.py:1933
+#: netbox/dcim/choices.py:1991 netbox/dcim/choices.py:2058
+#: netbox/dcim/choices.py:2080 netbox/virtualization/choices.py:20
 #: netbox/virtualization/choices.py:46 netbox/vpn/choices.py:18
 #: netbox/vpn/choices.py:281
 msgid "Planned"
@@ -57,8 +57,8 @@ msgstr ""
 #: netbox/core/tables/tasks.py:23 netbox/dcim/choices.py:22
 #: netbox/dcim/choices.py:103 netbox/dcim/choices.py:155
 #: netbox/dcim/choices.py:203 netbox/dcim/choices.py:256
-#: netbox/dcim/choices.py:1986 netbox/dcim/choices.py:2053
-#: netbox/dcim/choices.py:2075 netbox/extras/tables/tables.py:642
+#: netbox/dcim/choices.py:1990 netbox/dcim/choices.py:2057
+#: netbox/dcim/choices.py:2079 netbox/extras/tables/tables.py:642
 #: netbox/ipam/choices.py:31 netbox/ipam/choices.py:49
 #: netbox/ipam/choices.py:69 netbox/ipam/choices.py:154
 #: netbox/templates/extras/configcontext.html:29
@@ -70,8 +70,8 @@ msgid "Active"
 msgstr ""
 
 #: netbox/circuits/choices.py:24 netbox/dcim/choices.py:202
-#: netbox/dcim/choices.py:255 netbox/dcim/choices.py:1985
-#: netbox/dcim/choices.py:2055 netbox/dcim/choices.py:2074
+#: netbox/dcim/choices.py:255 netbox/dcim/choices.py:1989
+#: netbox/dcim/choices.py:2059 netbox/dcim/choices.py:2078
 #: netbox/virtualization/choices.py:24 netbox/virtualization/choices.py:44
 msgid "Offline"
 msgstr ""
@@ -84,7 +84,7 @@ msgstr ""
 msgid "Decommissioned"
 msgstr ""
 
-#: netbox/circuits/choices.py:90 netbox/dcim/choices.py:1998
+#: netbox/circuits/choices.py:90 netbox/dcim/choices.py:2002
 #: netbox/dcim/tables/devices.py:1208 netbox/templates/dcim/interface.html:148
 #: netbox/tenancy/choices.py:17
 msgid "Primary"
@@ -1995,7 +1995,7 @@ msgstr ""
 #: netbox/core/choices.py:22 netbox/core/choices.py:59
 #: netbox/core/constants.py:21 netbox/core/tables/tasks.py:35
 #: netbox/dcim/choices.py:206 netbox/dcim/choices.py:259
-#: netbox/dcim/choices.py:1988 netbox/dcim/choices.py:2078
+#: netbox/dcim/choices.py:1992 netbox/dcim/choices.py:2082
 #: netbox/virtualization/choices.py:48
 msgid "Failed"
 msgstr ""
@@ -2181,7 +2181,7 @@ msgid "User name"
 msgstr ""
 
 #: netbox/core/forms/bulk_edit.py:25 netbox/core/forms/filtersets.py:47
-#: netbox/core/tables/data.py:28 netbox/dcim/choices.py:2036
+#: netbox/core/tables/data.py:28 netbox/dcim/choices.py:2040
 #: netbox/dcim/forms/bulk_edit.py:1105 netbox/dcim/forms/bulk_edit.py:1386
 #: netbox/dcim/forms/filtersets.py:1619 netbox/dcim/forms/filtersets.py:1712
 #: netbox/dcim/tables/devices.py:581 netbox/dcim/tables/devicetypes.py:233
@@ -2375,7 +2375,7 @@ msgstr ""
 msgid "Rack Elevations"
 msgstr ""
 
-#: netbox/core/forms/model_forms.py:160 netbox/dcim/choices.py:1907
+#: netbox/core/forms/model_forms.py:160 netbox/dcim/choices.py:1911
 #: netbox/dcim/forms/bulk_edit.py:944 netbox/dcim/forms/bulk_edit.py:1340
 #: netbox/dcim/forms/bulk_edit.py:1361 netbox/dcim/tables/racks.py:144
 #: netbox/netbox/navigation/menu.py:316 netbox/netbox/navigation/menu.py:320
@@ -3041,8 +3041,8 @@ msgid "Staging"
 msgstr ""
 
 #: netbox/dcim/choices.py:23 netbox/dcim/choices.py:208
-#: netbox/dcim/choices.py:260 netbox/dcim/choices.py:1930
-#: netbox/dcim/choices.py:2079 netbox/virtualization/choices.py:23
+#: netbox/dcim/choices.py:260 netbox/dcim/choices.py:1934
+#: netbox/dcim/choices.py:2083 netbox/virtualization/choices.py:23
 #: netbox/virtualization/choices.py:49 netbox/vpn/choices.py:282
 msgid "Decommissioning"
 msgstr ""
@@ -3108,7 +3108,7 @@ msgstr ""
 msgid "Millimeters"
 msgstr ""
 
-#: netbox/dcim/choices.py:115 netbox/dcim/choices.py:1952
+#: netbox/dcim/choices.py:115 netbox/dcim/choices.py:1956
 msgid "Inches"
 msgstr ""
 
@@ -3186,7 +3186,7 @@ msgid "Rear"
 msgstr ""
 
 #: netbox/dcim/choices.py:205 netbox/dcim/choices.py:258
-#: netbox/dcim/choices.py:2077 netbox/virtualization/choices.py:47
+#: netbox/dcim/choices.py:2081 netbox/virtualization/choices.py:47
 msgid "Staged"
 msgstr ""
 
@@ -3219,7 +3219,7 @@ msgid "Top to bottom"
 msgstr ""
 
 #: netbox/dcim/choices.py:235 netbox/dcim/choices.py:280
-#: netbox/dcim/choices.py:1562
+#: netbox/dcim/choices.py:1566
 msgid "Passive"
 msgstr ""
 
@@ -3248,8 +3248,8 @@ msgid "Proprietary"
 msgstr ""
 
 #: netbox/dcim/choices.py:606 netbox/dcim/choices.py:853
-#: netbox/dcim/choices.py:1474 netbox/dcim/choices.py:1476
-#: netbox/dcim/choices.py:1712 netbox/dcim/choices.py:1714
+#: netbox/dcim/choices.py:1478 netbox/dcim/choices.py:1480
+#: netbox/dcim/choices.py:1716 netbox/dcim/choices.py:1718
 #: netbox/netbox/navigation/menu.py:212
 msgid "Other"
 msgstr ""
@@ -3262,11 +3262,11 @@ msgstr ""
 msgid "Physical"
 msgstr ""
 
-#: netbox/dcim/choices.py:884 netbox/dcim/choices.py:1151
+#: netbox/dcim/choices.py:884 netbox/dcim/choices.py:1153
 msgid "Virtual"
 msgstr ""
 
-#: netbox/dcim/choices.py:885 netbox/dcim/choices.py:1351
+#: netbox/dcim/choices.py:885 netbox/dcim/choices.py:1355
 #: netbox/dcim/forms/bulk_edit.py:1546 netbox/dcim/forms/filtersets.py:1577
 #: netbox/dcim/forms/filtersets.py:1703 netbox/dcim/forms/model_forms.py:1125
 #: netbox/dcim/forms/model_forms.py:1589 netbox/netbox/navigation/menu.py:150
@@ -3275,11 +3275,11 @@ msgstr ""
 msgid "Wireless"
 msgstr ""
 
-#: netbox/dcim/choices.py:1149
+#: netbox/dcim/choices.py:1151
 msgid "Virtual interfaces"
 msgstr ""
 
-#: netbox/dcim/choices.py:1152 netbox/dcim/forms/bulk_edit.py:1399
+#: netbox/dcim/choices.py:1154 netbox/dcim/forms/bulk_edit.py:1399
 #: netbox/dcim/forms/bulk_import.py:949 netbox/dcim/forms/model_forms.py:1107
 #: netbox/dcim/tables/devices.py:741 netbox/templates/dcim/interface.html:112
 #: netbox/virtualization/forms/bulk_edit.py:177
@@ -3288,67 +3288,67 @@ msgstr ""
 msgid "Bridge"
 msgstr ""
 
-#: netbox/dcim/choices.py:1153
+#: netbox/dcim/choices.py:1155
 msgid "Link Aggregation Group (LAG)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1157
+#: netbox/dcim/choices.py:1159
 msgid "FastEthernet (100 Mbps)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1166
+#: netbox/dcim/choices.py:1168
 msgid "GigabitEthernet (1 Gbps)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1184
+#: netbox/dcim/choices.py:1186
 msgid "2.5/5 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1191
+#: netbox/dcim/choices.py:1193
 msgid "10 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1206
+#: netbox/dcim/choices.py:1209
 msgid "25 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1216
+#: netbox/dcim/choices.py:1219
 msgid "40 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1226
+#: netbox/dcim/choices.py:1230
 msgid "50 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1236
+#: netbox/dcim/choices.py:1240
 msgid "100 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1257
+#: netbox/dcim/choices.py:1261
 msgid "200 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1271
+#: netbox/dcim/choices.py:1275
 msgid "400 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1289
+#: netbox/dcim/choices.py:1293
 msgid "800 Gbps Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1298
+#: netbox/dcim/choices.py:1302
 msgid "Pluggable transceivers"
 msgstr ""
 
-#: netbox/dcim/choices.py:1335
+#: netbox/dcim/choices.py:1339
 msgid "Backplane Ethernet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1367
+#: netbox/dcim/choices.py:1371
 msgid "Cellular"
 msgstr ""
 
-#: netbox/dcim/choices.py:1419 netbox/dcim/forms/filtersets.py:425
+#: netbox/dcim/choices.py:1423 netbox/dcim/forms/filtersets.py:425
 #: netbox/dcim/forms/filtersets.py:911 netbox/dcim/forms/filtersets.py:1112
 #: netbox/dcim/forms/filtersets.py:1910
 #: netbox/templates/dcim/inventoryitem.html:56
@@ -3356,255 +3356,255 @@ msgstr ""
 msgid "Serial"
 msgstr ""
 
-#: netbox/dcim/choices.py:1434
+#: netbox/dcim/choices.py:1438
 msgid "Coaxial"
 msgstr ""
 
-#: netbox/dcim/choices.py:1455
+#: netbox/dcim/choices.py:1459
 msgid "Stacking"
 msgstr ""
 
-#: netbox/dcim/choices.py:1507
+#: netbox/dcim/choices.py:1511
 msgid "Half"
 msgstr ""
 
-#: netbox/dcim/choices.py:1508
+#: netbox/dcim/choices.py:1512
 msgid "Full"
 msgstr ""
 
-#: netbox/dcim/choices.py:1509 netbox/netbox/preferences.py:32
+#: netbox/dcim/choices.py:1513 netbox/netbox/preferences.py:32
 #: netbox/wireless/choices.py:480
 msgid "Auto"
 msgstr ""
 
-#: netbox/dcim/choices.py:1521
+#: netbox/dcim/choices.py:1525
 msgid "Access"
 msgstr ""
 
-#: netbox/dcim/choices.py:1522 netbox/ipam/tables/vlans.py:150
+#: netbox/dcim/choices.py:1526 netbox/ipam/tables/vlans.py:150
 #: netbox/ipam/tables/vlans.py:210
 #: netbox/templates/dcim/inc/interface_vlans_table.html:7
 msgid "Tagged"
 msgstr ""
 
-#: netbox/dcim/choices.py:1523
+#: netbox/dcim/choices.py:1527
 msgid "Tagged (All)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1524 netbox/templates/ipam/vlan_edit.html:26
+#: netbox/dcim/choices.py:1528 netbox/templates/ipam/vlan_edit.html:26
 msgid "Q-in-Q (802.1ad)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1553
+#: netbox/dcim/choices.py:1557
 msgid "IEEE Standard"
 msgstr ""
 
-#: netbox/dcim/choices.py:1564
+#: netbox/dcim/choices.py:1568
 msgid "Passive 24V (2-pair)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1565
+#: netbox/dcim/choices.py:1569
 msgid "Passive 24V (4-pair)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1566
+#: netbox/dcim/choices.py:1570
 msgid "Passive 48V (2-pair)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1567
+#: netbox/dcim/choices.py:1571
 msgid "Passive 48V (4-pair)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1640
+#: netbox/dcim/choices.py:1644
 msgid "Copper"
 msgstr ""
 
-#: netbox/dcim/choices.py:1663
+#: netbox/dcim/choices.py:1667
 msgid "Fiber Optic"
 msgstr ""
 
-#: netbox/dcim/choices.py:1699 netbox/dcim/choices.py:1913
+#: netbox/dcim/choices.py:1703 netbox/dcim/choices.py:1917
 msgid "USB"
 msgstr ""
 
-#: netbox/dcim/choices.py:1755
+#: netbox/dcim/choices.py:1759
 msgid "Single"
 msgstr ""
 
-#: netbox/dcim/choices.py:1757
+#: netbox/dcim/choices.py:1761
 msgid "1C1P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1758
+#: netbox/dcim/choices.py:1762
 msgid "1C2P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1759
+#: netbox/dcim/choices.py:1763
 msgid "1C4P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1760
+#: netbox/dcim/choices.py:1764
 msgid "1C6P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1761
+#: netbox/dcim/choices.py:1765
 msgid "1C8P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1762
+#: netbox/dcim/choices.py:1766
 msgid "1C12P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1763
+#: netbox/dcim/choices.py:1767
 msgid "1C16P"
 msgstr ""
 
-#: netbox/dcim/choices.py:1767
+#: netbox/dcim/choices.py:1771
 msgid "Trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1769
+#: netbox/dcim/choices.py:1773
 msgid "2C1P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1770
+#: netbox/dcim/choices.py:1774
 msgid "2C2P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1771
+#: netbox/dcim/choices.py:1775
 msgid "2C4P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1772
+#: netbox/dcim/choices.py:1776
 msgid "2C4P trunk (shuffle)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1773
+#: netbox/dcim/choices.py:1777
 msgid "2C6P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1774
+#: netbox/dcim/choices.py:1778
 msgid "2C8P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1775
+#: netbox/dcim/choices.py:1779
 msgid "2C12P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1776
+#: netbox/dcim/choices.py:1780
 msgid "4C1P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1777
+#: netbox/dcim/choices.py:1781
 msgid "4C2P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1778
+#: netbox/dcim/choices.py:1782
 msgid "4C4P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1779
+#: netbox/dcim/choices.py:1783
 msgid "4C4P trunk (shuffle)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1780
+#: netbox/dcim/choices.py:1784
 msgid "4C6P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1781
+#: netbox/dcim/choices.py:1785
 msgid "4C8P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1782
+#: netbox/dcim/choices.py:1786
 msgid "8C4P trunk"
 msgstr ""
 
-#: netbox/dcim/choices.py:1786
+#: netbox/dcim/choices.py:1790
 msgid "Breakout"
 msgstr ""
 
-#: netbox/dcim/choices.py:1788
+#: netbox/dcim/choices.py:1792
 msgid "1C4P:4C1P breakout"
 msgstr ""
 
-#: netbox/dcim/choices.py:1789
+#: netbox/dcim/choices.py:1793
 msgid "1C6P:6C1P breakout"
 msgstr ""
 
-#: netbox/dcim/choices.py:1790
+#: netbox/dcim/choices.py:1794
 msgid "2C4P:8C1P breakout (shuffle)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1848
+#: netbox/dcim/choices.py:1852
 msgid "Copper - Twisted Pair (UTP/STP)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1862
+#: netbox/dcim/choices.py:1866
 msgid "Copper - Twinax (DAC)"
 msgstr ""
 
-#: netbox/dcim/choices.py:1869
+#: netbox/dcim/choices.py:1873
 msgid "Copper - Coaxial"
 msgstr ""
 
-#: netbox/dcim/choices.py:1884
+#: netbox/dcim/choices.py:1888
 msgid "Fiber - Multimode"
 msgstr ""
 
-#: netbox/dcim/choices.py:1895
+#: netbox/dcim/choices.py:1899
 msgid "Fiber - Single-mode"
 msgstr ""
 
-#: netbox/dcim/choices.py:1903
+#: netbox/dcim/choices.py:1907
 msgid "Fiber - Other"
 msgstr ""
 
-#: netbox/dcim/choices.py:1928 netbox/dcim/forms/filtersets.py:1402
+#: netbox/dcim/choices.py:1932 netbox/dcim/forms/filtersets.py:1402
 msgid "Connected"
 msgstr ""
 
-#: netbox/dcim/choices.py:1947 netbox/netbox/choices.py:177
+#: netbox/dcim/choices.py:1951 netbox/netbox/choices.py:177
 msgid "Kilometers"
 msgstr ""
 
-#: netbox/dcim/choices.py:1948 netbox/netbox/choices.py:178
+#: netbox/dcim/choices.py:1952 netbox/netbox/choices.py:178
 #: netbox/templates/dcim/cable_trace.html:65
 msgid "Meters"
 msgstr ""
 
-#: netbox/dcim/choices.py:1949
+#: netbox/dcim/choices.py:1953
 msgid "Centimeters"
 msgstr ""
 
-#: netbox/dcim/choices.py:1950 netbox/netbox/choices.py:179
+#: netbox/dcim/choices.py:1954 netbox/netbox/choices.py:179
 msgid "Miles"
 msgstr ""
 
-#: netbox/dcim/choices.py:1951 netbox/netbox/choices.py:180
+#: netbox/dcim/choices.py:1955 netbox/netbox/choices.py:180
 #: netbox/templates/dcim/cable_trace.html:66
 msgid "Feet"
 msgstr ""
 
-#: netbox/dcim/choices.py:1999
+#: netbox/dcim/choices.py:2003
 msgid "Redundant"
 msgstr ""
 
-#: netbox/dcim/choices.py:2020
+#: netbox/dcim/choices.py:2024
 msgid "Single phase"
 msgstr ""
 
-#: netbox/dcim/choices.py:2021
+#: netbox/dcim/choices.py:2025
 msgid "Three-phase"
 msgstr ""
 
-#: netbox/dcim/choices.py:2037 netbox/extras/choices.py:53
+#: netbox/dcim/choices.py:2041 netbox/extras/choices.py:53
 #: netbox/netbox/preferences.py:45 netbox/netbox/preferences.py:70
 #: netbox/templates/extras/customfield.html:78 netbox/vpn/choices.py:20
 #: netbox/wireless/choices.py:27
 msgid "Disabled"
 msgstr ""
 
-#: netbox/dcim/choices.py:2038
+#: netbox/dcim/choices.py:2042
 msgid "Faulty"
 msgstr ""
 
@@ -13824,17 +13824,17 @@ msgstr ""
 msgid "Not Connected"
 msgstr ""
 
-#: netbox/templates/dcim/device/attrs/ipaddress.html:4
+#: netbox/templates/dcim/device/attrs/ipaddress.html:5
 #: netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html:4
 msgid "NAT for"
 msgstr ""
 
-#: netbox/templates/dcim/device/attrs/ipaddress.html:6
+#: netbox/templates/dcim/device/attrs/ipaddress.html:7
 #: netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html:6
 msgid "NAT"
 msgstr ""
 
-#: netbox/templates/dcim/device/attrs/ipaddress.html:8
+#: netbox/templates/dcim/device/attrs/ipaddress.html:10
 #: netbox/templates/ui/actions/copy_content.html:2
 #: netbox/templates/ui/attrs/numeric.html:9
 #: netbox/templates/ui/attrs/text.html:4
@@ -16677,7 +16677,7 @@ msgstr ""
 msgid "A column named {name} is already defined for table {table_name}"
 msgstr ""
 
-#: netbox/utilities/templates/builtins/customfield_value.html:30
+#: netbox/utilities/templates/builtins/customfield_value.html:32
 msgid "Not defined"
 msgstr ""
 

BIN
netbox/translations/es/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/it/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/it/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 342 - 346
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/lv/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/lv/LC_MESSAGES/django.po


BIN
netbox/translations/nl/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/nl/LC_MESSAGES/django.po


BIN
netbox/translations/pl/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/pl/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/tr/LC_MESSAGES/django.po


BIN
netbox/translations/uk/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/uk/LC_MESSAGES/django.po


BIN
netbox/translations/zh/LC_MESSAGES/django.mo


File diff suppressed because it is too large
+ 343 - 347
netbox/translations/zh/LC_MESSAGES/django.po


+ 11 - 11
netbox/users/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import Annotated
 
 import strawberry
 import strawberry_django
-from strawberry_django import DatetimeFilterLookup, FilterLookup
+from strawberry_django import DatetimeFilterLookup, FilterLookup, StrFilterLookup
 
 from netbox.graphql.filters import BaseModelFilter
 from users import models
@@ -18,16 +18,16 @@ __all__ = (
 
 @strawberry_django.filter_type(models.Group, lookups=True)
 class GroupFilter(BaseModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.User, lookups=True)
 class UserFilter(BaseModelFilter):
-    username: FilterLookup[str] | None = strawberry_django.filter_field()
-    first_name: FilterLookup[str] | None = strawberry_django.filter_field()
-    last_name: FilterLookup[str] | None = strawberry_django.filter_field()
-    email: FilterLookup[str] | None = strawberry_django.filter_field()
+    username: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    first_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    last_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    email: StrFilterLookup[str] | None = strawberry_django.filter_field()
     is_superuser: FilterLookup[bool] | None = strawberry_django.filter_field()
     is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
     date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
@@ -37,8 +37,8 @@ class UserFilter(BaseModelFilter):
 
 @strawberry_django.filter_type(models.Owner, lookups=True)
 class OwnerFilter(BaseModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
     group: Annotated['OwnerGroupFilter', strawberry.lazy('users.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -50,5 +50,5 @@ class OwnerFilter(BaseModelFilter):
 
 @strawberry_django.filter_type(models.OwnerGroup, lookups=True)
 class OwnerGroupFilter(BaseModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 2 - 0
netbox/utilities/templates/builtins/customfield_value.html

@@ -2,6 +2,8 @@
 {% load i18n %}
 {% if customfield.type == 'integer' and value is not None %}
   {{ value }}
+{% elif customfield.type == 'decimal' and value is not None %}
+  {{ value }}
 {% elif customfield.type == 'longtext' and value %}
   {{ value|markdown }}
 {% elif customfield.type == 'boolean' and value == True %}

+ 3 - 3
netbox/virtualization/graphql/filter_mixins.py

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import FilterLookup
+from strawberry_django import StrFilterLookup
 
 if TYPE_CHECKING:
     from .filters import VirtualMachineFilter
@@ -20,5 +20,5 @@ class VMComponentFilterMixin:
         strawberry_django.filter_field()
     )
     virtual_machine_id: ID | None = strawberry_django.filter_field()
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 4 - 4
netbox/virtualization/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
 
 from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin
 from extras.graphql.filter_mixins import ConfigContextFilterMixin
@@ -39,7 +39,7 @@ __all__ = (
 
 @strawberry_django.filter_type(models.Cluster, lookups=True)
 class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -77,7 +77,7 @@ class VirtualMachineFilter(
     TenancyFilterMixin,
     PrimaryModelFilter,
 ):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
@@ -116,7 +116,7 @@ class VirtualMachineFilter(
     disk: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    serial: FilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
     interface_count: FilterLookup[int] | None = strawberry_django.filter_field()
     virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field()
     interfaces: Annotated['VMInterfaceFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (

+ 10 - 10
netbox/vpn/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, StrFilterLookup
 
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from netbox.graphql.filters import (
@@ -63,7 +63,7 @@ class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLo
 
 @strawberry_django.filter_type(models.Tunnel, lookups=True)
 class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -89,7 +89,7 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.IKEProposal, lookups=True)
 class IKEProposalFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     authentication_method: (
         BaseFilterLookup[Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')]] | None
     ) = (
@@ -118,7 +118,7 @@ class IKEProposalFilter(PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.IKEPolicy, lookups=True)
 class IKEPolicyFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     version: BaseFilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -128,12 +128,12 @@ class IKEPolicyFilter(PrimaryModelFilter):
     proposals: Annotated['IKEProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    preshared_key: FilterLookup[str] | None = strawberry_django.filter_field()
+    preshared_key: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.IPSecProposal, lookups=True)
 class IPSecProposalFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     encryption_algorithm: (
         BaseFilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None
     ) = (
@@ -159,7 +159,7 @@ class IPSecProposalFilter(PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.IPSecPolicy, lookups=True)
 class IPSecPolicyFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -170,7 +170,7 @@ class IPSecPolicyFilter(PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.IPSecProfile, lookups=True)
 class IPSecProfileFilter(PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     mode: BaseFilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -186,8 +186,8 @@ class IPSecProfileFilter(PrimaryModelFilter):
 
 @strawberry_django.filter_type(models.L2VPN, lookups=True)
 class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
     type: BaseFilterLookup[Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )

+ 2 - 2
netbox/wireless/graphql/filter_mixins.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 
 import strawberry
 import strawberry_django
-from strawberry_django import FilterLookup
+from strawberry_django import StrFilterLookup
 
 if TYPE_CHECKING:
     from .enums import *
@@ -21,4 +21,4 @@ class WirelessAuthenticationFilterMixin:
     auth_cipher: Annotated['WirelessAuthCipherEnum', strawberry.lazy('wireless.graphql.enums')] | None = (
         strawberry_django.filter_field()
     )
-    auth_psk: FilterLookup[str] | None = strawberry_django.filter_field()
+    auth_psk: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 3 - 3
netbox/wireless/graphql/filters.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, FilterLookup
+from strawberry_django import BaseFilterLookup, StrFilterLookup
 
 from dcim.graphql.filter_mixins import ScopedFilterMixin
 from netbox.graphql.filter_mixins import DistanceFilterMixin
@@ -38,7 +38,7 @@ class WirelessLANFilter(
     TenancyFilterMixin,
     PrimaryModelFilter
 ):
-    ssid: FilterLookup[str] | None = strawberry_django.filter_field()
+    ssid: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -65,7 +65,7 @@ class WirelessLinkFilter(
         strawberry_django.filter_field()
     )
     interface_b_id: ID | None = strawberry_django.filter_field()
-    ssid: FilterLookup[str] | None = strawberry_django.filter_field()
+    ssid: StrFilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )

+ 1 - 1
pyproject.toml

@@ -3,7 +3,7 @@
 
 [project]
 name = "netbox"
-version = "4.5.3"
+version = "4.5.4"
 requires-python = ">=3.12"
 description = "The premier source of truth powering network automation."
 readme = "README.md"

+ 9 - 8
requirements.txt

@@ -17,27 +17,28 @@ django-taggit==6.1.0
 django-timezone-field==7.2.1
 djangorestframework==3.16.1
 drf-spectacular==0.29.0
-drf-spectacular-sidecar==2026.1.1
+drf-spectacular-sidecar==2026.3.1
 feedparser==6.0.12
-gunicorn==25.0.3
+gunicorn==25.1.0
 Jinja2==3.1.6
 jsonschema==4.26.0
 Markdown==3.10.2
-mkdocs-material==9.7.1
+mkdocs==1.6.1
+mkdocs-material==9.7.3
 mkdocstrings==1.0.3
-mkdocstrings-python==2.0.2
+mkdocstrings-python==2.0.3
 netaddr==1.3.0
 nh3==0.3.3
 Pillow==12.1.1
-psycopg[c,pool]==3.3.2
+psycopg[c,pool]==3.3.3
 PyYAML==6.0.3
 requests==2.32.5
-rq==2.6.1
+rq==2.7.0
 social-auth-app-django==5.7.0
 social-auth-core==4.8.5
 sorl-thumbnail==13.0.0
-strawberry-graphql==0.295.0
-strawberry-graphql-django==0.75.0
+strawberry-graphql==0.307.1
+strawberry-graphql-django==0.79.0
 svgwrite==1.4.3
 tablib==3.9.0
 tzdata==2025.3

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