Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Novinger
5e57cec369 Closes #21157: Add public models to export template context
Move shared get_context() logic from ConfigTemplate into
RenderTemplateMixin so ExportTemplate also gets access to all
public model classes. This enables export templates to perform
cross-model lookups (e.g. resolving parent Prefix from IPAddress).
2026-03-10 16:03:28 -05:00
346 changed files with 54696 additions and 46214 deletions

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v4
- name: Check Python linting & PEP8 compliance
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
@@ -63,12 +63,12 @@ jobs:
src: "netbox/"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
@@ -76,7 +76,7 @@ jobs:
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: yarn

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -26,38 +26,20 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v4
with:
fetch-depth: 1
# Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
# (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
# Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
- name: Configure git remote for fork PRs
# Workaround for claude-code-action bug with fork PRs: The action tries to fetch by branch name, which doesn't
# exist on origin for forks. Pre-fetch the PR ref so it's available as a local ref.
- name: Fetch fork PR ref (if applicable)
if: github.event.issue.pull_request != '' && github.event.issue.pull_request != null
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine PR number based on event type
if [ "${{ github.event_name }}" = "issue_comment" ]; then
PR_NUMBER="${{ github.event.issue.number }}"
elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
exit 0 # issues event — no PR branch to worry about
fi
# Fetch fork info in one API call; silently skip if this is not a PR
PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
if [ -z "$PR_INFO" ]; then
exit 0
fi
IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
if [ "$IS_FORK" = "true" ]; then
FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
PR_NUMBER=$(gh pr view ${{ github.event.issue.number }} --json number -q .number 2>/dev/null || echo "")
if [ -n "$PR_NUMBER" ]; then
git fetch origin refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head || true
fi
- name: Run Claude Code

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,12 +27,12 @@ jobs:
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v5
with:
python-version: 3.12

View File

@@ -54,8 +54,7 @@ python manage.py nbshell # NetBox-enhanced shell
## Architecture Conventions
- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
- **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`).
@@ -69,8 +68,6 @@ python manage.py nbshell # NetBox-enhanced shell
- API serializers must include a `url` field (absolute URL of the object).
- Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
- Avoid adding new dependencies without strong justification.
- Avoid running `ruff format` on existing files, as this tends to introduce unnecessary style changes.
- Don't craft Django database migrations manually: Prompt the user to run `manage.py makemigrations` instead.
## Branch & PR Conventions
- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)

View File

@@ -416,13 +416,9 @@
"800gbase-dr8",
"800gbase-sr8",
"800gbase-vr8",
"1.6tbase-cr8",
"1.6tbase-dr8",
"1.6tbase-dr8-2",
"100base-x-sfp",
"1000base-x-gbic",
"1000base-x-sfp",
"2.5gbase-x-sfp",
"10gbase-x-sfpp",
"10gbase-x-xenpak",
"10gbase-x-xfp",
@@ -452,9 +448,6 @@
"400gbase-x-osfp-rhs",
"800gbase-x-osfp",
"800gbase-x-qsfpdd",
"1.6tbase-x-osfp1600",
"1.6tbase-x-osfp1600-rhs",
"1.6tbase-x-qsfpdd1600",
"1000base-kx",
"2.5gbase-kx",
"5gbase-kr",
@@ -466,7 +459,6 @@
"100gbase-kp4",
"100gbase-kr2",
"100gbase-kr4",
"1.6tbase-kr8",
"ieee802.11a",
"ieee802.11g",
"ieee802.11n",

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -215,7 +215,6 @@ if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot()
obj.property = "New Value"
obj._changelog_message = 'Example Message Text' # Optional
obj.full_clean()
obj.save()
```
@@ -384,18 +383,6 @@ A calendar date. Returns a `datetime.date` object.
A complete date & time. Returns a `datetime.datetime` object.
## Uploading Scripts via the API
Script modules can be uploaded to NetBox via the REST API by sending a `multipart/form-data` POST request to `/api/extras/scripts/upload/`. The caller must have the `extras.add_scriptmodule` and `core.add_managedfile` permissions.
```no-highlight
curl -X POST \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-F "file=@/path/to/myscript.py" \
http://netbox/api/extras/scripts/upload/
```
## Running Custom Scripts
!!! note

View File

@@ -36,16 +36,13 @@ If false, synchronization will be disabled.
### Ignore Rules
A set of rules (one per line) identifying files or paths to ignore during synchronization. Rules are matched against both the full relative path (e.g. `subdir/file.txt`) and the bare filename, so path-based patterns can be used to exclude entire directories. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
| Rule | Description |
|-----------------------|------------------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
| `subdir/*` | Ignore all files within `subdir/` |
| `subdir/*/*` | Ignore all files one level deep within `subdir/` |
| `*/dev/*` | Ignore files inside any directory named `dev/` |
| Rule | Description |
|----------------|------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
### Sync Interval

View File

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

View File

@@ -1,90 +1,5 @@
# NetBox v4.5
## v4.5.7 (2026-04-03)
### Enhancements
* [#21095](https://github.com/netbox-community/netbox/issues/21095) - Adopt IEC unit labels (e.g. GiB) for virtual machine resources
* [#21696](https://github.com/netbox-community/netbox/issues/21696) - Add support for django-rq 4.0 and introduce `RQ` configuration parameter
* [#21701](https://github.com/netbox-community/netbox/issues/21701) - Support uploading custom scripts via the REST API (`/api/extras/scripts/upload/`)
* [#21760](https://github.com/netbox-community/netbox/issues/21760) - Add a 1C2P:2C1P breakout cable profile
### Performance Improvements
* [#21655](https://github.com/netbox-community/netbox/issues/21655) - Optimize queries for object and multi-object type custom fields
### Bug Fixes
* [#20474](https://github.com/netbox-community/netbox/issues/20474) - Fix installation of modules with placeholder values in component names
* [#21498](https://github.com/netbox-community/netbox/issues/21498) - Fix server error triggered by event rules referencing deleted objects
* [#21533](https://github.com/netbox-community/netbox/issues/21533) - Ensure read-only fields are included in REST API responses upon object creation
* [#21535](https://github.com/netbox-community/netbox/issues/21535) - Fix filtering of object-type custom fields when "is empty" is selected
* [#21784](https://github.com/netbox-community/netbox/issues/21784) - Fix `AttributeError` exception when sorting a table as an anonymous user
* [#21808](https://github.com/netbox-community/netbox/issues/21808) - Fix `RelatedObjectDoesNotExist` exception when viewing an interface with a virtual circuit termination
* [#21810](https://github.com/netbox-community/netbox/issues/21810) - Fix `AttributeError` exception when viewing virtual chassis member
* [#21825](https://github.com/netbox-community/netbox/issues/21825) - Fix sorting by broken columns in several object lists
---
## v4.5.6 (2026-03-31)
### Enhancements
* [#21480](https://github.com/netbox-community/netbox/issues/21480) - Add OSFP224 (1.6T) interface type
* [#21727](https://github.com/netbox-community/netbox/issues/21727) - Add 2.5GBASE-X SFP modular interface type
* [#21743](https://github.com/netbox-community/netbox/issues/21743) - Improve object change diff styling and layout
* [#21793](https://github.com/netbox-community/netbox/issues/21793) - Add 50 Gbps, 800 Gbps, and 1.6 Tbps interface speed options
### Bug Fixes
* [#20467](https://github.com/netbox-community/netbox/issues/20467) - Fix resolution of the `{module}` variable for position fields in nested modules
* [#21698](https://github.com/netbox-community/netbox/issues/21698) - Adjust custom field URL filter to support non-standard port numbers
* [#21707](https://github.com/netbox-community/netbox/issues/21707) - Fix grouping of owner fields in provider account add/edit forms
* [#21749](https://github.com/netbox-community/netbox/issues/21749) - Fix `FieldError` exception when sorting the circuit group assignment table by the member column
* [#21763](https://github.com/netbox-community/netbox/issues/21763) - Use separate add/remove form fields when editing a site or provider with a large number of ASNs assigned
---
## v4.5.5 (2026-03-17)
### Enhancements
* [#21114](https://github.com/netbox-community/netbox/issues/21114) - Support path exclusions for data source synchronization
* [#21578](https://github.com/netbox-community/netbox/issues/21578) - Support identifying scope object by name or slug when bulk importing scoped objects
### Performance Improvements
* [#21330](https://github.com/netbox-community/netbox/issues/21330) - Optimize the assignment of tags when saving objects
* [#21402](https://github.com/netbox-community/netbox/issues/21402) - Avoid excessive database queries when rendering unnamed devices via the REST API
* [#21611](https://github.com/netbox-community/netbox/issues/21611) - Replace inefficient calls to `.count()` with `.exists()`
### Bug Fixes
* [#19867](https://github.com/netbox-community/netbox/issues/19867) - Preserve the "per page" pagination setting when returning from object edit forms
* [#20077](https://github.com/netbox-community/netbox/issues/20077) - Fix form field focus bug in Microsoft Edge
* [#20385](https://github.com/netbox-community/netbox/issues/20385) - Enforce `MAX_PAGE_SIZE` limit for GraphQL API requests
* [#20468](https://github.com/netbox-community/netbox/issues/20468) - Fix range-based filter lookups for integer fields in GraphQL API
* [#20915](https://github.com/netbox-community/netbox/issues/20915) - Restore user language preference after login via social authentication
* [#20934](https://github.com/netbox-community/netbox/issues/20934) - Fix dark mode flicker on page load
* [#21012](https://github.com/netbox-community/netbox/issues/21012) - Add pagination for VLAN table on interface view to prevent silent truncation at 100 entries
* [#21380](https://github.com/netbox-community/netbox/issues/21380) - Fix display of the background tasks table on mobile
* [#21440](https://github.com/netbox-community/netbox/issues/21440) - Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
* [#21468](https://github.com/netbox-community/netbox/issues/21468) - Preserve safe custom HTTP headers when copying requests for background job processing
* [#21486](https://github.com/netbox-community/netbox/issues/21486) - Fix `AttributeError` exception caused by missing `COOKIES` attribute on `NetBoxFakeRequest`
* [#21512](https://github.com/netbox-community/netbox/issues/21512) - Fix GraphQL filter field name mismatch for device component types (e.g. `console_ports`)
* [#21531](https://github.com/netbox-community/netbox/issues/21531) - Fix search functionality for location when combined with other filters
* [#21556](https://github.com/netbox-community/netbox/issues/21556) - Avoid clearing the platform field when changing device type in the device edit form
* [#21579](https://github.com/netbox-community/netbox/issues/21579) - Hide the script "Add" button for users lacking the required permission
* [#21580](https://github.com/netbox-community/netbox/issues/21580) - Hide the virtual machine "Add components" dropdown for users lacking change permission
* [#21586](https://github.com/netbox-community/netbox/issues/21586) - Fix broken "Add child group" link in site group view (was pointing to the region endpoint)
* [#21618](https://github.com/netbox-community/netbox/issues/21618) - Fix cable termination points being lost when bulk-editing the cable profile
* [#21651](https://github.com/netbox-community/netbox/issues/21651) - Disable sorting by the `is_primary` column in the MAC address list view
* [#21653](https://github.com/netbox-community/netbox/issues/21653) - Fix profile-based cable tracing when a single origin carries multiple positions
* [#21673](https://github.com/netbox-community/netbox/issues/21673) - Fix display of primary IP address with associated NAT IP on virtual machine view
* [#21686](https://github.com/netbox-community/netbox/issues/21686) - Clean up cached circuit attributes when reassigning a circuit termination
---
## v4.5.4 (2026-03-03)
### Enhancements

View File

@@ -22,7 +22,7 @@ from utilities.forms.fields import (
SlugField,
)
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
@@ -48,42 +48,17 @@ class ProviderForm(PrimaryModelForm):
label=_('ASNs'),
required=False
)
add_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Add ASNs'),
required=False
)
remove_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Remove ASNs'),
required=False
)
fieldsets = (
FieldSet('name', 'slug', M2MAddRemoveFields('asns'), 'description', 'tags'),
FieldSet('name', 'slug', 'asns', 'description', 'tags'),
)
class Meta:
model = Provider
fields = [
'name', 'slug', 'description', 'owner', 'comments', 'tags',
'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
# Add/remove mode for large M2M sets
self.fields.pop('asns')
self.fields['add_asns'].widget.add_query_param('provider_id__n', self.instance.pk)
self.fields['remove_asns'].widget.add_query_param('provider_id', self.instance.pk)
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
else:
# Simple mode for new objects or small M2M sets
self.fields.pop('add_asns')
self.fields.pop('remove_asns')
if self.instance.pk:
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
class ProviderAccountForm(PrimaryModelForm):
provider = DynamicModelChoiceField(
@@ -93,14 +68,10 @@ class ProviderAccountForm(PrimaryModelForm):
quick_add=True
)
fieldsets = (
FieldSet('provider', 'account', 'name', 'description', 'tags'),
)
class Meta:
model = ProviderAccount
fields = [
'provider', 'account', 'name', 'description', 'owner', 'comments', 'tags',
'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
]

View File

@@ -347,13 +347,6 @@ class CircuitTermination(
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache original values to detect changes
self._orig_circuit_id = self.__dict__.get('circuit_id')
self._orig_term_side = self.__dict__.get('term_side')
def __str__(self):
return f'{self.circuit}: Termination {self.term_side}'
@@ -367,39 +360,11 @@ class CircuitTermination(
raise ValidationError(_("A circuit termination must attach to a terminating object."))
def save(self, *args, **kwargs):
is_new = self._state.adding
update_fields = kwargs.get('update_fields')
# Only consider circuit/term_side changes if those fields
# are actually being persisted
if update_fields is not None:
tracking_relevant = 'circuit' in update_fields or 'term_side' in update_fields
else:
tracking_relevant = True
circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
# Clear the old termination reference if circuit or term_side changed
if circuit_changed or term_side_changed:
old_termination_name = f'termination_{self._orig_term_side.lower()}'
Circuit.objects.filter(pk=self._orig_circuit_id).update(**{old_termination_name: None})
# Update the cache if this is a new termination or circuit/term_side changed
if is_new or circuit_changed or term_side_changed:
# Update the new circuit's termination reference
termination_name = f'termination_{self.term_side.lower()}'
Circuit.objects.filter(pk=self.circuit_id).update(**{termination_name: self.pk})
# Update cached values for subsequent saves
self._orig_circuit_id = self.circuit_id
self._orig_term_side = self.term_side
def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None
if self.termination_type:

View File

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

View File

@@ -190,16 +190,14 @@ class CircuitGroupAssignmentTable(NetBoxTable):
provider = tables.Column(
accessor='member__provider',
verbose_name=_('Provider'),
orderable=False,
linkify=True,
linkify=True
)
member_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
member = tables.Column(
verbose_name=_('Circuit'),
orderable=False,
linkify=True,
linkify=True
)
priority = tables.Column(
verbose_name=_('Priority'),

View File

@@ -95,7 +95,6 @@ class VirtualCircuitTerminationTable(NetBoxTable):
verbose_name=_('Provider network')
)
provider_account = tables.Column(
accessor=tables.A('virtual_circuit__provider_account'),
linkify=True,
verbose_name=_('Account')
)
@@ -113,7 +112,7 @@ class VirtualCircuitTerminationTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualCircuitTermination
fields = (
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interface',
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
'description', 'created', 'last_updated', 'actions',
)
default_columns = (

View File

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

View File

@@ -1,48 +1,23 @@
from django.test import RequestFactory, TestCase, tag
from circuits.models import CircuitGroupAssignment, CircuitTermination
from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
from circuits.models import CircuitTermination
from circuits.tables import CircuitTerminationTable
@tag('regression')
class CircuitTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {
'actions',
}
disallowed = {'actions', }
orderable_columns = [
column.name
for column in CircuitTerminationTable(terminations).columns
column.name for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
fake_request = RequestFactory().get("/")
for col in orderable_columns:
for direction in ('-', ''):
for dir in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
@tag('regression')
class CircuitGroupAssignmentTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
assignment = CircuitGroupAssignment.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name
for column in CircuitGroupAssignmentTable(assignment).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = CircuitGroupAssignmentTable(assignment)
table.order_by = f'{direction}{col}'
table.order_by = f'{dir}{col}'
table.as_html(fake_request)

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_redis_connection
from django_rq.settings import get_queues_list
from django_rq.settings import QUEUES_LIST
from django_rq.utils import get_statistics
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -195,7 +195,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
return 'Background Workers'
def get_data(self):
config = get_queues_list()[0]
config = QUEUES_LIST[0]
return Worker.all(get_redis_connection(config['connection_config']))
@extend_schema(
@@ -205,7 +205,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
)
def retrieve(self, request, name):
# all the RQ queues should use the same connection
config = get_queues_list()[0]
config = QUEUES_LIST[0]
workers = Worker.all(get_redis_connection(config['connection_config']))
worker = next((item for item in workers if item.name == name), None)
if not worker:
@@ -229,7 +229,7 @@ class BackgroundTaskViewSet(BaseRQViewSet):
return get_rq_jobs()
def get_task_from_id(self, task_id):
config = get_queues_list()[0]
config = QUEUES_LIST[0]
task = RQ_Job.fetch(task_id, connection=get_redis_connection(config['connection_config']))
if not task:
raise Http404

View File

@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
attrs={
'rows': 5,
'class': 'font-monospace',
'placeholder': '.cache\n*.txt\nsubdir/*'
'placeholder': '.cache\n*.txt'
}
),
}

View File

@@ -69,7 +69,7 @@ class DataSource(JobsMixin, PrimaryModel):
ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True,
help_text=_("Patterns (one per line) matching files or paths to ignore when syncing")
help_text=_("Patterns (one per line) matching files to ignore when syncing")
)
parameters = models.JSONField(
verbose_name=_('parameters'),
@@ -258,22 +258,21 @@ class DataSource(JobsMixin, PrimaryModel):
if path.startswith('.'):
continue
for file_name in file_names:
file_path = os.path.join(path, file_name)
if not self._ignore(file_path):
paths.add(file_path)
if not self._ignore(file_name):
paths.add(os.path.join(path, file_name))
logger.debug(f"Found {len(paths)} files")
return paths
def _ignore(self, file_path):
def _ignore(self, filename):
"""
Returns a boolean indicating whether the file should be ignored per the DataSource's configured
ignore rules. file_path is the full relative path (e.g. "subdir/file.txt").
ignore rules.
"""
if os.path.basename(file_path).startswith('.'):
if filename.startswith('.'):
return True
for rule in self.ignore_rules.splitlines():
if fnmatchcase(file_path, rule) or fnmatchcase(os.path.basename(file_path), rule):
if fnmatchcase(filename, rule):
return True
return False

View File

@@ -19,7 +19,6 @@ REVISION_BUTTONS = """
class ConfigRevisionTable(NetBoxTable):
is_active = columns.BooleanColumn(
verbose_name=_('Is Active'),
accessor='active',
false_mark=None
)
actions = columns.ActionsColumn(

View File

@@ -10,26 +10,6 @@ from dcim.models import Device, Location, Site
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
class DataSourceIgnoreRulesTestCase(TestCase):
def test_no_ignore_rules(self):
ds = DataSource(ignore_rules='')
self.assertFalse(ds._ignore('README.md'))
self.assertFalse(ds._ignore('subdir/file.py'))
def test_ignore_by_filename(self):
ds = DataSource(ignore_rules='*.txt')
self.assertTrue(ds._ignore('notes.txt'))
self.assertTrue(ds._ignore('subdir/notes.txt'))
self.assertFalse(ds._ignore('notes.py'))
def test_ignore_by_subdirectory(self):
ds = DataSource(ignore_rules='dev/*')
self.assertTrue(ds._ignore('dev/README.md'))
self.assertTrue(ds._ignore('dev/script.py'))
self.assertFalse(ds._ignore('prod/script.py'))
class DataSourceChangeLoggingTestCase(TestCase):
def test_password_added_on_create(self):

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from django.urls import reverse
from django.utils import timezone
from django_rq import get_queue
from django_rq.settings import get_queues_map
from django_rq.settings import QUEUES_MAP
from django_rq.workers import get_worker
from rq.job import Job as RQ_Job
from rq.job import JobStatus
@@ -189,7 +189,7 @@ class BackgroundTaskTestCase(TestCase):
def test_background_tasks_list_default(self):
queue = get_queue('default')
queue.enqueue(self.dummy_job_default)
queue_index = get_queues_map()['default']
queue_index = QUEUES_MAP['default']
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
self.assertEqual(response.status_code, 200)
@@ -198,7 +198,7 @@ class BackgroundTaskTestCase(TestCase):
def test_background_tasks_list_high(self):
queue = get_queue('high')
queue.enqueue(self.dummy_job_high)
queue_index = get_queues_map()['high']
queue_index = QUEUES_MAP['high']
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
self.assertEqual(response.status_code, 200)
@@ -207,7 +207,7 @@ class BackgroundTaskTestCase(TestCase):
def test_background_tasks_list_finished(self):
queue = get_queue('default')
job = queue.enqueue(self.dummy_job_default)
queue_index = get_queues_map()['default']
queue_index = QUEUES_MAP['default']
registry = FinishedJobRegistry(queue.name, queue.connection)
registry.add(job, 2)
@@ -218,7 +218,7 @@ class BackgroundTaskTestCase(TestCase):
def test_background_tasks_list_failed(self):
queue = get_queue('default')
job = queue.enqueue(self.dummy_job_default)
queue_index = get_queues_map()['default']
queue_index = QUEUES_MAP['default']
registry = FailedJobRegistry(queue.name, queue.connection)
registry.add(job, 2)
@@ -229,7 +229,7 @@ class BackgroundTaskTestCase(TestCase):
def test_background_tasks_scheduled(self):
queue = get_queue('default')
queue.enqueue_at(datetime.now(), self.dummy_job_default)
queue_index = get_queues_map()['default']
queue_index = QUEUES_MAP['default']
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'scheduled']))
self.assertEqual(response.status_code, 200)
@@ -238,7 +238,7 @@ class BackgroundTaskTestCase(TestCase):
def test_background_tasks_list_deferred(self):
queue = get_queue('default')
job = queue.enqueue(self.dummy_job_default)
queue_index = get_queues_map()['default']
queue_index = QUEUES_MAP['default']
registry = DeferredJobRegistry(queue.name, queue.connection)
registry.add(job, 2)
@@ -335,7 +335,7 @@ class BackgroundTaskTestCase(TestCase):
worker2 = get_worker('high')
worker2.register_birth()
queue_index = get_queues_map()['default']
queue_index = QUEUES_MAP['default']
response = self.client.get(reverse('core:worker_list', args=[queue_index]))
self.assertEqual(response.status_code, 200)
self.assertIn(str(worker1.name), str(response.content))

View File

@@ -1,91 +0,0 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class DataSourcePanel(panels.ObjectAttributesPanel):
title = _('Data Source')
name = attrs.TextAttr('name')
type = attrs.ChoiceAttr('type')
enabled = attrs.BooleanAttr('enabled')
status = attrs.ChoiceAttr('status')
sync_interval = attrs.ChoiceAttr('sync_interval', label=_('Sync interval'))
last_synced = attrs.DateTimeAttr('last_synced', label=_('Last synced'))
description = attrs.TextAttr('description')
source_url = attrs.TemplatedAttr(
'source_url',
label=_('URL'),
template_name='core/datasource/attrs/source_url.html',
)
ignore_rules = attrs.TemplatedAttr(
'ignore_rules',
label=_('Ignore rules'),
template_name='core/datasource/attrs/ignore_rules.html',
)
class DataSourceBackendPanel(panels.ObjectPanel):
template_name = 'core/panels/datasource_backend.html'
title = _('Backend')
class DataFilePanel(panels.ObjectAttributesPanel):
title = _('Data File')
source = attrs.RelatedObjectAttr('source', linkify=True)
path = attrs.TextAttr('path', style='font-monospace', copy_button=True)
last_updated = attrs.DateTimeAttr('last_updated')
size = attrs.TemplatedAttr('size', template_name='core/datafile/attrs/size.html')
hash = attrs.TextAttr('hash', label=_('SHA256 hash'), style='font-monospace', copy_button=True)
class DataFileContentPanel(panels.ObjectPanel):
template_name = 'core/panels/datafile_content.html'
title = _('Content')
class JobPanel(panels.ObjectAttributesPanel):
title = _('Job')
object_type = attrs.TemplatedAttr(
'object_type',
label=_('Object type'),
template_name='core/job/attrs/object_type.html',
)
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
error = attrs.TextAttr('error')
user = attrs.TextAttr('user', label=_('Created by'))
class JobSchedulingPanel(panels.ObjectAttributesPanel):
title = _('Scheduling')
created = attrs.DateTimeAttr('created')
scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
started = attrs.DateTimeAttr('started')
completed = attrs.DateTimeAttr('completed')
queue = attrs.TextAttr('queue_name', label=_('Queue'))
class ObjectChangePanel(panels.ObjectAttributesPanel):
title = _('Change')
time = attrs.DateTimeAttr('time')
user = attrs.TemplatedAttr(
'user_name',
label=_('User'),
template_name='core/objectchange/attrs/user.html',
)
action = attrs.ChoiceAttr('action')
changed_object_type = attrs.TextAttr(
'changed_object_type',
label=_('Object type'),
)
changed_object = attrs.TemplatedAttr(
'object_repr',
label=_('Object'),
template_name='core/objectchange/attrs/changed_object.html',
)
message = attrs.TextAttr('message')
request_id = attrs.TemplatedAttr(
'request_id',
label=_('Request ID'),
template_name='core/objectchange/attrs/request_id.html',
)

View File

@@ -1,7 +1,7 @@
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
from django_rq.settings import get_queues_list, get_queues_map
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
from django_rq.utils import get_jobs, stop_jobs
from rq import requeue_job
from rq.exceptions import NoSuchJobError
@@ -31,7 +31,7 @@ def get_rq_jobs():
"""
jobs = set()
for queue in get_queues_list():
for queue in QUEUES_LIST:
queue = get_queue(queue['name'])
jobs.update(queue.get_jobs())
@@ -78,13 +78,13 @@ def delete_rq_job(job_id):
"""
Delete the specified RQ job.
"""
config = get_queues_list()[0]
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = get_queues_map()[job.origin]
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
# Remove job id from queue and delete the actual job
@@ -96,13 +96,13 @@ def requeue_rq_job(job_id):
"""
Requeue the specified RQ job.
"""
config = get_queues_list()[0]
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {id} not found.").format(id=job_id))
queue_index = get_queues_map()[job.origin]
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
@@ -112,13 +112,13 @@ def enqueue_rq_job(job_id):
"""
Enqueue the specified RQ job.
"""
config = get_queues_list()[0]
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {id} not found.").format(id=job_id))
queue_index = get_queues_map()[job.origin]
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
try:
@@ -144,13 +144,13 @@ def stop_rq_job(job_id):
"""
Stop the specified RQ job.
"""
config = get_queues_list()[0]
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = get_queues_map()[job.origin]
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
return stop_jobs(queue, job_id)[0]

View File

@@ -14,7 +14,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
from django_rq.settings import get_queues_list, get_queues_map
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
from django_rq.utils import get_statistics
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQ_Job
@@ -23,20 +23,9 @@ from rq.worker import Worker
from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from netbox.config import PARAMS, get_config
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.plugins.utils import get_installed_plugins
from netbox.ui import layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
JSONPanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
@@ -59,7 +48,6 @@ from .jobs import SyncDataSourceJob
from .models import *
from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
from .ui import panels
#
# Data sources
@@ -79,24 +67,6 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource)
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.DataSourcePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.DataSourceBackendPanel(),
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='core.DataFile',
filters={'source_id': lambda ctx: ctx['object'].pk},
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -187,14 +157,6 @@ class DataFileListView(generic.ObjectListView):
class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all()
actions = (DeleteObject,)
layout = layout.Layout(
layout.Row(
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
),
),
)
@register_model_view(DataFile, 'delete')
@@ -226,17 +188,6 @@ class JobListView(generic.ObjectListView):
class JobView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
layout = layout.SimpleLayout(
left_panels=[
panels.JobPanel(),
],
right_panels=[
panels.JobSchedulingPanel(),
],
bottom_panels=[
JSONPanel('data', title=_('Data')),
],
)
@register_model_view(Job, 'log')
@@ -249,13 +200,6 @@ class JobLogView(generic.ObjectView):
badge=lambda obj: len(obj.log_entries),
weight=500,
)
layout = layout.Layout(
layout.Row(
layout.Column(
ContextTablePanel('table', title=_('Log Entries')),
),
),
)
def get_extra_context(self, request, instance):
table = JobLogEntryTable(instance.log_entries)
@@ -297,26 +241,6 @@ class ObjectChangeListView(generic.ObjectListView):
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView):
queryset = None
layout = layout.Layout(
layout.Row(
layout.Column(panels.ObjectChangePanel()),
layout.Column(TemplatePanel('core/panels/objectchange_difference.html')),
),
layout.Row(
layout.Column(TemplatePanel('core/panels/objectchange_prechange.html')),
layout.Column(TemplatePanel('core/panels/objectchange_postchange.html')),
),
layout.Row(
layout.Column(PluginContentPanel('left_page')),
layout.Column(PluginContentPanel('right_page')),
),
layout.Row(
layout.Column(
TemplatePanel('core/panels/objectchange_related.html'),
PluginContentPanel('full_width_page'),
),
),
)
def get_queryset(self, request):
return ObjectChange.objects.valid_models()
@@ -388,14 +312,6 @@ class ConfigRevisionListView(generic.ObjectListView):
@register_model_view(ConfigRevision)
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
TemplatePanel('core/panels/configrevision_data.html'),
TemplatePanel('core/panels/configrevision_comment.html'),
),
),
)
def get_extra_context(self, request, instance):
"""
@@ -524,13 +440,13 @@ class BackgroundTaskView(BaseRQView):
def get(self, request, job_id):
# all the RQ queues should use the same connection
config = get_queues_list()[0]
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = get_queues_map()[job.origin]
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
try:
@@ -640,7 +556,7 @@ class WorkerView(BaseRQView):
def get(self, request, key):
# all the RQ queues should use the same connection
config = get_queues_list()[0]
config = QUEUES_LIST[0]
worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
# Convert microseconds to milliseconds
worker.total_working_time = worker.total_working_time / 1000

View File

@@ -38,15 +38,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
@extend_schema_field(serializers.BooleanField)
def get_connected_endpoints_reachable(self, obj):
"""
Return whether the connected endpoints are reachable via a complete, active cable path.
"""
# Use the public `path` accessor rather than dereferencing `_path`
# directly. `path` already handles the stale in-memory relation case
# that can occur while CablePath rows are rebuilt during cable edits.
if path := obj.path:
return path.is_complete and path.is_active
return False
return obj._path and obj._path.is_complete and obj._path.is_active
class PortSerializer(serializers.ModelSerializer):

View File

@@ -6,9 +6,8 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
@@ -160,60 +159,6 @@ class ModuleSerializer(PrimaryModelSerializer):
]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
def validate(self, data):
data = super().validate(data)
if self.nested:
return data
# Skip validation for existing modules (updates)
if self.instance is not None:
return data
module_bay = data.get('module_bay')
module_type = data.get('module_type')
device = data.get('device')
if not all((module_bay, module_type, device)):
return data
positions = get_module_bay_positions(module_bay)
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports"),
]:
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
for template in getattr(module_type, templates).all():
resolved_name = template.name
if MODULE_TOKEN in template.name:
if not module_bay.position:
raise serializers.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
try:
resolved_name = resolve_module_placeholder(template.name, positions)
except ValueError as e:
raise serializers.ValidationError(str(e))
if resolved_name in installed_components:
raise serializers.ValidationError(
_("A {model} named {name} already exists").format(
model=template.component_model.__name__,
name=resolved_name
)
)
return data
class MACAddressSerializer(PrimaryModelSerializer):
assigned_object_type = ContentTypeField(

View File

@@ -254,21 +254,6 @@ class Trunk8C4PCableProfile(BaseCableProfile):
b_connectors = a_connectors
class Breakout1C2Px2C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
}
b_connectors = {
1: 1,
2: 1,
}
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(2, 1): (1, 2),
}
class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,

View File

@@ -1003,16 +1003,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_800GE_SR8 = '800gbase-sr8'
TYPE_800GE_VR8 = '800gbase-vr8'
# 1.6 Tbps Ethernet
TYPE_1TE_CR8 = '1.6tbase-cr8'
TYPE_1TE_DR8 = '1.6tbase-dr8'
TYPE_1TE_DR8_2 = '1.6tbase-dr8-2'
# Ethernet (modular)
TYPE_100ME_SFP = '100base-x-sfp'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
TYPE_2GE_SFP = '2.5gbase-x-sfp'
TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
TYPE_10GE_XFP = '10gbase-x-xfp'
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
@@ -1040,11 +1034,8 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' # TODO: Rename to _QSFP_DD800
TYPE_800GE_OSFP = '800gbase-x-osfp' # TODO: Rename to _OSFP800
TYPE_1TE_OSFP1600 = '1.6tbase-x-osfp1600'
TYPE_1TE_OSFP1600_RHS = '1.6tbase-x-osfp1600-rhs'
TYPE_1TE_QSFP_DD1600 = '1.6tbase-x-qsfpdd1600'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
# Backplane Ethernet
TYPE_1GE_KX = '1000base-kx'
@@ -1058,7 +1049,6 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_KP4 = '100gbase-kp4'
TYPE_100GE_KR2 = '100gbase-kr2'
TYPE_100GE_KR4 = '100gbase-kr4'
TYPE_1TE_KR8 = '1.6tbase-kr8'
# Wireless
TYPE_80211A = 'ieee802.11a'
@@ -1308,21 +1298,12 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_800GE_VR8, '800GBASE-VR8 (800GE)'),
)
),
(
_('1.6 Tbps Ethernet'),
(
(TYPE_1TE_CR8, '1.6TBASE-CR8 (1.6TE)'),
(TYPE_1TE_DR8, '1.6TBASE-DR8 (1.6TE)'),
(TYPE_1TE_DR8_2, '1.6TBASE-DR8-2 (1.6TE)'),
)
),
(
_('Pluggable transceivers'),
(
(TYPE_100ME_SFP, 'SFP (100ME)'),
(TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'),
(TYPE_2GE_SFP, 'SFP (2.5GE)'),
(TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
(TYPE_10GE_XFP, 'XFP (10GE)'),
@@ -1352,9 +1333,6 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_1TE_OSFP1600, 'OSFP1600 (1.6TE)'),
(TYPE_1TE_OSFP1600_RHS, 'OSFP1600-RHS (1.6TE)'),
(TYPE_1TE_QSFP_DD1600, 'QSFP-DD1600 (1.6TE)'),
)
),
(
@@ -1371,7 +1349,6 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
(TYPE_1TE_KR8, '1.6TBASE-KR8 (1.6TE)'),
)
),
(
@@ -1518,12 +1495,9 @@ class InterfaceSpeedChoices(ChoiceSet):
(10000000, '10 Gbps'),
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(50000000, '50 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(800000000, '800 Gbps'),
(1600000000, '1.6 Tbps'),
]
@@ -1776,7 +1750,6 @@ class CableProfileChoices(ChoiceSet):
TRUNK_4C8P = 'trunk-4c8p'
TRUNK_8C4P = 'trunk-8c4p'
# Breakouts
BREAKOUT_1C2P_2C1P = 'breakout-1c2p-2c1p'
BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
@@ -1816,7 +1789,6 @@ class CableProfileChoices(ChoiceSet):
(
_('Breakout'),
(
(BREAKOUT_1C2P_2C1P, _('1C2P:2C1P breakout')),
(BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
(BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
(BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),

View File

@@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from utilities.forms import get_field_value
__all__ = (
@@ -71,6 +70,18 @@ class InterfaceCommonForm(forms.Form):
class ModuleCommonForm(forms.Form):
def _get_module_bay_tree(self, module_bay):
module_bays = []
while module_bay:
module_bays.append(module_bay)
if module_bay.module:
module_bay = module_bay.module.module_bay
else:
module_bay = None
module_bays.reverse()
return module_bays
def clean(self):
super().clean()
@@ -89,7 +100,7 @@ class ModuleCommonForm(forms.Form):
self.instance._disable_replication = True
return
positions = get_module_bay_positions(module_bay)
module_bays = self._get_module_bay_tree(module_bay)
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
@@ -108,16 +119,25 @@ class ModuleCommonForm(forms.Form):
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
resolved_name = template.name
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name:
if not module_bay.position:
raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
try:
resolved_name = resolve_module_placeholder(template.name, positions)
except ValueError as e:
raise forms.ValidationError(str(e))
if len(module_bays) != template.name.count(MODULE_TOKEN):
raise forms.ValidationError(
_(
"Cannot install module with placeholder values in a module bay tree {level} in tree "
"but {tokens} placeholders given."
).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
)
)
for module_bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
existing_item = installed_components.get(resolved_name)

View File

@@ -121,52 +121,13 @@ class ScopedImportForm(forms.Form):
required=False,
label=_('Scope type (app & model)')
)
scope_name = forms.CharField(
required=False,
label=_('Scope name'),
help_text=_('Name of the assigned scope object (if not using ID)')
)
def clean(self):
super().clean()
scope_id = self.cleaned_data.get('scope_id')
scope_name = self.cleaned_data.get('scope_name')
scope_type = self.cleaned_data.get('scope_type')
# Cannot specify both scope_name and scope_id
if scope_name and scope_id:
raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
# Must specify scope_type with scope_name or scope_id
if scope_name and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_name"))
if scope_id and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_id"))
# Look up the scope object by name
if scope_type and scope_name:
model = scope_type.model_class()
try:
scope_obj = model.objects.get(name=scope_name)
except model.DoesNotExist:
raise ValidationError({
'scope_name': _('{scope_type} "{name}" not found.').format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name
)
})
except model.MultipleObjectsReturned:
raise ValidationError({
'scope_name': _(
'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
).format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name,
)
})
self.cleaned_data['scope_id'] = scope_obj.pk
elif scope_type and not scope_id:
if scope_type and not scope_id:
raise ValidationError({
'scope_id': _(
"Please select a {scope_type}."

View File

@@ -23,7 +23,7 @@ from utilities.forms.fields import (
NumericArrayField,
SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import (
APISelect,
ClearableFileInput,
@@ -142,16 +142,6 @@ class SiteForm(TenancyForm, PrimaryModelForm):
label=_('ASNs'),
required=False
)
add_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Add ASNs'),
required=False
)
remove_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Remove ASNs'),
required=False
)
slug = SlugField()
time_zone = TimeZoneFormField(
label=_('Time zone'),
@@ -161,8 +151,7 @@ class SiteForm(TenancyForm, PrimaryModelForm):
fieldsets = (
FieldSet(
'name', 'slug', 'status', 'region', 'group', 'facility', M2MAddRemoveFields('asns'), 'time_zone',
'description', 'tags',
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
name=_('Site')
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -172,7 +161,7 @@ class SiteForm(TenancyForm, PrimaryModelForm):
class Meta:
model = Site
fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'time_zone',
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags',
)
widgets = {
@@ -188,21 +177,6 @@ class SiteForm(TenancyForm, PrimaryModelForm):
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
# Add/remove mode for large M2M sets
self.fields.pop('asns')
self.fields['add_asns'].widget.add_query_param('site_id__n', self.instance.pk)
self.fields['remove_asns'].widget.add_query_param('site_id', self.instance.pk)
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
else:
# Simple mode for new objects or small M2M sets
self.fields.pop('add_asns')
self.fields.pop('remove_asns')
if self.instance.pk:
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
class LocationForm(TenancyForm, NestedGroupModelForm):
site = DynamicModelChoiceField(

View File

@@ -160,7 +160,6 @@ class Cable(PrimaryModel):
CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile,
CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,
@@ -294,6 +293,7 @@ class Cable(PrimaryModel):
self._pk = self.pk
if self._orig_profile != self.profile:
print(f'profile changed from {self._orig_profile} to {self.profile}')
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations()
@@ -403,15 +403,6 @@ class Cable(PrimaryModel):
"""
a_terminations, b_terminations = self.get_terminations()
# When force-recreating terminations (e.g. after a profile change), cache the termination objects
# from the database before deleting, so they are available for recreation. Without this, the
# a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
if force:
if not hasattr(self, '_a_terminations'):
self._a_terminations = list(a_terminations.keys())
if not hasattr(self, '_b_terminations'):
self._b_terminations = list(b_terminations.keys())
# Delete any stale CableTerminations
for termination, ct in a_terminations.items():
if force or (termination.pk and termination not in self.a_terminations):
@@ -821,9 +812,9 @@ class CablePath(models.Model):
path.append([
object_to_path_node(t) for t in terminations
])
# If not null, push cable positions onto the stack
# If not null, push cable position onto the stack
if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
position_stack.append(list(terminations[0].cable_positions))
position_stack.append([terminations[0].cable_positions[0]])
# Step 2: Determine the attached links (Cable or WirelessLink), if any
links = list(dict.fromkeys(
@@ -864,33 +855,10 @@ class CablePath(models.Model):
# Profile-based tracing
if links[0].profile:
cable_profile = links[0].profile_class()
positions = position_stack.pop() if position_stack else [None]
remote_terminations = []
new_positions = []
# Build (termination, position) pairs by matching stacked positions
# to each termination's cable_positions. This correctly handles
# multiple terminations on different connectors of the same cable.
remaining = list(positions)
term_position_pairs = []
for term in terminations:
if term.cable_positions:
for cp in term.cable_positions:
if cp in remaining:
term_position_pairs.append((term, cp))
remaining.remove(cp)
# Fallback for when positions don't match cable_positions
# (e.g., empty position stack yielding [None])
if not term_position_pairs:
term_position_pairs = [(terminations[0], pos) for pos in positions]
for term, pos in term_position_pairs:
peer, new_pos = cable_profile.get_peer_termination(term, pos)
if peer not in remote_terminations:
remote_terminations.append(peer)
new_positions.append(new_pos)
position_stack.append(new_positions)
position = position_stack.pop()[0] if position_stack else None
term, position = cable_profile.get_peer_termination(terminations[0], position)
remote_terminations = [term]
position_stack.append([position])
# Legacy (positionless) behavior
else:

View File

@@ -9,7 +9,6 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
@@ -166,15 +165,41 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
_("A component template must be associated with either a device type or a module type.")
)
def _get_module_tree(self, module):
modules = []
while module:
modules.append(module)
if module.module_bay:
module = module.module_bay.module
else:
module = None
modules.reverse()
return modules
def resolve_name(self, module):
if MODULE_TOKEN not in self.name or not module:
if MODULE_TOKEN not in self.name:
return self.name
return resolve_module_placeholder(self.name, get_module_bay_positions(module.module_bay))
if module:
modules = self._get_module_tree(module)
name = self.name
for module in modules:
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
return name
return self.name
def resolve_label(self, module):
if MODULE_TOKEN not in self.label or not module:
if MODULE_TOKEN not in self.label:
return self.label
return resolve_module_placeholder(self.label, get_module_bay_positions(module.module_bay))
if module:
modules = self._get_module_tree(module)
label = self.label
for module in modules:
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
return label
return self.label
class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -704,16 +729,11 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates')
def resolve_position(self, module):
if MODULE_TOKEN not in self.position or not module:
return self.position
return resolve_module_placeholder(self.position, get_module_bay_positions(module.module_bay))
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
position=self.resolve_position(kwargs.get('module')),
position=self.position,
**kwargs
)
instantiate.do_not_call_in_templates = True

View File

@@ -2,7 +2,7 @@ from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
@@ -307,12 +307,11 @@ class PathEndpoint(models.Model):
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
"""
_path = models.ForeignKey(
to='dcim.CablePath',
on_delete=models.SET_NULL,
null=True,
blank=True,
blank=True
)
class Meta:
@@ -324,14 +323,11 @@ class PathEndpoint(models.Model):
# Construct the complete path (including e.g. bridged interfaces)
while origin is not None:
# Go through the public accessor rather than dereferencing `_path`
# directly. During cable edits, CablePath rows can be deleted and
# recreated while this endpoint instance is still in memory.
cable_path = origin.path
if cable_path is None:
if origin._path is None:
break
path.extend(cable_path.path_objects)
path.extend(origin._path.path_objects)
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
if len(path) % 3 == 1:
@@ -340,8 +336,8 @@ class PathEndpoint(models.Model):
elif len(path) % 3 == 2:
path.insert(-1, [])
# Check for a bridged relationship to continue the trace.
destinations = cable_path.destinations
# Check for a bridged relationship to continue the trace
destinations = origin._path.destinations
if len(destinations) == 1:
origin = getattr(destinations[0], 'bridge', None)
else:
@@ -352,42 +348,14 @@ class PathEndpoint(models.Model):
@property
def path(self):
"""
Return this endpoint's current CablePath, if any.
`_path` is a denormalized reference that is updated from CablePath
save/delete handlers, including queryset.update() calls on origin
endpoints. That means an already-instantiated endpoint can briefly hold
a stale in-memory `_path` relation while the database already points to
a different CablePath (or to no path at all).
If the cached relation points to a CablePath that has just been
deleted, refresh only the `_path` field from the database and retry.
This keeps the fix cheap and narrowly scoped to the denormalized FK.
"""
if self._path_id is None:
return None
try:
return self._path
except ObjectDoesNotExist:
# Refresh only the denormalized FK instead of the whole model.
# The expected problem here is in-memory staleness during path
# rebuilds, not persistent database corruption.
self.refresh_from_db(fields=['_path'])
return self._path if self._path_id else None
return self._path
@cached_property
def connected_endpoints(self):
"""
Caching accessor for the attached CablePath's destinations (if any).
Always route through `path` so stale in-memory `_path` references are
repaired before we cache the result for the lifetime of this instance.
Caching accessor for the attached CablePath's destination (if any)
"""
if cable_path := self.path:
return cable_path.destinations
return []
return self._path.destinations if self._path else []
#

View File

@@ -1149,7 +1149,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, PrimaryModelTable):
)
device = tables.Column(
verbose_name=_('Device'),
order_by=('device__name',),
order_by=('device___name',),
linkify=True
)
status = columns.ChoiceFieldColumn(
@@ -1205,8 +1205,7 @@ class MACAddressTable(PrimaryModelTable):
verbose_name=_('Parent')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary'),
orderable=False,
verbose_name=_('Primary')
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'

View File

@@ -56,9 +56,7 @@ class ModuleTypeTable(PrimaryModelTable):
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
attributes = columns.DictColumn(
orderable=False,
)
attributes = columns.DictColumn()
module_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},

View File

@@ -218,7 +218,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

View File

@@ -797,432 +797,6 @@ class CablePathTests(CablePathTestCase):
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_107_duplex_interface_profiled_patch_through_trunk_with_splices(self):
"""
Tests that a duplex interface (cable_positions=[1,2]) traces both positions through
profiled cables and splice pass-throughs, producing a single CablePath with both
strands visible.
[IF1] -C1(1C2P)- [FP1(p=2)][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
[FP2] -C3- [FP4][RP3(p=2)] -C4(1C2P)- [RP4(p=2)][FP6(p=2)]
-C5(1C2P)- [IF2] / [FP3] -C6- [FP5]
Cable profiles: C1=1C2P, C2=1C2P, C3/C6=unprofiled splices, C4=1C2P, C5=1C2P
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2), # Panel A duplex
FrontPort.objects.create(device=self.device, name='Front Port 2'), # Splice A strand 1
FrontPort.objects.create(device=self.device, name='Front Port 3'), # Splice A strand 2
FrontPort.objects.create(device=self.device, name='Front Port 4'), # Splice B strand 1
FrontPort.objects.create(device=self.device, name='Front Port 5'), # Splice B strand 2
FrontPort.objects.create(device=self.device, name='Front Port 6', positions=2), # Panel B duplex
]
PortMapping.objects.bulk_create([
# Panel A: duplex FP1(pos=2) -> RP1(pos=2)
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=2,
rear_port=rear_ports[0], rear_port_position=2,
),
# Splice A: FP2, FP3 -> RP2(pos=2)
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[2], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=2,
),
# Splice B: FP4, FP5 -> RP3(pos=2)
PortMapping(
device=self.device, front_port=front_ports[3], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[4], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=2,
),
# Panel B: duplex FP6(pos=2) -> RP4(pos=2)
PortMapping(
device=self.device, front_port=front_ports[5], front_port_position=1,
rear_port=rear_ports[3], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[5], front_port_position=2,
rear_port=rear_ports[3], rear_port_position=2,
),
])
# Create cables
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0]],
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[0]],
b_terminations=[rear_ports[1]],
)
cable2.clean()
cable2.save()
cable3 = Cable(
a_terminations=[front_ports[1]],
b_terminations=[front_ports[3]],
)
cable3.clean()
cable3.save()
cable4 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[2]],
b_terminations=[rear_ports[3]],
)
cable4.clean()
cable4.save()
cable5 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[front_ports[5]],
b_terminations=[interfaces[1]],
)
cable5.clean()
cable5.save()
cable6 = Cable(
a_terminations=[front_ports[2]],
b_terminations=[front_ports[4]],
)
cable6.clean()
cable6.save()
# Verify forward path: IF1 -> IF2 (both strands through splice)
self.assertPathExists(
(
interfaces[0], cable1, front_ports[0],
rear_ports[0], cable2, rear_ports[1],
[front_ports[1], front_ports[2]], [cable3, cable6], [front_ports[3], front_ports[4]],
rear_ports[2], cable4, rear_ports[3],
front_ports[5], cable5, interfaces[1],
),
is_complete=True,
is_active=True
)
# Verify reverse path: IF2 -> IF1
self.assertPathExists(
(
interfaces[1], cable5, front_ports[5],
rear_ports[3], cable4, rear_ports[2],
[front_ports[3], front_ports[4]], [cable3, cable6], [front_ports[1], front_ports[2]],
rear_ports[1], cable2, rear_ports[0],
front_ports[0], cable1, interfaces[0],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Verify cable positions on interfaces
for iface in interfaces:
iface.refresh_from_db()
self.assertEqual(interfaces[0].cable_connector, 1)
self.assertEqual(interfaces[0].cable_positions, [1, 2])
self.assertEqual(interfaces[1].cable_connector, 1)
self.assertEqual(interfaces[1].cable_positions, [1, 2])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_108_single_interface_two_frontports_unprofiled_through_trunk_with_splices(self):
"""
Tests that positions seeded by PortMapping (not cable_positions) are preserved
when crossing profiled cables.
[IF1] -C1- [FP1,FP2][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
[FP3] -C3- [FP5][RP3(p=2)] -C4(1C2P)- [RP4(p=2)]
[FP7,FP8] -C5- [IF2] / [FP4] -C6- [FP6]
PortMappings: FP1->RP1p1, FP2->RP1p2, FP3->RP2p1, FP4->RP2p2,
FP5->RP3p1, FP6->RP3p2, FP7->RP4p1, FP8->RP4p2
C1 is unprofiled (1 IF -> 2 FPs), C2/C4 are 1C2P trunks,
C3/C6 are unprofiled splices, C5 is unprofiled (2 FPs -> 1 IF).
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1'), # Panel A strand 1
FrontPort.objects.create(device=self.device, name='Front Port 2'), # Panel A strand 2
FrontPort.objects.create(device=self.device, name='Front Port 3'), # Splice A strand 1
FrontPort.objects.create(device=self.device, name='Front Port 4'), # Splice A strand 2
FrontPort.objects.create(device=self.device, name='Front Port 5'), # Splice B strand 1
FrontPort.objects.create(device=self.device, name='Front Port 6'), # Splice B strand 2
FrontPort.objects.create(device=self.device, name='Front Port 7'), # Panel B strand 1
FrontPort.objects.create(device=self.device, name='Front Port 8'), # Panel B strand 2
]
PortMapping.objects.bulk_create([
# Panel A: FP1, FP2 -> RP1(pos=2)
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=2,
),
# Splice A: FP3, FP4 -> RP2(pos=2)
PortMapping(
device=self.device, front_port=front_ports[2], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[3], front_port_position=1,
rear_port=rear_ports[1], rear_port_position=2,
),
# Splice B: FP5, FP6 -> RP3(pos=2)
PortMapping(
device=self.device, front_port=front_ports[4], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[5], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=2,
),
# Panel B: FP7, FP8 -> RP4(pos=2)
PortMapping(
device=self.device, front_port=front_ports[6], front_port_position=1,
rear_port=rear_ports[3], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[7], front_port_position=1,
rear_port=rear_ports[3], rear_port_position=2,
),
])
# Create cables
cable1 = Cable(
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0], front_ports[1]],
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[0]],
b_terminations=[rear_ports[1]],
)
cable2.clean()
cable2.save()
cable3 = Cable(
a_terminations=[front_ports[2]],
b_terminations=[front_ports[4]],
)
cable3.clean()
cable3.save()
cable4 = Cable(
profile=CableProfileChoices.SINGLE_1C2P,
a_terminations=[rear_ports[2]],
b_terminations=[rear_ports[3]],
)
cable4.clean()
cable4.save()
cable5 = Cable(
a_terminations=[front_ports[6], front_ports[7]],
b_terminations=[interfaces[1]],
)
cable5.clean()
cable5.save()
cable6 = Cable(
a_terminations=[front_ports[3]],
b_terminations=[front_ports[5]],
)
cable6.clean()
cable6.save()
# Verify forward path: IF1 -> IF2 (both strands through splice)
self.assertPathExists(
(
interfaces[0], cable1, [front_ports[0], front_ports[1]],
rear_ports[0], cable2, rear_ports[1],
[front_ports[2], front_ports[3]], [cable3, cable6], [front_ports[4], front_ports[5]],
rear_ports[2], cable4, rear_ports[3],
[front_ports[6], front_ports[7]], cable5, interfaces[1],
),
is_complete=True,
is_active=True
)
# Verify reverse path: IF2 -> IF1
self.assertPathExists(
(
interfaces[1], cable5, [front_ports[6], front_ports[7]],
rear_ports[3], cable4, rear_ports[2],
[front_ports[4], front_ports[5]], [cable3, cable6], [front_ports[2], front_ports[3]],
rear_ports[1], cable2, rear_ports[0],
[front_ports[0], front_ports[1]], cable1, interfaces[0],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Verify cable positions are not set (unprofiled patch cables)
for iface in interfaces:
iface.refresh_from_db()
self.assertIsNone(interfaces[0].cable_connector)
self.assertIsNone(interfaces[0].cable_positions)
self.assertIsNone(interfaces[1].cable_connector)
self.assertIsNone(interfaces[1].cable_positions)
def test_109_multiconnector_trunk_through_patch_panel(self):
"""
Tests that a 4-position interface traces correctly through a patch panel
that fans out to both connectors of a Trunk2C2P cable.
[IF1] --C1(1C4P)-- [FP1(p=4)][RP1(p=2)] --C3(Trunk2C2P)-- [RP3(p=2)][FP5(p=4)] --C5(1C4P)-- [IF2]
[RP2(p=2)] [RP4(p=2)]
PortMappings (Panel A): FP1p1->RP1p1, FP1p2->RP1p2, FP1p3->RP2p1, FP1p4->RP2p2
PortMappings (Panel B): FP5p1->RP3p1, FP5p2->RP3p2, FP5p3->RP4p1, FP5p4->RP4p2
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1', positions=4),
FrontPort.objects.create(device=self.device, name='Front Port 5', positions=4),
]
PortMapping.objects.bulk_create([
# Panel A: FP1(p=4) -> RP1(p=2) and RP2(p=2)
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=1,
rear_port=rear_ports[0], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=2,
rear_port=rear_ports[0], rear_port_position=2,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=3,
rear_port=rear_ports[1], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[0], front_port_position=4,
rear_port=rear_ports[1], rear_port_position=2,
),
# Panel B: FP5(p=4) -> RP3(p=2) and RP4(p=2)
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=1,
rear_port=rear_ports[2], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=2,
rear_port=rear_ports[2], rear_port_position=2,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=3,
rear_port=rear_ports[3], rear_port_position=1,
),
PortMapping(
device=self.device, front_port=front_ports[1], front_port_position=4,
rear_port=rear_ports[3], rear_port_position=2,
),
])
# Create cables
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C4P,
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0]],
)
cable1.clean()
cable1.save()
cable3 = Cable(
profile=CableProfileChoices.TRUNK_2C2P,
a_terminations=[rear_ports[0], rear_ports[1]],
b_terminations=[rear_ports[2], rear_ports[3]],
)
cable3.clean()
cable3.save()
cable5 = Cable(
profile=CableProfileChoices.SINGLE_1C4P,
a_terminations=[front_ports[1]],
b_terminations=[interfaces[1]],
)
cable5.clean()
cable5.save()
# Verify forward path: IF1 -> IF2 (all 4 positions through trunk)
self.assertPathExists(
(
interfaces[0], cable1, front_ports[0],
[rear_ports[0], rear_ports[1]], cable3, [rear_ports[2], rear_ports[3]],
front_ports[1], cable5, interfaces[1],
),
is_complete=True,
is_active=True
)
# Verify reverse path: IF2 -> IF1
self.assertPathExists(
(
interfaces[1], cable5, front_ports[1],
[rear_ports[2], rear_ports[3]], cable3, [rear_ports[0], rear_ports[1]],
front_ports[0], cable1, interfaces[0],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Verify cable positions
for iface in interfaces:
iface.refresh_from_db()
self.assertEqual(interfaces[0].cable_connector, 1)
self.assertEqual(interfaces[0].cable_positions, [1, 2, 3, 4])
self.assertEqual(interfaces[1].cable_connector, 1)
self.assertEqual(interfaces[1].cable_positions, [1, 2, 3, 4])
# Verify rear port connector assignments
for rp in rear_ports:
rp.refresh_from_db()
self.assertEqual(rear_ports[0].cable_connector, 1)
self.assertEqual(rear_ports[0].cable_positions, [1, 2])
self.assertEqual(rear_ports[1].cable_connector, 2)
self.assertEqual(rear_ports[1].cable_positions, [1, 2])
self.assertEqual(rear_ports[2].cable_connector, 1)
self.assertEqual(rear_ports[2].cable_positions, [1, 2])
self.assertEqual(rear_ports[3].cable_connector, 2)
self.assertEqual(rear_ports[3].cable_positions, [1, 2])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_202_single_path_via_pass_through_with_breakouts(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF3]

View File

@@ -10,8 +10,7 @@ from dcim.choices import (
)
from dcim.forms import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN
from utilities.forms.rendering import M2MAddRemoveFields
from ipam.models import VLAN
from utilities.testing import create_test_device
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -418,111 +417,3 @@ class InterfaceTestCase(TestCase):
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
class SiteFormTestCase(TestCase):
"""
Tests for M2MAddRemoveFields using Site ASN assignments as the test case.
Covers both simple mode (single multi-select field) and add/remove mode (dual fields).
"""
@classmethod
def setUpTestData(cls):
cls.rir = RIR.objects.create(name='RIR 1', slug='rir-1')
# Create 110 ASNs: 100 to pre-assign (triggering add/remove mode) plus 10 extras
ASN.objects.bulk_create([ASN(asn=i, rir=cls.rir) for i in range(1, 111)])
cls.asns = list(ASN.objects.order_by('asn'))
def _site_data(self, **kwargs):
data = {'name': 'Test Site', 'slug': 'test-site', 'status': 'active'}
data.update(kwargs)
return data
def test_new_site_uses_simple_mode(self):
"""A form for a new site uses the single 'asns' field (simple mode)."""
form = SiteForm(data=self._site_data())
self.assertIn('asns', form.fields)
self.assertNotIn('add_asns', form.fields)
self.assertNotIn('remove_asns', form.fields)
def test_existing_site_below_threshold_uses_simple_mode(self):
"""A form for an existing site with fewer than THRESHOLD ASNs uses simple mode."""
site = Site.objects.create(name='Site 1', slug='site-1')
site.asns.set(self.asns[:5])
form = SiteForm(instance=site)
self.assertIn('asns', form.fields)
self.assertNotIn('add_asns', form.fields)
self.assertNotIn('remove_asns', form.fields)
def test_existing_site_at_threshold_uses_add_remove_mode(self):
"""A form for an existing site with THRESHOLD or more ASNs uses add/remove mode."""
site = Site.objects.create(name='Site 2', slug='site-2')
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
form = SiteForm(instance=site)
self.assertNotIn('asns', form.fields)
self.assertIn('add_asns', form.fields)
self.assertIn('remove_asns', form.fields)
def test_simple_mode_assigns_asns_on_create(self):
"""Saving a new site via simple mode assigns the selected ASNs."""
asn_pks = [asn.pk for asn in self.asns[:3]]
form = SiteForm(data=self._site_data(asns=asn_pks))
self.assertTrue(form.is_valid(), form.errors)
site = form.save()
self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(asn_pks))
def test_simple_mode_replaces_asns_on_edit(self):
"""Saving an existing site via simple mode replaces the current ASN assignments."""
site = Site.objects.create(name='Site 3', slug='site-3')
site.asns.set(self.asns[:3])
new_asn_pks = [asn.pk for asn in self.asns[3:6]]
form = SiteForm(
data=self._site_data(name='Site 3', slug='site-3', asns=new_asn_pks),
instance=site
)
self.assertTrue(form.is_valid(), form.errors)
site = form.save()
self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(new_asn_pks))
def test_add_remove_mode_adds_asns(self):
"""In add/remove mode, specifying 'add_asns' appends to current assignments."""
site = Site.objects.create(name='Site 4', slug='site-4')
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
new_asn_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:]]
form = SiteForm(
data=self._site_data(name='Site 4', slug='site-4', add_asns=new_asn_pks),
instance=site
)
self.assertTrue(form.is_valid(), form.errors)
site = form.save()
self.assertEqual(site.asns.count(), len(self.asns))
def test_add_remove_mode_removes_asns(self):
"""In add/remove mode, specifying 'remove_asns' drops those assignments."""
site = Site.objects.create(name='Site 5', slug='site-5')
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
remove_pks = [asn.pk for asn in self.asns[:5]]
form = SiteForm(
data=self._site_data(name='Site 5', slug='site-5', remove_asns=remove_pks),
instance=site
)
self.assertTrue(form.is_valid(), form.errors)
site = form.save()
self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD - 5)
self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())
def test_add_remove_mode_simultaneous_add_and_remove(self):
"""In add/remove mode, add and remove operations are applied together."""
site = Site.objects.create(name='Site 6', slug='site-6')
site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
add_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:M2MAddRemoveFields.THRESHOLD + 3]]
remove_pks = [asn.pk for asn in self.asns[:3]]
form = SiteForm(
data=self._site_data(name='Site 6', slug='site-6', add_asns=add_pks, remove_asns=remove_pks),
instance=site
)
self.assertTrue(form.is_valid(), form.errors)
site = form.save()
self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD)
self.assertTrue(site.asns.filter(pk__in=add_pks).count() == 3)
self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())

View File

@@ -5,7 +5,6 @@ from circuits.models import *
from core.models import ObjectType
from dcim.choices import *
from dcim.models import *
from extras.events import serialize_for_event
from extras.models import CustomField
from ipam.models import Prefix
from netbox.choices import WeightUnitChoices
@@ -850,121 +849,6 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='SFP A-21')
self.assertEqual(nested_bay.label, 'A-21')
@tag('regression') # #20467
def test_nested_module_bay_position_resolution(self):
"""Test that {module} in a module bay template's position field is resolved when the module is installed."""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Device with Position Test',
slug='device-with-position-test'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Slot 1',
position='1'
)
module_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module with Position Placeholder'
)
ModuleBayTemplate.objects.create(
module_type=module_type,
name='Sub-bay {module}-1',
position='{module}-1'
)
device = Device.objects.create(
name='Position Test Device',
device_type=device_type,
role=device_role,
site=site
)
module_bay = device.modulebays.get(name='Slot 1')
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type
)
nested_bay = module.modulebays.get(name='Sub-bay 1-1')
self.assertEqual(nested_bay.position, '1-1')
@tag('regression') # #20474
def test_single_module_token_at_nested_depth(self):
"""
A module type with a single {module} token should install at depth > 1
without raising a token count mismatch error, resolving to the immediate
parent bay's position.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Chassis with Rear Card',
slug='chassis-with-rear-card'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Rear card slot',
position='1'
)
rear_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Rear Card'
)
ModuleBayTemplate.objects.create(
module_type=rear_card_type,
name='SFP slot 1',
position='1'
)
ModuleBayTemplate.objects.create(
module_type=rear_card_type,
name='SFP slot 2',
position='2'
)
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='SFP Module'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='SFP {module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
device = Device.objects.create(
name='Test Chassis',
device_type=device_type,
role=device_role,
site=site
)
rear_card_bay = device.modulebays.get(name='Rear card slot')
rear_card = Module.objects.create(
device=device,
module_bay=rear_card_bay,
module_type=rear_card_type
)
sfp_bay = rear_card.modulebays.get(name='SFP slot 2')
sfp_module = Module.objects.create(
device=device,
module_bay=sfp_bay,
module_type=sfp_type
)
interface = sfp_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 2')
@tag('regression') # #20912
def test_module_bay_parent_cleared_when_module_removed(self):
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
@@ -1317,94 +1201,6 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_profile_change_preserves_terminations(self):
"""
When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
bulk edit), the existing termination points must be preserved.
"""
cable = Cable.objects.first()
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
# Verify initial state: cable has terminations and no profile
self.assertEqual(cable.profile, '')
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
# Crucially, do NOT set a_terminations or b_terminations on the instance.
cable_from_db = Cable.objects.get(pk=cable.pk)
cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
cable_from_db.save()
# Verify terminations are preserved
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Verify the correct interfaces are still terminated
cable_from_db.refresh_from_db()
a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
self.assertEqual(a_terms, [interface1])
self.assertEqual(b_terms, [interface2])
@tag('regression') # #21498
def test_path_refreshes_replaced_cablepath_reference(self):
"""
An already-instantiated interface should refresh its denormalized
`_path` foreign key when the referenced CablePath row has been
replaced in the database.
"""
stale_interface = Interface.objects.get(device__name='TestDevice1', name='eth0')
old_path = CablePath.objects.get(pk=stale_interface._path_id)
new_path = CablePath(
path=old_path.path,
is_active=old_path.is_active,
is_complete=old_path.is_complete,
is_split=old_path.is_split,
)
old_path_id = old_path.pk
old_path.delete()
new_path.save()
# The old CablePath no longer exists
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
# The already-instantiated interface still points to the deleted path
# until the accessor refreshes `_path` from the database.
self.assertEqual(stale_interface._path_id, old_path_id)
self.assertEqual(stale_interface.path.pk, new_path.pk)
@tag('regression') # #21498
def test_serialize_for_event_handles_stale_cablepath_reference_after_retermination(self):
"""
Serializing an interface whose previously cached `_path` row has been
deleted during cable retermination must not raise.
"""
stale_interface = Interface.objects.get(device__name='TestDevice2', name='eth0')
old_path_id = stale_interface._path_id
new_peer = Interface.objects.get(device__name='TestDevice2', name='eth1')
cable = stale_interface.cable
self.assertIsNotNone(cable)
self.assertIsNotNone(old_path_id)
self.assertEqual(stale_interface.cable_end, 'B')
cable.b_terminations = [new_peer]
cable.save()
# The old CablePath was deleted during retrace.
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
# The stale in-memory instance still holds the deleted FK value.
self.assertEqual(stale_interface._path_id, old_path_id)
# Serialization must not raise ObjectDoesNotExist. Because this interface
# was the former B-side termination, it is now disconnected.
data = serialize_for_event(stale_interface)
self.assertIsNone(data['connected_endpoints'])
self.assertIsNone(data['connected_endpoints_type'])
self.assertFalse(data['connected_endpoints_reachable'])
class VirtualDeviceContextTestCase(TestCase):

View File

@@ -1,8 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
from netbox.ui import attrs, panels
class SitePanel(panels.ObjectAttributesPanel):
@@ -191,260 +189,16 @@ class PlatformPanel(panels.NestedGroupObjectPanel):
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
class ConsolePortPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
speed = attrs.ChoiceAttr('speed')
description = attrs.TextAttr('description')
class ConsoleServerPortPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
speed = attrs.ChoiceAttr('speed')
description = attrs.TextAttr('description')
class PowerPortPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
description = attrs.TextAttr('description')
maximum_draw = attrs.TextAttr('maximum_draw')
allocated_draw = attrs.TextAttr('allocated_draw')
class PowerOutletPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
status = attrs.ChoiceAttr('status')
description = attrs.TextAttr('description')
color = attrs.ColorAttr('color')
power_port = attrs.RelatedObjectAttr('power_port', linkify=True)
feed_leg = attrs.ChoiceAttr('feed_leg')
class FrontPortPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
positions = attrs.TextAttr('positions')
description = attrs.TextAttr('description')
class RearPortPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
positions = attrs.TextAttr('positions')
description = attrs.TextAttr('description')
class ModuleBayPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
position = attrs.TextAttr('position')
description = attrs.TextAttr('description')
class DeviceBayPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
description = attrs.TextAttr('description')
class InventoryItemPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
status = attrs.ChoiceAttr('status')
role = attrs.RelatedObjectAttr('role', linkify=True)
component = attrs.GenericForeignKeyAttr('component', linkify=True)
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
part_id = attrs.TextAttr('part_id', label=_('Part ID'))
serial = attrs.TextAttr('serial')
asset_tag = attrs.TextAttr('asset_tag')
description = attrs.TextAttr('description')
class InventoryItemRolePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class CablePanel(panels.ObjectAttributesPanel):
type = attrs.ChoiceAttr('type')
status = attrs.ChoiceAttr('status')
profile = attrs.ChoiceAttr('profile')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
label = attrs.TextAttr('label')
description = attrs.TextAttr('description')
color = attrs.ColorAttr('color')
length = attrs.NumericAttr('length', unit_accessor='get_length_unit_display')
class VirtualChassisPanel(panels.ObjectAttributesPanel):
domain = attrs.TextAttr('domain')
master = attrs.RelatedObjectAttr('master', linkify=True)
description = attrs.TextAttr('description')
class PowerPanelPanel(panels.ObjectAttributesPanel):
site = attrs.RelatedObjectAttr('site', linkify=True)
location = attrs.NestedObjectAttr('location', linkify=True)
description = attrs.TextAttr('description')
class PowerFeedPanel(panels.ObjectAttributesPanel):
power_panel = attrs.RelatedObjectAttr('power_panel', linkify=True)
rack = attrs.RelatedObjectAttr('rack', linkify=True)
type = attrs.ChoiceAttr('type')
status = attrs.ChoiceAttr('status')
description = attrs.TextAttr('description')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
connected_device = attrs.TemplatedAttr(
'connected_endpoints',
label=_('Connected device'),
template_name='dcim/powerfeed/attrs/connected_device.html',
)
utilization = attrs.TemplatedAttr(
'connected_endpoints',
label=_('Utilization (allocated)'),
template_name='dcim/powerfeed/attrs/utilization.html',
)
class PowerFeedElectricalPanel(panels.ObjectAttributesPanel):
title = _('Electrical Characteristics')
supply = attrs.ChoiceAttr('supply')
voltage = attrs.TextAttr('voltage', format_string=_('{}V'))
amperage = attrs.TextAttr('amperage', format_string=_('{}A'))
phase = attrs.ChoiceAttr('phase')
max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
device = attrs.RelatedObjectAttr('device', linkify=True)
identifier = attrs.TextAttr('identifier')
status = attrs.ChoiceAttr('status')
primary_ip4 = attrs.TemplatedAttr(
'primary_ip4',
label=_('Primary IPv4'),
template_name='dcim/device/attrs/ipaddress.html',
)
primary_ip6 = attrs.TemplatedAttr(
'primary_ip6',
label=_('Primary IPv6'),
template_name='dcim/device/attrs/ipaddress.html',
)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class MACAddressPanel(panels.ObjectAttributesPanel):
mac_address = attrs.TextAttr('mac_address', label=_('MAC address'), style='font-monospace', copy_button=True)
description = attrs.TextAttr('description')
assignment = attrs.RelatedObjectAttr('assigned_object', linkify=True, grouped_by='parent_object')
is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
class ConnectionPanel(panels.ObjectPanel):
"""
A panel which displays connection information for a cabled object.
"""
template_name = 'dcim/panels/connection.html'
title = _('Connection')
def __init__(self, trace_url_name, connect_options=None, show_endpoints=True, **kwargs):
super().__init__(**kwargs)
self.trace_url_name = trace_url_name
self.connect_options = connect_options or []
self.show_endpoints = show_endpoints
def get_context(self, context):
return {
**super().get_context(context),
'trace_url_name': self.trace_url_name,
'connect_options': self.connect_options,
'show_endpoints': self.show_endpoints,
}
def render(self, context):
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class InventoryItemsPanel(panels.ObjectPanel):
"""
A panel which displays inventory items associated with a component.
"""
template_name = 'dcim/panels/component_inventory_items.html'
title = _('Inventory Items')
actions = [
actions.AddObject(
'dcim.inventoryitem',
url_params={
'component_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'component_id': lambda ctx: ctx['object'].pk,
},
),
]
def render(self, context):
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class VirtualChassisMembersPanel(panels.ObjectPanel):
"""
A panel which lists all members of a virtual chassis.
"""
template_name = 'dcim/panels/virtual_chassis_members.html'
title = _('Virtual Chassis Members')
actions = [
actions.AddObject(
'dcim.device',
url_params={
'site': lambda ctx: (
ctx['virtual_chassis'].master.site_id
if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
else ''
),
'rack': lambda ctx: (
ctx['virtual_chassis'].master.rack_id
if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
else ''
),
},
),
]
def get_context(self, context):
return {
**super().get_context(context),
'virtual_chassis': context.get('virtual_chassis'),
'vc_members': context.get('vc_members'),
}
@@ -472,106 +226,3 @@ class PowerUtilizationPanel(panels.ObjectPanel):
if not obj.powerports.exists() or not obj.poweroutlets.exists():
return ''
return super().render(context)
class InterfacePanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
module = attrs.RelatedObjectAttr('module', linkify=True)
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
duplex = attrs.ChoiceAttr('duplex')
mtu = attrs.TextAttr('mtu', label=_('MTU'))
enabled = attrs.BooleanAttr('enabled')
mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
description = attrs.TextAttr('description')
poe_mode = attrs.ChoiceAttr('poe_mode', label=_('PoE mode'))
poe_type = attrs.ChoiceAttr('poe_type', label=_('PoE type'))
mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
title = _('Related Interfaces')
parent = attrs.RelatedObjectAttr('parent', linkify=True)
bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
class InterfaceAddressingPanel(panels.ObjectAttributesPanel):
title = _('Addressing')
mac_address = attrs.TemplatedAttr(
'primary_mac_address',
template_name='dcim/interface/attrs/mac_address.html',
label=_('MAC address'),
)
wwn = attrs.TextAttr('wwn', style='font-monospace', label=_('WWN'))
vrf = attrs.RelatedObjectAttr('vrf', linkify=True, label=_('VRF'))
vlan_translation = attrs.RelatedObjectAttr('vlan_translation_policy', linkify=True, label=_('VLAN translation'))
class InterfaceConnectionPanel(panels.ObjectPanel):
"""
A connection panel for interfaces, which handles cable, wireless link, and virtual circuit cases.
"""
template_name = 'dcim/panels/interface_connection.html'
title = _('Connection')
def render(self, context):
obj = context.get('object')
if obj and obj.is_virtual:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class VirtualCircuitPanel(panels.ObjectPanel):
"""
A panel which displays virtual circuit information for a virtual interface.
"""
template_name = 'dcim/panels/interface_virtual_circuit.html'
title = _('Virtual Circuit')
def render(self, context):
obj = context.get('object')
if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class InterfaceWirelessPanel(panels.ObjectPanel):
"""
A panel which displays wireless RF attributes for an interface, comparing local and peer values.
"""
template_name = 'dcim/panels/interface_wireless.html'
title = _('Wireless')
def render(self, context):
obj = context.get('object')
if not obj or not obj.is_wireless:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class WirelessLANsPanel(panels.ObjectPanel):
"""
A panel which lists the wireless LANs associated with an interface.
"""
template_name = 'dcim/panels/interface_wireless_lans.html'
title = _('Wireless LANs')
def render(self, context):
obj = context.get('object')
if not obj or not obj.is_wireless:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))

View File

@@ -3,9 +3,6 @@ from collections import defaultdict
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import router, transaction
from django.utils.translation import gettext as _
from dcim.constants import MODULE_TOKEN
def compile_path_node(ct_id, object_id):
@@ -36,51 +33,6 @@ def path_node_to_object(repr):
return ct.model_class().objects.filter(pk=object_id).first()
def get_module_bay_positions(module_bay):
"""
Given a module bay, traverse up the module hierarchy and return
a list of bay position strings from root to leaf.
"""
positions = []
while module_bay:
positions.append(module_bay.position)
if module_bay.module:
module_bay = module_bay.module.module_bay
else:
module_bay = None
positions.reverse()
return positions
def resolve_module_placeholder(value, positions):
"""
Resolve {module} placeholder tokens in a string using the given
list of module bay positions (ordered root to leaf).
A single {module} token resolves to the leaf (immediate parent) bay's position.
Multiple tokens must match the tree depth and resolve level-by-level.
Returns the resolved string.
Raises ValueError if token count is greater than 1 and doesn't match tree depth.
"""
if MODULE_TOKEN not in value:
return value
token_count = value.count(MODULE_TOKEN)
if token_count == 1:
return value.replace(MODULE_TOKEN, positions[-1])
if token_count == len(positions):
for pos in positions:
value = value.replace(MODULE_TOKEN, pos, 1)
return value
raise ValueError(
_("Cannot install module with placeholder values in a module bay tree "
"{level} levels deep but {tokens} placeholders given.").format(
level=len(positions), tokens=token_count
)
)
def create_cablepaths(objects):
"""
Create CablePaths for all paths originating from the specified set of nodes.

View File

@@ -17,12 +17,10 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
from ipam.tables import VLANTranslationRuleTable
from ipam.ui.panels import FHRPGroupAssignmentsPanel
from netbox.object_actions import *
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
JSONPanel,
NestedGroupObjectPanel,
ObjectsTablePanel,
@@ -1579,7 +1577,7 @@ class ModuleTypeProfileListView(generic.ObjectListView):
@register_model_view(ModuleTypeProfile)
class ModuleTypeProfileView(generic.ObjectView):
class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
template_name = 'generic/object.html'
queryset = ModuleTypeProfile.objects.all()
layout = layout.SimpleLayout(
@@ -2557,7 +2555,6 @@ class DeviceView(generic.ObjectView):
vc_members = []
return {
'virtual_chassis': instance.virtual_chassis,
'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}',
}
@@ -2910,28 +2907,6 @@ class ConsolePortListView(generic.ObjectListView):
@register_model_view(ConsolePort)
class ConsolePortView(generic.ObjectView):
queryset = ConsolePort.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ConsolePortPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:consoleport_trace',
connect_options=[
{
'a_type': 'dcim.consoleport',
'b_type': 'dcim.consoleserverport',
'label': _('Console Server Port'),
},
{'a_type': 'dcim.consoleport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
{'a_type': 'dcim.consoleport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
],
),
panels.InventoryItemsPanel(),
],
)
@register_model_view(ConsolePort, 'add', detail=False)
@@ -3003,24 +2978,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
@register_model_view(ConsoleServerPort)
class ConsoleServerPortView(generic.ObjectView):
queryset = ConsoleServerPort.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ConsoleServerPortPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:consoleserverport_trace',
connect_options=[
{'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.consoleport', 'label': _('Console Port')},
{'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
{'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
],
),
panels.InventoryItemsPanel(),
],
)
@register_model_view(ConsoleServerPort, 'add', detail=False)
@@ -3092,23 +3049,6 @@ class PowerPortListView(generic.ObjectListView):
@register_model_view(PowerPort)
class PowerPortView(generic.ObjectView):
queryset = PowerPort.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PowerPortPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:powerport_trace',
connect_options=[
{'a_type': 'dcim.powerport', 'b_type': 'dcim.poweroutlet', 'label': _('Power Outlet')},
{'a_type': 'dcim.powerport', 'b_type': 'dcim.powerfeed', 'label': _('Power Feed')},
],
),
panels.InventoryItemsPanel(),
],
)
@register_model_view(PowerPort, 'add', detail=False)
@@ -3180,22 +3120,6 @@ class PowerOutletListView(generic.ObjectListView):
@register_model_view(PowerOutlet)
class PowerOutletView(generic.ObjectView):
queryset = PowerOutlet.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PowerOutletPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:poweroutlet_trace',
connect_options=[
{'a_type': 'dcim.poweroutlet', 'b_type': 'dcim.powerport', 'label': _('Power Port')},
],
),
panels.InventoryItemsPanel(),
],
)
@register_model_view(PowerOutlet, 'add', detail=False)
@@ -3267,45 +3191,6 @@ class InterfaceListView(generic.ObjectListView):
@register_model_view(Interface)
class InterfaceView(generic.ObjectView):
queryset = Interface.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.InterfacePanel(),
panels.RelatedInterfacesPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
ContextTablePanel('vdc_table', title=_('Virtual Device Contexts')),
panels.InterfaceAddressingPanel(),
panels.VirtualCircuitPanel(),
panels.InterfaceConnectionPanel(),
panels.InterfaceWirelessPanel(),
panels.WirelessLANsPanel(),
FHRPGroupAssignmentsPanel(),
panels.InventoryItemsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='ipam.IPAddress',
filters={'interface_id': lambda ctx: ctx['object'].pk},
title=_('IP Addresses'),
),
ObjectsTablePanel(
model='dcim.MACAddress',
filters={'interface_id': lambda ctx: ctx['object'].pk},
title=_('MAC Addresses'),
),
ObjectsTablePanel(
model='ipam.VLAN',
filters={'interface_id': lambda ctx: ctx['object'].pk},
title=_('VLANs'),
),
ContextTablePanel('lag_interfaces_table', title=_('LAG Members')),
ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
ContextTablePanel('bridge_interfaces_table', title=_('Bridged Interfaces')),
ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
],
)
def get_extra_context(self, request, instance):
# Get assigned VDCs
@@ -3320,29 +3205,30 @@ class InterfaceView(generic.ObjectView):
vdc_table.configure(request)
# Get bridge interfaces
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
bridge_interfaces_table = tables.InterfaceTable(
Interface.objects.restrict(request.user, 'view').filter(bridge=instance),
bridge_interfaces,
exclude=('device', 'parent'),
orderable=False
)
bridge_interfaces_table.configure(request)
# Get child interfaces
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_table = tables.InterfaceTable(
Interface.objects.restrict(request.user, 'view').filter(parent=instance),
child_interfaces,
exclude=('device', 'parent'),
orderable=False
)
child_interfaces_table.configure(request)
# Get LAG members (only for LAG interfaces)
lag_interfaces_table = None
if instance.is_lag:
lag_interfaces_table = tables.InterfaceLAGMemberTable(
Interface.objects.restrict(request.user, 'view').filter(lag=instance),
orderable=False
)
lag_interfaces_table.configure(request)
# Get LAG interfaces
lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
lag_interfaces_table = tables.InterfaceLAGMemberTable(
lag_interfaces,
orderable=False
)
lag_interfaces_table.configure(request)
# Get VLAN translation rules
vlan_translation_table = None
@@ -3355,6 +3241,7 @@ class InterfaceView(generic.ObjectView):
return {
'vdc_table': vdc_table,
'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
@@ -3442,33 +3329,6 @@ class FrontPortListView(generic.ObjectListView):
@register_model_view(FrontPort)
class FrontPortView(generic.ObjectView):
queryset = FrontPort.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.FrontPortPanel(),
CustomFieldsPanel(),
TagsPanel(),
panels.InventoryItemsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:frontport_trace',
show_endpoints=False,
connect_options=[
{'a_type': 'dcim.frontport', 'b_type': 'dcim.interface', 'label': _('Interface')},
{'a_type': 'dcim.frontport', 'b_type': 'dcim.consoleserverport', 'label': _('Console Server Port')},
{'a_type': 'dcim.frontport', 'b_type': 'dcim.consoleport', 'label': _('Console Port')},
{'a_type': 'dcim.frontport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
{'a_type': 'dcim.frontport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
{
'a_type': 'dcim.frontport',
'b_type': 'circuits.circuittermination',
'label': _('Circuit Termination'),
},
],
),
TemplatePanel('dcim/panels/front_port_mappings.html'),
],
)
def get_extra_context(self, request, instance):
return {
@@ -3545,31 +3405,6 @@ class RearPortListView(generic.ObjectListView):
@register_model_view(RearPort)
class RearPortView(generic.ObjectView):
queryset = RearPort.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RearPortPanel(),
CustomFieldsPanel(),
TagsPanel(),
panels.InventoryItemsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:rearport_trace',
show_endpoints=False,
connect_options=[
{'a_type': 'dcim.rearport', 'b_type': 'dcim.interface', 'label': _('Interface')},
{'a_type': 'dcim.rearport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
{'a_type': 'dcim.rearport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
{
'a_type': 'dcim.rearport',
'b_type': 'circuits.circuittermination',
'label': _('Circuit Termination'),
},
],
),
TemplatePanel('dcim/panels/rear_port_mappings.html'),
],
)
def get_extra_context(self, request, instance):
return {
@@ -3646,19 +3481,6 @@ class ModuleBayListView(generic.ObjectListView):
@register_model_view(ModuleBay)
class ModuleBayView(generic.ObjectView):
queryset = ModuleBay.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ModuleBayPanel(),
TagsPanel(),
],
right_panels=[
CustomFieldsPanel(),
Panel(
title=_('Installed Module'),
template_name='dcim/panels/installed_module.html',
),
],
)
@register_model_view(ModuleBay, 'add', detail=False)
@@ -3721,19 +3543,6 @@ class DeviceBayListView(generic.ObjectListView):
@register_model_view(DeviceBay)
class DeviceBayView(generic.ObjectView):
queryset = DeviceBay.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.DeviceBayPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
Panel(
title=_('Installed Device'),
template_name='dcim/panels/installed_device.html',
),
],
)
@register_model_view(DeviceBay, 'add', detail=False)
@@ -3877,13 +3686,6 @@ class InventoryItemListView(generic.ObjectListView):
@register_model_view(InventoryItem)
class InventoryItemView(generic.ObjectView):
queryset = InventoryItem.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.InventoryItemPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
)
@register_model_view(InventoryItem, 'edit')
@@ -3965,23 +3767,12 @@ class InventoryItemRoleListView(generic.ObjectListView):
@register_model_view(InventoryItemRole)
class InventoryItemRoleView(GetRelatedModelsMixin, generic.ObjectView):
class InventoryItemRoleView(generic.ObjectView):
queryset = InventoryItemRole.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.InventoryItemRolePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(),
}
@@ -4149,24 +3940,6 @@ class CableListView(generic.ObjectListView):
@register_model_view(Cable)
class CableView(generic.ObjectView):
queryset = Cable.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CablePanel(),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
Panel(
title=_('Termination A'),
template_name='dcim/panels/cable_termination_a.html',
),
Panel(
title=_('Termination B'),
template_name='dcim/panels/cable_termination_b.html',
),
],
)
@register_model_view(Cable, 'add', detail=False)
@@ -4299,23 +4072,12 @@ class VirtualChassisListView(generic.ObjectListView):
@register_model_view(VirtualChassis)
class VirtualChassisView(generic.ObjectView):
queryset = VirtualChassis.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualChassisPanel(),
TagsPanel(),
CustomFieldsPanel(),
],
right_panels=[
panels.VirtualChassisMembersPanel(),
CommentsPanel(),
],
)
def get_extra_context(self, request, instance):
vc_members = Device.objects.restrict(request.user).filter(virtual_chassis=instance).order_by('vc_position')
members = Device.objects.restrict(request.user).filter(virtual_chassis=instance)
return {
'virtual_chassis': instance,
'vc_members': vc_members,
'members': members,
}
@@ -4555,27 +4317,6 @@ class PowerPanelListView(generic.ObjectListView):
@register_model_view(PowerPanel)
class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
queryset = PowerPanel.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PowerPanelPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
ImageAttachmentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.PowerFeed',
filters={'power_panel_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.PowerFeed', url_params={'power_panel': lambda ctx: ctx['object'].pk}),
],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -4639,23 +4380,6 @@ class PowerFeedListView(generic.ObjectListView):
@register_model_view(PowerFeed)
class PowerFeedView(generic.ObjectView):
queryset = PowerFeed.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PowerFeedPanel(),
panels.PowerFeedElectricalPanel(),
CustomFieldsPanel(),
TagsPanel(),
],
right_panels=[
panels.ConnectionPanel(
trace_url_name='dcim:powerfeed_trace',
connect_options=[
{'a_type': 'dcim.powerfeed', 'b_type': 'dcim.powerport', 'label': _('Power Port')},
],
),
CommentsPanel(),
],
)
@register_model_view(PowerFeed, 'add', detail=False)
@@ -4724,23 +4448,6 @@ class VirtualDeviceContextListView(generic.ObjectListView):
@register_model_view(VirtualDeviceContext)
class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualDeviceContext.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualDeviceContextPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.Interface',
filters={'vdc_id': lambda ctx: ctx['object'].pk},
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -4809,16 +4516,6 @@ class MACAddressListView(generic.ObjectListView):
@register_model_view(MACAddress)
class MACAddressView(generic.ObjectView):
queryset = MACAddress.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.MACAddressPanel(),
TagsPanel(),
CustomFieldsPanel(),
],
right_panels=[
CommentsPanel(),
],
)
@register_model_view(MACAddress, 'add', detail=False)

View File

@@ -2,7 +2,7 @@ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework.fields import Field
from rest_framework.serializers import ListSerializer, ValidationError
from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
@@ -49,25 +49,8 @@ class CustomFieldsDataField(Field):
# TODO: Fix circular import
from utilities.api import get_serializer_for_model
data = {}
cache = self.parent.context.get('cf_object_cache')
for cf in self._get_custom_fields():
if cache is not None and cf.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
):
raw = obj.get(cf.name)
if raw is None:
value = None
elif cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
model = cf.related_object_type.model_class()
value = cache.get((model, raw))
else:
model = cf.related_object_type.model_class()
value = [cache[(model, pk)] for pk in raw if (model, pk) in cache] or None
else:
value = cf.deserialize(obj.get(cf.name))
value = cf.deserialize(obj.get(cf.name))
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
serializer = get_serializer_for_model(cf.related_object_type.model_class())
value = serializer(value, nested=True, context=self.parent.context).data
@@ -104,32 +87,3 @@ class CustomFieldsDataField(Field):
data = {**self.parent.instance.custom_field_data, **data}
return data
class CustomFieldListSerializer(ListSerializer):
"""
ListSerializer that pre-fetches all OBJECT/MULTIOBJECT custom field related objects
in bulk before per-item serialization.
"""
def to_representation(self, data):
cf_field = self.child.fields.get('custom_fields')
if isinstance(cf_field, CustomFieldsDataField):
object_type_cfs = [
cf for cf in cf_field._get_custom_fields()
if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
]
cache = {}
for cf in object_type_cfs:
model = cf.related_object_type.model_class()
pks = set()
for item in data:
raw = item.custom_field_data.get(cf.name)
if raw is not None:
if cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
pks.update(raw)
else:
pks.add(raw)
for obj in model.objects.filter(pk__in=pks):
cache[(model, obj.pk)] = obj
self.child.context['cf_object_cache'] = cache
return super().to_representation(data)

View File

@@ -1,70 +1,19 @@
import logging
from django.core.files.storage import storages
from django.db import IntegrityError
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.api.serializers_.jobs import JobSerializer
from core.choices import ManagedFileRootPathChoices
from extras.models import Script, ScriptModule
from extras.models import Script
from netbox.api.serializers import ValidatedModelSerializer
from utilities.datetime import local_now
logger = logging.getLogger(__name__)
__all__ = (
'ScriptDetailSerializer',
'ScriptInputSerializer',
'ScriptModuleSerializer',
'ScriptSerializer',
)
class ScriptModuleSerializer(ValidatedModelSerializer):
file = serializers.FileField(write_only=True)
file_path = serializers.CharField(read_only=True)
class Meta:
model = ScriptModule
fields = ['id', 'display', 'file_path', 'file', 'created', 'last_updated']
brief_fields = ('id', 'display')
def validate(self, data):
# ScriptModule.save() sets file_root; inject it here so full_clean() succeeds.
# Pop 'file' before model instantiation — ScriptModule has no such field.
file = data.pop('file', None)
data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
data = super().validate(data)
data.pop('file_root', None)
if file is not None:
data['file'] = file
return data
def create(self, validated_data):
file = validated_data.pop('file')
storage = storages.create_storage(storages.backends["scripts"])
validated_data['file_path'] = storage.save(file.name, file)
created = False
try:
instance = super().create(validated_data)
created = True
return instance
except IntegrityError as e:
if 'file_path' in str(e):
raise serializers.ValidationError(
_("A script module with this file name already exists.")
)
raise
finally:
if not created and (file_path := validated_data.get('file_path')):
try:
storage.delete(file_path)
except Exception:
logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
class ScriptSerializer(ValidatedModelSerializer):
description = serializers.SerializerMethodField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)

View File

@@ -26,7 +26,6 @@ router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts/upload', views.ScriptModuleViewSet)
router.register('scripts', views.ScriptViewSet, basename='script')
app_name = 'extras-api'

View File

@@ -6,7 +6,7 @@ from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
@@ -21,7 +21,6 @@ from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin
from utilities.exceptions import RQWorkerNotRunningException
from utilities.request import copy_safe_request
@@ -265,11 +264,6 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
# Scripts
#
class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, BaseViewSet):
queryset = ScriptModule.objects.all()
serializer_class = serializers.ScriptModuleSerializer
@extend_schema_view(
update=extend_schema(request=serializers.ScriptInputSerializer),
partial_update=extend_schema(request=serializers.ScriptInputSerializer),

View File

@@ -25,54 +25,16 @@ logger = logging.getLogger('netbox.events_processor')
class EventContext(UserDict):
"""
Dictionary-compatible wrapper for queued events that lazily serializes
``event['data']`` on first access.
Backward-compatible with the plain-dict interface expected by existing
EVENTS_PIPELINE consumers. When the same object is enqueued more than once
in a single request, the serialization source is updated so consumers see
the latest state.
A custom dictionary that automatically serializes its associated object on demand.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Track which model instance should be serialized if/when `data` is
# requested. This may be refreshed on duplicate enqueue, while leaving
# the public `object` entry untouched for compatibility.
self._serialization_source = None
if 'object' in self:
self._serialization_source = super().__getitem__('object')
def refresh_serialization_source(self, instance):
"""
Point lazy serialization at a fresher instance, invalidating any
already-materialized ``data``.
"""
self._serialization_source = instance
# UserDict.__contains__ checks the backing dict directly, so `in`
# does not trigger __getitem__'s lazy serialization.
if 'data' in self:
del self['data']
def freeze_data(self, instance):
"""
Eagerly serialize and cache the payload for delete events, where the
object may become inaccessible after deletion.
"""
super().__setitem__('data', serialize_for_event(instance))
self._serialization_source = None
# We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
# queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
# changes until a suitable minor release.
def __getitem__(self, item):
if item == 'data' and 'data' not in self:
# Materialize the payload only when an event consumer asks for it.
#
# On coalesced events, use the latest explicitly queued instance so
# webhooks/scripts/notifications observe the final queued state for
# that object within the request.
source = self._serialization_source or super().__getitem__('object')
super().__setitem__('data', serialize_for_event(source))
data = serialize_for_event(self['object'])
self.__setitem__('data', data)
return super().__getitem__(item)
@@ -114,9 +76,8 @@ def get_snapshots(instance, event_type):
def enqueue_event(queue, instance, request, event_type):
"""
Enqueue (or coalesce) an event for a created/updated/deleted object.
Events are processed after the request completes.
Enqueue a serialized representation of a created/updated/deleted object for the processing of
events once the request has completed.
"""
# Bail if this type of object does not support event rules
if not has_feature(instance, 'event_rules'):
@@ -127,18 +88,11 @@ def enqueue_event(queue, instance, request, event_type):
assert instance.pk is not None
key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue:
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
# If the object is being deleted, convert any prior update event into a
# delete event and freeze the payload before the object (or related
# rows) become inaccessible.
# If the object is being deleted, update any prior "update" event to "delete"
if event_type == OBJECT_DELETED:
queue[key]['event_type'] = event_type
else:
# Keep the public `object` entry stable for compatibility.
queue[key].refresh_serialization_source(instance)
else:
queue[key] = EventContext(
object_type=ObjectType.objects.get_for_model(instance),
@@ -152,11 +106,9 @@ def enqueue_event(queue, instance, request, event_type):
username=request.user.username, # DEPRECATED, will be removed in NetBox v4.7.0
request_id=request.id, # DEPRECATED, will be removed in NetBox v4.7.0
)
# For delete events, eagerly serialize the payload before the row is gone.
# This covers both first-time enqueues and coalesced update→delete promotions.
# Force serialization of objects prior to them actually being deleted
if event_type == OBJECT_DELETED:
queue[key].freeze_data(instance)
queue[key]['data'] = serialize_for_event(instance)
def process_event_rules(event_rules, object_type, event):
@@ -181,9 +133,9 @@ def process_event_rules(event_rules, object_type, event):
if not event_rule.eval_conditions(event['data']):
continue
# Merge rule-specific action_data with the event payload.
# Copy to avoid mutating the rule's stored action_data dict.
event_data = {**(event_rule.action_data or {}), **event['data']}
# Compile event data
event_data = event_rule.action_data or {}
event_data.update(event['data'])
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:

View File

@@ -1,5 +1,3 @@
from collections import defaultdict
import jsonschema
from django.conf import settings
from django.core.validators import ValidationError
@@ -8,7 +6,6 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from core.models import ObjectType
from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet
from netbox.models import ChangeLoggedModel, PrimaryModel
@@ -302,17 +299,3 @@ class ConfigTemplate(
"""
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def get_context(self, context=None, queryset=None):
_context = defaultdict(dict)
# Populate all public models for reference within the template
for object_type in ObjectType.objects.public():
if model := object_type.model_class():
_context[object_type.app_label][model.__name__] = model
# Apply the provided context data, if any
if context is not None:
_context.update(context)
return _context

View File

@@ -74,7 +74,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return custom_fields
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
custom_fields = self.get_queryset().filter(object_types=content_type).select_related('related_object_type')
custom_fields = self.get_queryset().filter(object_types=content_type)
# Populate the request cache to avoid redundant lookups
if cache is not None:

View File

@@ -2,6 +2,7 @@ import importlib.abc
import importlib.util
import os
import sys
from collections import defaultdict
from django.core.files.storage import storages
from django.db import models
@@ -9,6 +10,7 @@ from django.http import HttpResponse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
from extras.utils import filename_from_model, filename_from_object
from utilities.jinja2 import render_jinja2
@@ -120,9 +122,17 @@ class RenderTemplateMixin(models.Model):
abstract = True
def get_context(self, context=None, queryset=None):
raise NotImplementedError(_("{class_name} must implement a get_context() method.").format(
class_name=self.__class__
))
_context = defaultdict(dict)
# Populate all public models for reference within the template
for object_type in ObjectType.objects.public():
if model := object_type.model_class():
_context[object_type.app_label][model.__name__] = model
if context is not None:
_context.update(context)
return _context
def get_environment_params(self):
"""

View File

@@ -458,14 +458,8 @@ class ExportTemplate(
sync_data.alters_data = True
def get_context(self, context=None, queryset=None):
_context = {
'queryset': queryset,
}
# Apply the provided context data, if any
if context is not None:
_context.update(context)
_context = super().get_context(context=context, queryset=queryset)
_context['queryset'] = queryset
return _context

View File

@@ -417,7 +417,6 @@ class NotificationTable(NetBoxTable):
icon = columns.TemplateColumn(
template_code=NOTIFICATION_ICON,
accessor=tables.A('event'),
orderable=False,
attrs={
'td': {'class': 'w-1'},
'th': {'class': 'w-1'},
@@ -480,8 +479,8 @@ class WebhookTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
ssl_verification = columns.BooleanColumn(
verbose_name=_('SSL Verification'),
ssl_validation = columns.BooleanColumn(
verbose_name=_('SSL Validation')
)
owner = tables.Column(
linkify=True,
@@ -511,9 +510,8 @@ class EventRuleTable(NetBoxTable):
verbose_name=_('Type'),
)
action_object = tables.Column(
verbose_name=_('Object'),
orderable=False,
linkify=True,
verbose_name=_('Object'),
)
object_types = columns.ContentTypesColumn(
verbose_name=_('Object Types'),

View File

@@ -1,9 +1,7 @@
import datetime
import hashlib
from unittest.mock import MagicMock, patch
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils.timezone import make_aware, now
from rest_framework import status
@@ -1386,54 +1384,3 @@ class NotificationTest(APIViewTestCases.APIViewTestCase):
'event_type': OBJECT_DELETED,
},
]
class ScriptModuleTest(APITestCase):
"""
Tests for the POST /api/extras/scripts/upload/ endpoint.
ScriptModule is a proxy of core.ManagedFile (a different app) so the standard
APIViewTestCases mixins cannot be used directly. All tests use add_permissions()
with explicit Django model-level permissions.
"""
def setUp(self):
super().setUp()
self.url = reverse('extras-api:scriptmodule-list') # /api/extras/scripts/upload/
def test_upload_script_module_without_permission(self):
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
response = self.client.post(
self.url,
{'file': upload_file},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
def test_upload_script_module(self):
# ScriptModule is a proxy of core.ManagedFile; both permissions required.
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
mock_storage = MagicMock()
mock_storage.save.return_value = 'test_upload.py'
with patch('extras.api.serializers_.scripts.storages') as mock_storages:
mock_storages.create_storage.return_value = mock_storage
mock_storages.backends = {'scripts': {}}
response = self.client.post(
self.url,
{'file': upload_file},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['file_path'], 'test_upload.py')
mock_storage.save.assert_called_once()
self.assertTrue(ScriptModule.objects.filter(file_path='test_upload.py').exists())
def test_upload_script_module_without_file_fails(self):
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
response = self.client.post(self.url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)

View File

@@ -1,10 +1,8 @@
import json
import uuid
from unittest import skipIf
from unittest.mock import Mock, patch
import django_rq
from django.conf import settings
from django.http import HttpResponse
from django.test import RequestFactory
from django.urls import reverse
@@ -345,7 +343,6 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
def test_send_webhook(self):
request_id = uuid.uuid4()
@@ -429,97 +426,6 @@ class EventRuleTest(APITestCase):
self.assertEqual(job.kwargs['object_type'], script_type)
self.assertEqual(job.kwargs['username'], self.user.username)
def test_duplicate_enqueue_refreshes_lazy_payload(self):
"""
When the same object is enqueued more than once in a single request,
lazy serialization should use the most recently enqueued instance while
preserving the original event['object'] reference.
"""
request = RequestFactory().get(reverse('dcim:site_add'))
request.id = uuid.uuid4()
request.user = self.user
site = Site.objects.create(name='Site 1', slug='site-1')
stale_site = Site.objects.get(pk=site.pk)
queue = {}
enqueue_event(queue, stale_site, request, OBJECT_UPDATED)
event = queue[f'dcim.site:{site.pk}']
# Data should not be materialized yet (lazy serialization)
self.assertNotIn('data', event.data)
fresh_site = Site.objects.get(pk=site.pk)
fresh_site.description = 'foo'
fresh_site.save()
enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
# The original object reference should be preserved
self.assertIs(event['object'], stale_site)
# But serialized data should reflect the fresher instance
self.assertEqual(event['data']['description'], 'foo')
self.assertEqual(event['snapshots']['postchange']['description'], 'foo')
def test_duplicate_enqueue_invalidates_materialized_data(self):
"""
If event['data'] has already been materialized before a second enqueue
for the same object, the stale payload should be discarded and rebuilt
from the fresher instance on next access.
"""
request = RequestFactory().get(reverse('dcim:site_add'))
request.id = uuid.uuid4()
request.user = self.user
site = Site.objects.create(name='Site 1', slug='site-1')
queue = {}
enqueue_event(queue, site, request, OBJECT_UPDATED)
event = queue[f'dcim.site:{site.pk}']
# Force early materialization
self.assertEqual(event['data']['description'], '')
# Now update and re-enqueue
fresh_site = Site.objects.get(pk=site.pk)
fresh_site.description = 'updated'
fresh_site.save()
enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
# Stale data should have been invalidated; new access should reflect update
self.assertEqual(event['data']['description'], 'updated')
def test_update_then_delete_enqueue_freezes_payload(self):
"""
When an update event is coalesced with a subsequent delete, the event
type should be promoted to OBJECT_DELETED and the payload should be
eagerly frozen (since the object will be inaccessible after deletion).
"""
request = RequestFactory().get(reverse('dcim:site_add'))
request.id = uuid.uuid4()
request.user = self.user
site = Site.objects.create(name='Site 1', slug='site-1')
queue = {}
enqueue_event(queue, site, request, OBJECT_UPDATED)
event = queue[f'dcim.site:{site.pk}']
enqueue_event(queue, site, request, OBJECT_DELETED)
# Event type should have been promoted
self.assertEqual(event['event_type'], OBJECT_DELETED)
# Data should already be materialized (frozen), not lazy
self.assertIn('data', event.data)
self.assertEqual(event['data']['name'], 'Site 1')
self.assertIsNone(event['snapshots']['postchange'])
def test_duplicate_triggers(self):
"""
Test for erroneous duplicate event triggers resulting from saving an object multiple times

View File

@@ -8,7 +8,15 @@ from django.test import TestCase, tag
from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
from extras.models import (
ConfigContext,
ConfigContextProfile,
ConfigTemplate,
ExportTemplate,
ImageAttachment,
Tag,
TaggedItem,
)
from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -804,3 +812,36 @@ class ConfigTemplateTest(TestCase):
object_id=config_template.pk
)
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
class ExportTemplateContextTest(TestCase):
"""
Tests for ExportTemplate.get_context() including public model population.
"""
def test_get_context_includes_public_models(self):
et = ExportTemplate(name='test', template_code='test')
ctx = et.get_context()
self.assertIs(ctx['dcim']['Site'], Site)
self.assertIs(ctx['dcim']['Device'], Device)
def test_get_context_includes_queryset(self):
et = ExportTemplate(name='test', template_code='test')
qs = Site.objects.all()
ctx = et.get_context(queryset=qs)
self.assertIs(ctx['queryset'], qs)
def test_get_context_applies_extra_context(self):
et = ExportTemplate(name='test', template_code='test')
ctx = et.get_context(context={'custom_key': 'custom_value'})
self.assertEqual(ctx['custom_key'], 'custom_value')
self.assertIs(ctx['dcim']['Site'], Site)
def test_config_template_get_context_includes_public_models(self):
ct = ConfigTemplate(name='test', template_code='test')
ctx = ct.get_context()
self.assertIs(ctx['dcim']['Site'], Site)

View File

@@ -1,24 +0,0 @@
from django.test import RequestFactory, TestCase, tag
from extras.models import EventRule
from extras.tables import EventRuleTable
@tag('regression')
class EventRuleTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
rule = EventRule.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name for column in EventRuleTable(rule).columns if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = EventRuleTable(rule)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)

View File

@@ -2,55 +2,16 @@ from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
from netbox.ui import actions, panels
from utilities.data import resolve_attr_path
__all__ = (
'ConfigContextAssignmentPanel',
'ConfigContextPanel',
'ConfigContextProfilePanel',
'ConfigTemplatePanel',
'CustomFieldBehaviorPanel',
'CustomFieldChoiceSetChoicesPanel',
'CustomFieldChoiceSetPanel',
'CustomFieldObjectTypesPanel',
'CustomFieldPanel',
'CustomFieldRelatedObjectsPanel',
'CustomFieldValidationPanel',
'CustomFieldsPanel',
'CustomLinkPanel',
'EventRuleActionPanel',
'EventRuleEventTypesPanel',
'EventRulePanel',
'ExportTemplatePanel',
'ImageAttachmentFilePanel',
'ImageAttachmentImagePanel',
'ImageAttachmentPanel',
'ImageAttachmentsPanel',
'JournalEntryPanel',
'NotificationGroupGroupsPanel',
'NotificationGroupPanel',
'NotificationGroupUsersPanel',
'ObjectTypesPanel',
'SavedFilterObjectTypesPanel',
'SavedFilterPanel',
'TableConfigColumnsPanel',
'TableConfigOrderingPanel',
'TableConfigPanel',
'TagItemTypesPanel',
'TagObjectTypesPanel',
'TagPanel',
'TagsPanel',
'WebhookHTTPPanel',
'WebhookPanel',
'WebhookSSLPanel',
)
#
# Generic panels
#
class CustomFieldsPanel(panels.ObjectPanel):
"""
A panel showing the value of all custom fields defined on an object.
@@ -112,403 +73,3 @@ class TagsPanel(panels.ObjectPanel):
**super().get_context(context),
'object': resolve_attr_path(context, self.accessor),
}
class ObjectTypesPanel(panels.ObjectPanel):
"""
A panel listing the object types assigned to the object.
"""
template_name = 'extras/panels/object_types.html'
title = _('Object Types')
#
# CustomField panels
#
class CustomFieldPanel(panels.ObjectAttributesPanel):
title = _('Custom Field')
name = attrs.TextAttr('name')
type = attrs.TemplatedAttr('type', label=_('Type'), template_name='extras/customfield/attrs/type.html')
label = attrs.TextAttr('label')
group_name = attrs.TextAttr('group_name', label=_('Group name'))
description = attrs.TextAttr('description')
required = attrs.BooleanAttr('required')
unique = attrs.BooleanAttr('unique', label=_('Must be unique'))
is_cloneable = attrs.BooleanAttr('is_cloneable', label=_('Cloneable'))
choice_set = attrs.TemplatedAttr(
'choice_set',
template_name='extras/customfield/attrs/choice_set.html',
)
default = attrs.TextAttr('default', label=_('Default value'))
related_object_filter = attrs.TemplatedAttr(
'related_object_filter',
template_name='extras/customfield/attrs/related_object_filter.html',
)
class CustomFieldBehaviorPanel(panels.ObjectAttributesPanel):
title = _('Behavior')
search_weight = attrs.TemplatedAttr(
'search_weight',
template_name='extras/customfield/attrs/search_weight.html',
)
filter_logic = attrs.ChoiceAttr('filter_logic')
weight = attrs.NumericAttr('weight', label=_('Display weight'))
ui_visible = attrs.ChoiceAttr('ui_visible', label=_('UI visible'))
ui_editable = attrs.ChoiceAttr('ui_editable', label=_('UI editable'))
class CustomFieldValidationPanel(panels.ObjectAttributesPanel):
title = _('Validation Rules')
validation_minimum = attrs.NumericAttr('validation_minimum', label=_('Minimum value'))
validation_maximum = attrs.NumericAttr('validation_maximum', label=_('Maximum value'))
validation_regex = attrs.TextAttr(
'validation_regex',
label=_('Regular expression'),
style='font-monospace',
)
class CustomFieldObjectTypesPanel(panels.ObjectPanel):
template_name = 'extras/panels/object_types.html'
title = _('Object Types')
class CustomFieldRelatedObjectsPanel(panels.ObjectPanel):
template_name = 'extras/panels/customfield_related_objects.html'
title = _('Related Objects')
def get_context(self, context):
return {
**super().get_context(context),
'related_models': context.get('related_models'),
}
#
# CustomFieldChoiceSet panels
#
class CustomFieldChoiceSetPanel(panels.ObjectAttributesPanel):
title = _('Custom Field Choice Set')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
base_choices = attrs.ChoiceAttr('base_choices')
order_alphabetically = attrs.BooleanAttr('order_alphabetically')
choices_for = attrs.RelatedObjectListAttr('choices_for', linkify=True, label=_('Used by'))
class CustomFieldChoiceSetChoicesPanel(panels.ObjectPanel):
template_name = 'extras/panels/customfieldchoiceset_choices.html'
def get_context(self, context):
obj = context.get('object')
total = len(obj.choices) if obj else 0
return {
**super().get_context(context),
'title': f'{_("Choices")} ({total})',
'choices': context.get('choices'),
}
#
# CustomLink panels
#
class CustomLinkPanel(panels.ObjectAttributesPanel):
title = _('Custom Link')
name = attrs.TextAttr('name')
enabled = attrs.BooleanAttr('enabled')
group_name = attrs.TextAttr('group_name')
weight = attrs.NumericAttr('weight')
button_class = attrs.ChoiceAttr('button_class')
new_window = attrs.BooleanAttr('new_window')
#
# ExportTemplate panels
#
class ExportTemplatePanel(panels.ObjectAttributesPanel):
title = _('Export Template')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
file_name = attrs.TextAttr('file_name')
file_extension = attrs.TextAttr('file_extension')
as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
#
# SavedFilter panels
#
class SavedFilterPanel(panels.ObjectAttributesPanel):
title = _('Saved Filter')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
user = attrs.TextAttr('user')
enabled = attrs.BooleanAttr('enabled')
shared = attrs.BooleanAttr('shared')
weight = attrs.NumericAttr('weight')
class SavedFilterObjectTypesPanel(panels.ObjectPanel):
template_name = 'extras/panels/savedfilter_object_types.html'
title = _('Assigned Models')
#
# TableConfig panels
#
class TableConfigPanel(panels.ObjectAttributesPanel):
title = _('Table Config')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
object_type = attrs.TextAttr('object_type')
table = attrs.TextAttr('table')
user = attrs.TextAttr('user')
enabled = attrs.BooleanAttr('enabled')
shared = attrs.BooleanAttr('shared')
weight = attrs.NumericAttr('weight')
class TableConfigColumnsPanel(panels.ObjectPanel):
template_name = 'extras/panels/tableconfig_columns.html'
title = _('Columns Displayed')
def get_context(self, context):
return {
**super().get_context(context),
'columns': context.get('columns'),
}
class TableConfigOrderingPanel(panels.ObjectPanel):
template_name = 'extras/panels/tableconfig_ordering.html'
title = _('Ordering')
def get_context(self, context):
return {
**super().get_context(context),
'columns': context.get('columns'),
}
#
# NotificationGroup panels
#
class NotificationGroupPanel(panels.ObjectAttributesPanel):
title = _('Notification Group')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class NotificationGroupGroupsPanel(panels.ObjectPanel):
template_name = 'extras/panels/notificationgroup_groups.html'
title = _('Groups')
class NotificationGroupUsersPanel(panels.ObjectPanel):
template_name = 'extras/panels/notificationgroup_users.html'
title = _('Users')
#
# Webhook panels
#
class WebhookPanel(panels.ObjectAttributesPanel):
title = _('Webhook')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class WebhookHTTPPanel(panels.ObjectAttributesPanel):
title = _('HTTP Request')
http_method = attrs.ChoiceAttr('http_method', label=_('HTTP method'))
payload_url = attrs.TextAttr('payload_url', label=_('Payload URL'), style='font-monospace')
http_content_type = attrs.TextAttr('http_content_type', label=_('HTTP content type'))
secret = attrs.TextAttr('secret')
class WebhookSSLPanel(panels.ObjectAttributesPanel):
title = _('SSL')
ssl_verification = attrs.BooleanAttr('ssl_verification', label=_('SSL verification'))
ca_file_path = attrs.TextAttr('ca_file_path', label=_('CA file path'))
#
# EventRule panels
#
class EventRulePanel(panels.ObjectAttributesPanel):
title = _('Event Rule')
name = attrs.TextAttr('name')
enabled = attrs.BooleanAttr('enabled')
description = attrs.TextAttr('description')
class EventRuleEventTypesPanel(panels.ObjectPanel):
template_name = 'extras/panels/eventrule_event_types.html'
title = _('Event Types')
def get_context(self, context):
return {
**super().get_context(context),
'registry': context.get('registry'),
}
class EventRuleActionPanel(panels.ObjectAttributesPanel):
title = _('Action')
action_type = attrs.ChoiceAttr('action_type', label=_('Type'))
action_object = attrs.RelatedObjectAttr('action_object', linkify=True, label=_('Object'))
action_data = attrs.TemplatedAttr(
'action_data',
label=_('Data'),
template_name='extras/eventrule/attrs/action_data.html',
)
#
# Tag panels
#
class TagPanel(panels.ObjectAttributesPanel):
title = _('Tag')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
color = attrs.ColorAttr('color')
weight = attrs.NumericAttr('weight')
tagged_items = attrs.TemplatedAttr(
'extras_taggeditem_items',
template_name='extras/tag/attrs/tagged_item_count.html',
)
class TagObjectTypesPanel(panels.ObjectPanel):
template_name = 'extras/panels/tag_object_types.html'
title = _('Allowed Object Types')
class TagItemTypesPanel(panels.ObjectPanel):
template_name = 'extras/panels/tag_item_types.html'
title = _('Tagged Item Types')
def get_context(self, context):
return {
**super().get_context(context),
'object_types': context.get('object_types'),
}
#
# ConfigContextProfile panels
#
class ConfigContextProfilePanel(panels.ObjectAttributesPanel):
title = _('Config Context Profile')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
#
# ConfigContext panels
#
class ConfigContextPanel(panels.ObjectAttributesPanel):
title = _('Config Context')
name = attrs.TextAttr('name')
weight = attrs.NumericAttr('weight')
profile = attrs.RelatedObjectAttr('profile', linkify=True)
description = attrs.TextAttr('description')
is_active = attrs.BooleanAttr('is_active', label=_('Active'))
class ConfigContextAssignmentPanel(panels.ObjectPanel):
template_name = 'extras/panels/configcontext_assignment.html'
title = _('Assignment')
def get_context(self, context):
return {
**super().get_context(context),
'assigned_objects': context.get('assigned_objects'),
}
#
# ConfigTemplate panels
#
class ConfigTemplatePanel(panels.ObjectAttributesPanel):
title = _('Config Template')
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
file_name = attrs.TextAttr('file_name')
file_extension = attrs.TextAttr('file_extension')
as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
data_source = attrs.RelatedObjectAttr('data_source', linkify=True)
data_file = attrs.TemplatedAttr(
'data_path',
template_name='extras/configtemplate/attrs/data_file.html',
)
data_synced = attrs.DateTimeAttr('data_synced')
auto_sync_enabled = attrs.BooleanAttr('auto_sync_enabled')
#
# ImageAttachment panels
#
class ImageAttachmentPanel(panels.ObjectAttributesPanel):
title = _('Image Attachment')
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent object'))
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class ImageAttachmentFilePanel(panels.ObjectPanel):
template_name = 'extras/panels/imageattachment_file.html'
title = _('File')
class ImageAttachmentImagePanel(panels.ObjectPanel):
template_name = 'extras/panels/imageattachment_image.html'
title = _('Image')
#
# JournalEntry panels
#
class JournalEntryPanel(panels.ObjectAttributesPanel):
title = _('Journal Entry')
assigned_object = attrs.RelatedObjectAttr('assigned_object', linkify=True, label=_('Object'))
created = attrs.DateTimeAttr('created', spec='minutes')
created_by = attrs.TextAttr('created_by')
kind = attrs.ChoiceAttr('kind')

View File

@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext as _
from django.views.generic import View
from jinja2.exceptions import TemplateError
@@ -23,14 +23,6 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from extras.utils import SharedObjectViewMixin
from netbox.object_actions import *
from netbox.ui import layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
JSONPanel,
TemplatePanel,
TextCodePanel,
)
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value
@@ -48,7 +40,6 @@ from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK
from .models import *
from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable
from .ui import panels
#
# Custom fields
@@ -66,18 +57,6 @@ class CustomFieldListView(generic.ObjectListView):
@register_model_view(CustomField)
class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.select_related('choice_set')
layout = layout.SimpleLayout(
left_panels=[
panels.CustomFieldPanel(),
panels.CustomFieldBehaviorPanel(),
CommentsPanel(),
],
right_panels=[
panels.CustomFieldObjectTypesPanel(),
panels.CustomFieldValidationPanel(),
panels.CustomFieldRelatedObjectsPanel(),
],
)
def get_extra_context(self, request, instance):
related_models = ()
@@ -149,14 +128,6 @@ class CustomFieldChoiceSetListView(generic.ObjectListView):
@register_model_view(CustomFieldChoiceSet)
class CustomFieldChoiceSetView(generic.ObjectView):
queryset = CustomFieldChoiceSet.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CustomFieldChoiceSetPanel(),
],
right_panels=[
panels.CustomFieldChoiceSetChoicesPanel(),
],
)
def get_extra_context(self, request, instance):
@@ -232,16 +203,6 @@ class CustomLinkListView(generic.ObjectListView):
@register_model_view(CustomLink)
class CustomLinkView(generic.ObjectView):
queryset = CustomLink.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CustomLinkPanel(),
panels.ObjectTypesPanel(title=_('Assigned Models')),
],
right_panels=[
TextCodePanel('link_text', title=_('Link Text')),
TextCodePanel('link_url', title=_('Link URL')),
],
)
@register_model_view(CustomLink, 'add', detail=False)
@@ -299,19 +260,6 @@ class ExportTemplateListView(generic.ObjectListView):
@register_model_view(ExportTemplate)
class ExportTemplateView(generic.ObjectView):
queryset = ExportTemplate.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ExportTemplatePanel(),
TemplatePanel('core/inc/datafile_panel.html'),
],
right_panels=[
panels.ObjectTypesPanel(title=_('Assigned Models')),
JSONPanel('environment_params', title=_('Environment Parameters')),
],
bottom_panels=[
TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
],
)
@register_model_view(ExportTemplate, 'add', detail=False)
@@ -373,15 +321,6 @@ class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
@register_model_view(SavedFilter)
class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
queryset = SavedFilter.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.SavedFilterPanel(),
panels.SavedFilterObjectTypesPanel(),
],
right_panels=[
JSONPanel('parameters', title=_('Parameters')),
],
)
@register_model_view(SavedFilter, 'add', detail=False)
@@ -444,15 +383,6 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
@register_model_view(TableConfig)
class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
queryset = TableConfig.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TableConfigPanel(),
],
right_panels=[
panels.TableConfigColumnsPanel(),
panels.TableConfigOrderingPanel(),
],
)
def get_extra_context(self, request, instance):
table = instance.table_class([])
@@ -546,15 +476,6 @@ class NotificationGroupListView(generic.ObjectListView):
@register_model_view(NotificationGroup)
class NotificationGroupView(generic.ObjectView):
queryset = NotificationGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.NotificationGroupPanel(),
],
right_panels=[
panels.NotificationGroupGroupsPanel(),
panels.NotificationGroupUsersPanel(),
],
)
@register_model_view(NotificationGroup, 'add', detail=False)
@@ -739,19 +660,6 @@ class WebhookListView(generic.ObjectListView):
@register_model_view(Webhook)
class WebhookView(generic.ObjectView):
queryset = Webhook.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.WebhookPanel(),
panels.WebhookHTTPPanel(),
panels.WebhookSSLPanel(),
],
right_panels=[
TextCodePanel('additional_headers', title=_('Additional Headers')),
TextCodePanel('body_template', title=_('Body Template')),
panels.CustomFieldsPanel(),
panels.TagsPanel(),
],
)
@register_model_view(Webhook, 'add', detail=False)
@@ -808,19 +716,6 @@ class EventRuleListView(generic.ObjectListView):
@register_model_view(EventRule)
class EventRuleView(generic.ObjectView):
queryset = EventRule.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.EventRulePanel(),
panels.ObjectTypesPanel(),
panels.EventRuleEventTypesPanel(),
],
right_panels=[
JSONPanel('conditions', title=_('Conditions')),
panels.EventRuleActionPanel(),
panels.CustomFieldsPanel(),
panels.TagsPanel(),
],
)
@register_model_view(EventRule, 'add', detail=False)
@@ -879,18 +774,6 @@ class TagListView(generic.ObjectListView):
@register_model_view(Tag)
class TagView(generic.ObjectView):
queryset = Tag.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TagPanel(),
],
right_panels=[
panels.TagObjectTypesPanel(),
panels.TagItemTypesPanel(),
],
bottom_panels=[
ContextTablePanel('taggeditem_table', title=_('Tagged Objects')),
],
)
def get_extra_context(self, request, instance):
tagged_items = TaggedItem.objects.filter(tag=instance)
@@ -970,18 +853,6 @@ class ConfigContextProfileListView(generic.ObjectListView):
@register_model_view(ConfigContextProfile)
class ConfigContextProfileView(generic.ObjectView):
queryset = ConfigContextProfile.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ConfigContextProfilePanel(),
TemplatePanel('core/inc/datafile_panel.html'),
panels.CustomFieldsPanel(),
panels.TagsPanel(),
CommentsPanel(),
],
right_panels=[
JSONPanel('schema', title=_('JSON Schema')),
],
)
@register_model_view(ConfigContextProfile, 'add', detail=False)
@@ -1044,16 +915,6 @@ class ConfigContextListView(generic.ObjectListView):
@register_model_view(ConfigContext)
class ConfigContextView(generic.ObjectView):
queryset = ConfigContext.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ConfigContextPanel(),
TemplatePanel('core/inc/datafile_panel.html'),
panels.ConfigContextAssignmentPanel(),
],
right_panels=[
TemplatePanel('extras/panels/configcontext_data.html'),
],
)
def get_extra_context(self, request, instance):
# Gather assigned objects for parsing in the template
@@ -1173,18 +1034,6 @@ class ConfigTemplateListView(generic.ObjectListView):
@register_model_view(ConfigTemplate)
class ConfigTemplateView(generic.ObjectView):
queryset = ConfigTemplate.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ConfigTemplatePanel(),
panels.TagsPanel(),
],
right_panels=[
JSONPanel('environment_params', title=_('Environment Parameters')),
],
bottom_panels=[
TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
],
)
@register_model_view(ConfigTemplate, 'add', detail=False)
@@ -1302,17 +1151,6 @@ class ImageAttachmentListView(generic.ObjectListView):
@register_model_view(ImageAttachment)
class ImageAttachmentView(generic.ObjectView):
queryset = ImageAttachment.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ImageAttachmentPanel(),
],
right_panels=[
panels.ImageAttachmentFilePanel(),
],
bottom_panels=[
panels.ImageAttachmentImagePanel(),
],
)
@register_model_view(ImageAttachment, 'add', detail=False)
@@ -1377,16 +1215,6 @@ class JournalEntryListView(generic.ObjectListView):
@register_model_view(JournalEntry)
class JournalEntryView(generic.ObjectView):
queryset = JournalEntry.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.JournalEntryPanel(),
panels.CustomFieldsPanel(),
panels.TagsPanel(),
],
right_panels=[
CommentsPanel(),
],
)
@register_model_view(JournalEntry, 'add', detail=False)

View File

@@ -210,8 +210,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
class Meta:
model = Prefix
fields = (
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name',
'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
@@ -424,36 +424,19 @@ class IPAddressImportForm(PrimaryModelImportForm):
# Set as primary for device/VM
if self.cleaned_data.get('is_primary') is not None:
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
if self.cleaned_data.get('is_primary'):
parent.snapshot()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress
parent.save()
else:
# Only clear the primary IP if this IP is currently set as primary
if self.instance.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.snapshot()
parent.primary_ip4 = None
parent.save()
elif self.instance.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.snapshot()
parent.primary_ip6 = None
parent.save()
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:
parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob') is not None:
parent = self.cleaned_data.get('device')
if self.cleaned_data.get('is_oob'):
parent.snapshot()
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
# Only clear OOB if this IP is currently set as the OOB IP
parent.snapshot()
parent.oob_ip = None
parent.save()
parent.snapshot()
parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
parent.save()
return ipaddress
@@ -474,8 +457,7 @@ class FHRPGroupImportForm(PrimaryModelImportForm):
fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags')
class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
# Override ScopedImportForm.scope_type to set custom queryset
class VLANGroupImportForm(OrganizationalModelImportForm):
scope_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
@@ -495,11 +477,10 @@ class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
class Meta:
model = VLANGroup
fields = (
'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner',
'comments', 'tags',
'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags'
)
labels = {
'scope_id': _('Scope ID'),
'scope_id': 'Scope ID',
}

View File

@@ -159,11 +159,9 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
@property
def family(self):
if not self.prefix:
return None
if isinstance(self.prefix, str):
return netaddr.IPNetwork(self.prefix).version
return self.prefix.version
if self.prefix:
return self.prefix.version
return None
@property
def ipv6_full(self):
@@ -337,19 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
@property
def family(self):
if not self.prefix:
return None
if isinstance(self.prefix, str):
return netaddr.IPNetwork(self.prefix).version
return self.prefix.version
return self.prefix.version if self.prefix else None
@property
def mask_length(self):
if not self.prefix:
return None
if isinstance(self.prefix, str):
return netaddr.IPNetwork(self.prefix).prefixlen
return self.prefix.prefixlen
return self.prefix.prefixlen if self.prefix else None
@property
def ipv6_full(self):
@@ -377,16 +367,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
def get_status_color(self):
return PrefixStatusChoices.colors.get(self.status)
@cached_property
def aggregate(self):
"""
Return the containing Aggregate for this Prefix, if any.
"""
try:
return Aggregate.objects.get(prefix__net_contains_or_equals=str(self.prefix))
except Aggregate.DoesNotExist:
return None
def get_parents(self, include_self=False):
"""
Return all containing Prefixes in the hierarchy.
@@ -652,11 +632,7 @@ class IPRange(ContactsMixin, PrimaryModel):
@property
def family(self):
if not self.start_address:
return None
if isinstance(self.start_address, str):
return netaddr.IPAddress(self.start_address.split('/')[0]).version
return self.start_address.version
return self.start_address.version if self.start_address else None
@property
def range(self):
@@ -1004,11 +980,9 @@ class IPAddress(ContactsMixin, PrimaryModel):
@property
def family(self):
if not self.address:
return None
if isinstance(self.address, str):
return netaddr.IPNetwork(self.address).version
return self.address.version
if self.address:
return self.address.version
return None
@property
def is_oob_ip(self):

View File

@@ -247,6 +247,6 @@ class VLANTranslationRuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VLANTranslationRule
fields = (
'pk', 'id', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'policy', 'local_vid', 'remote_vid', 'description')

View File

@@ -1,10 +1,8 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
from dcim.models import Location, Region, Site, SiteGroup
from ipam.forms import PrefixForm
from ipam.forms.bulk_import import IPAddressImportForm
class PrefixFormTestCase(TestCase):
@@ -43,56 +41,3 @@ class PrefixFormTestCase(TestCase):
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
class IPAddressImportFormTestCase(TestCase):
"""Tests for IPAddressImportForm bulk import behavior."""
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
cls.device = Device.objects.create(
name='Device 1',
site=site,
device_type=device_type,
role=device_role,
)
cls.interface = Interface.objects.create(
device=cls.device,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
)
def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
"""
Regression test for #21440: importing a second IP with is_oob=False should
not clear the OOB IP set by a previous row with is_oob=True.
"""
form1 = IPAddressImportForm(data={
'address': '10.10.10.1/24',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': True,
})
self.assertTrue(form1.is_valid(), form1.errors)
ip1 = form1.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1)
form2 = IPAddressImportForm(data={
'address': '2001:db8::1/64',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': False,
})
self.assertTrue(form2.is_valid(), form2.errors)
form2.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")

View File

@@ -11,13 +11,6 @@ from utilities.data import string_to_ranges
class TestAggregate(TestCase):
def test_family_string(self):
# Test property when prefix is a string
agg = Aggregate(prefix='10.0.0.0/8')
self.assertEqual(agg.family, 4)
agg_v6 = Aggregate(prefix='2001:db8::/32')
self.assertEqual(agg_v6.family, 6)
def test_get_utilization(self):
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
aggregate = Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rir)
@@ -47,13 +40,6 @@ class TestAggregate(TestCase):
class TestIPRange(TestCase):
def test_family_string(self):
# Test property when start_address is a string
ip_range = IPRange(start_address='10.0.0.1/24', end_address='10.0.0.254/24')
self.assertEqual(ip_range.family, 4)
ip_range_v6 = IPRange(start_address='2001:db8::1/64', end_address='2001:db8::ffff/64')
self.assertEqual(ip_range_v6.family, 6)
def test_overlapping_range(self):
iprange_192_168 = IPRange.objects.create(
start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')
@@ -104,20 +90,6 @@ class TestIPRange(TestCase):
class TestPrefix(TestCase):
def test_family_string(self):
# Test property when prefix is a string
prefix = Prefix(prefix='10.0.0.0/8')
self.assertEqual(prefix.family, 4)
prefix_v6 = Prefix(prefix='2001:db8::/32')
self.assertEqual(prefix_v6.family, 6)
def test_mask_length_string(self):
# Test property when prefix is a string
prefix = Prefix(prefix='10.0.0.0/8')
self.assertEqual(prefix.mask_length, 8)
prefix_v6 = Prefix(prefix='2001:db8::/32')
self.assertEqual(prefix_v6.mask_length, 32)
def test_get_duplicates(self):
prefixes = Prefix.objects.bulk_create((
Prefix(prefix=IPNetwork('192.0.2.0/24')),
@@ -561,13 +533,6 @@ class TestPrefixHierarchy(TestCase):
class TestIPAddress(TestCase):
def test_family_string(self):
# Test property when address is a string
ip = IPAddress(address='10.0.0.1/24')
self.assertEqual(ip.family, 4)
ip_v6 = IPAddress(address='2001:db8::1/64')
self.assertEqual(ip_v6.family, 6)
def test_get_duplicates(self):
ips = IPAddress.objects.bulk_create((
IPAddress(address=IPNetwork('192.0.2.1/24')),

View File

@@ -435,21 +435,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
site = sites[0]
cls.csv_data = {
'default': (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}",
),
'scope_name': (
"vrf,prefix,status,scope_type,scope_name",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}",
),
}
site = sites[0].pk
cls.csv_data = (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
)
cls.csv_update_data = (
"id,description,status",
@@ -540,32 +532,6 @@ scope_id: {site.pk}
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_scope_name(self):
"""
Test YAML-based import using scope_name instead of scope_id.
"""
site = Site.objects.get(name='Site 1')
IMPORT_DATA = """
prefix: 10.1.3.0/24
status: active
scope_type: dcim.site
scope_name: Site 1
"""
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.3.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
@@ -918,20 +884,12 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = {
'default': (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
),
'scope_name': (
"name,slug,scope_type,scope_name,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group",
),
}
cls.csv_data = (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
)
cls.csv_update_data = (
"id,name,description",

View File

@@ -1,24 +0,0 @@
from django.template.loader import render_to_string
from netbox.ui import attrs
class VRFDisplayAttr(attrs.ObjectAttribute):
"""
Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
the route distinguisher (RD).
"""
template_name = 'ipam/attrs/vrf.html'
def __init__(self, *args, show_rd=False, **kwargs):
super().__init__(*args, **kwargs)
self.show_rd = show_rd
def render(self, obj, context):
value = self.get_value(obj)
return render_to_string(self.template_name, {
**self.get_context(obj, context),
'name': context['name'],
'value': value,
'show_rd': self.show_rd,
})

View File

@@ -2,15 +2,14 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
from .attrs import VRFDisplayAttr
from netbox.ui import actions, panels
class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
"""
A panel which lists all FHRP group assignments for a given object.
"""
template_name = 'ipam/panels/fhrp_groups.html'
title = _('FHRP Groups')
actions = [
@@ -36,220 +35,3 @@ class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
label=_('Assign Group'),
),
]
class VRFPanel(panels.ObjectAttributesPanel):
rd = attrs.TextAttr('rd', label=_('Route Distinguisher'), style='font-monospace')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
enforce_unique = attrs.BooleanAttr('enforce_unique', label=_('Unique IP Space'))
description = attrs.TextAttr('description')
class RouteTargetPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name', style='font-monospace')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
class RIRPanel(panels.OrganizationalObjectPanel):
is_private = attrs.BooleanAttr('is_private', label=_('Private'))
class ASNRangePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
range = attrs.TextAttr('range_as_string_with_asdot', label=_('Range'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
class ASNPanel(panels.ObjectAttributesPanel):
asn = attrs.TextAttr('asn_with_asdot', label=_('AS Number'))
rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
class AggregatePanel(panels.ObjectAttributesPanel):
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
utilization = attrs.TemplatedAttr(
'prefix',
template_name='ipam/aggregate/attrs/utilization.html',
label=_('Utilization'),
)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
date_added = attrs.DateTimeAttr('date_added', spec='date', label=_('Date Added'))
description = attrs.TextAttr('description')
class RolePanel(panels.OrganizationalObjectPanel):
weight = attrs.NumericAttr('weight')
class IPRangePanel(panels.ObjectAttributesPanel):
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
start_address = attrs.TextAttr('start_address', label=_('Starting Address'))
end_address = attrs.TextAttr('end_address', label=_('Ending Address'))
size = attrs.NumericAttr('size')
mark_populated = attrs.BooleanAttr('mark_populated', label=_('Marked Populated'))
mark_utilized = attrs.BooleanAttr('mark_utilized', label=_('Marked Utilized'))
utilization = attrs.TemplatedAttr(
'utilization',
template_name='ipam/iprange/attrs/utilization.html',
label=_('Utilization'),
)
vrf = VRFDisplayAttr('vrf', label=_('VRF'), show_rd=True)
role = attrs.RelatedObjectAttr('role', linkify=True)
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
class IPAddressPanel(panels.ObjectAttributesPanel):
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
vrf = VRFDisplayAttr('vrf', label=_('VRF'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
status = attrs.ChoiceAttr('status')
role = attrs.ChoiceAttr('role')
dns_name = attrs.TextAttr('dns_name', label=_('DNS Name'))
description = attrs.TextAttr('description')
assigned_object = attrs.RelatedObjectAttr(
'assigned_object',
linkify=True,
grouped_by='parent_object',
label=_('Assignment'),
)
nat_inside = attrs.TemplatedAttr(
'nat_inside',
template_name='ipam/ipaddress/attrs/nat_inside.html',
label=_('NAT (inside)'),
)
nat_outside = attrs.TemplatedAttr(
'nat_outside',
template_name='ipam/ipaddress/attrs/nat_outside.html',
label=_('NAT (outside)'),
)
is_primary_ip = attrs.BooleanAttr('is_primary_ip', label=_('Primary IP'))
is_oob_ip = attrs.BooleanAttr('is_oob_ip', label=_('OOB IP'))
class PrefixPanel(panels.ObjectAttributesPanel):
family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
vrf = VRFDisplayAttr('vrf', label=_('VRF'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
aggregate = attrs.TemplatedAttr(
'aggregate',
template_name='ipam/prefix/attrs/aggregate.html',
label=_('Aggregate'),
)
scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
vlan = attrs.RelatedObjectAttr('vlan', linkify=True, label=_('VLAN'), grouped_by='group')
status = attrs.ChoiceAttr('status')
role = attrs.RelatedObjectAttr('role', linkify=True)
description = attrs.TextAttr('description')
is_pool = attrs.BooleanAttr('is_pool', label=_('Is a pool'))
class VLANGroupPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
vid_ranges = attrs.TemplatedAttr(
'vid_ranges_items',
template_name='ipam/vlangroup/attrs/vid_ranges.html',
label=_('VLAN IDs'),
)
utilization = attrs.UtilizationAttr('utilization')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class VLANTranslationPolicyPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class VLANTranslationRulePanel(panels.ObjectAttributesPanel):
policy = attrs.RelatedObjectAttr('policy', linkify=True)
local_vid = attrs.NumericAttr('local_vid', label=_('Local VID'))
remote_vid = attrs.NumericAttr('remote_vid', label=_('Remote VID'))
description = attrs.TextAttr('description')
class FHRPGroupPanel(panels.ObjectAttributesPanel):
protocol = attrs.ChoiceAttr('protocol')
group_id = attrs.NumericAttr('group_id', label=_('Group ID'))
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
member_count = attrs.NumericAttr('member_count', label=_('Members'))
class FHRPGroupAuthPanel(panels.ObjectAttributesPanel):
title = _('Authentication')
auth_type = attrs.ChoiceAttr('auth_type', label=_('Authentication Type'))
auth_key = attrs.TextAttr('auth_key', label=_('Authentication Key'))
class VLANPanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('site.region', linkify=True, label=_('Region'))
site = attrs.RelatedObjectAttr('site', linkify=True)
group = attrs.RelatedObjectAttr('group', linkify=True)
vid = attrs.NumericAttr('vid', label=_('VLAN ID'))
name = attrs.TextAttr('name')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
status = attrs.ChoiceAttr('status')
role = attrs.RelatedObjectAttr('role', linkify=True)
description = attrs.TextAttr('description')
qinq_role = attrs.ChoiceAttr('qinq_role', label=_('Q-in-Q Role'))
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
class VLANCustomerVLANsPanel(panels.ObjectsTablePanel):
"""
A panel listing customer VLANs (C-VLANs) for an S-VLAN. Only renders when the VLAN has Q-in-Q
role 'svlan'.
"""
def __init__(self):
super().__init__(
'ipam.vlan',
filters={'qinq_svlan_id': lambda ctx: ctx['object'].pk},
title=_('Customer VLANs'),
actions=[
actions.AddObject(
'ipam.vlan',
url_params={
'qinq_role': 'cvlan',
'qinq_svlan': lambda ctx: ctx['object'].pk,
},
label=_('Add a VLAN'),
),
],
)
def render(self, context):
obj = context.get('object')
if not obj or obj.qinq_role != 'svlan':
return ''
return super().render(context)
class ServiceTemplatePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
protocol = attrs.ChoiceAttr('protocol')
ports = attrs.TextAttr('port_list', label=_('Ports'))
description = attrs.TextAttr('description')
class ServicePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
parent = attrs.RelatedObjectAttr('parent', linkify=True)
protocol = attrs.ChoiceAttr('protocol')
ports = attrs.TextAttr('port_list', label=_('Ports'))
ip_addresses = attrs.TemplatedAttr(
'ipaddresses',
template_name='ipam/service/attrs/ip_addresses.html',
label=_('IP Addresses'),
)
description = attrs.TextAttr('description')

View File

@@ -9,16 +9,8 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Device, Interface, Site
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from ipam.tables import VLANTranslationRuleTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
from utilities.tables import get_table_ordering
@@ -31,7 +23,6 @@ from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
from .constants import *
from .models import *
from .ui import panels
from .utils import add_available_vlans, add_requested_prefixes, annotate_ip_space
#
@@ -50,27 +41,6 @@ class VRFListView(generic.ObjectListView):
@register_model_view(VRF)
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VRF.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
panels.VRFPanel(),
TagsPanel(),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
),
),
layout.Row(
layout.Column(
ContextTablePanel('import_targets_table', title=_('Import route targets')),
),
layout.Column(
ContextTablePanel('export_targets_table', title=_('Export route targets')),
),
),
)
def get_extra_context(self, request, instance):
import_targets_table = tables.RouteTargetTable(
@@ -164,50 +134,6 @@ class RouteTargetListView(generic.ObjectListView):
@register_model_view(RouteTarget)
class RouteTargetView(generic.ObjectView):
queryset = RouteTarget.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
),
),
layout.Row(
layout.Column(
ObjectsTablePanel(
'ipam.vrf',
filters={'import_target_id': lambda ctx: ctx['object'].pk},
title=_('Importing VRFs'),
),
),
layout.Column(
ObjectsTablePanel(
'ipam.vrf',
filters={'export_target_id': lambda ctx: ctx['object'].pk},
title=_('Exporting VRFs'),
),
),
),
layout.Row(
layout.Column(
ObjectsTablePanel(
'vpn.l2vpn',
filters={'import_target_id': lambda ctx: ctx['object'].pk},
title=_('Importing L2VPNs'),
),
),
layout.Column(
ObjectsTablePanel(
'vpn.l2vpn',
filters={'export_target_id': lambda ctx: ctx['object'].pk},
title=_('Exporting L2VPNs'),
),
),
),
)
@register_model_view(RouteTarget, 'add', detail=False)
@@ -266,17 +192,6 @@ class RIRListView(generic.ObjectListView):
@register_model_view(RIR)
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RIR.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RIRPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -342,16 +257,6 @@ class ASNRangeListView(generic.ObjectListView):
@register_model_view(ASNRange)
class ASNRangeView(generic.ObjectView):
queryset = ASNRange.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ASNRangePanel(),
TagsPanel(),
],
right_panels=[
CommentsPanel(),
CustomFieldsPanel(),
],
)
@register_model_view(ASNRange, 'asns')
@@ -432,17 +337,6 @@ class ASNListView(generic.ObjectListView):
@register_model_view(ASN)
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ASN.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ASNPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -518,16 +412,6 @@ class AggregateListView(generic.ObjectListView):
@register_model_view(Aggregate)
class AggregateView(generic.ObjectView):
queryset = Aggregate.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.AggregatePanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
)
@register_model_view(Aggregate, 'prefixes')
@@ -622,17 +506,6 @@ class RoleListView(generic.ObjectListView):
@register_model_view(Role)
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Role.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RolePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -696,23 +569,15 @@ class PrefixListView(generic.ObjectListView):
@register_model_view(Prefix)
class PrefixView(generic.ObjectView):
queryset = Prefix.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PrefixPanel(),
],
right_panels=[
TemplatePanel('ipam/panels/prefix_addressing.html'),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
bottom_panels=[
ContextTablePanel('duplicate_prefix_table', title=_('Duplicate prefixes')),
ContextTablePanel('parent_prefix_table', title=_('Parent prefixes')),
],
)
def get_extra_context(self, request, instance):
try:
aggregate = Aggregate.objects.restrict(request.user, 'view').get(
prefix__net_contains_or_equals=str(instance.prefix)
)
except Aggregate.DoesNotExist:
aggregate = None
# Parent prefixes table
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
@@ -743,12 +608,11 @@ class PrefixView(generic.ObjectView):
)
duplicate_prefix_table.configure(request)
context = {
return {
'aggregate': aggregate,
'parent_prefix_table': parent_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
}
if duplicate_prefixes.exists():
context['duplicate_prefix_table'] = duplicate_prefix_table
return context
@register_model_view(Prefix, 'prefixes')
@@ -892,19 +756,6 @@ class IPRangeListView(generic.ObjectListView):
@register_model_view(IPRange)
class IPRangeView(generic.ObjectView):
queryset = IPRange.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPRangePanel(),
],
right_panels=[
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
],
)
def get_extra_context(self, request, instance):
@@ -1002,23 +853,6 @@ class IPAddressListView(generic.ObjectListView):
@register_model_view(IPAddress)
class IPAddressView(generic.ObjectView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
layout = layout.SimpleLayout(
left_panels=[
panels.IPAddressPanel(),
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
right_panels=[
ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
ContextTablePanel('duplicate_ips_table', title=_('Duplicate IPs')),
ObjectsTablePanel(
'ipam.service',
filters={'ip_address_id': lambda ctx: ctx['object'].pk},
title=_('Application services'),
),
],
)
def get_extra_context(self, request, instance):
# Parent prefixes table
@@ -1051,12 +885,10 @@ class IPAddressView(generic.ObjectView):
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
duplicate_ips_table.configure(request)
context = {
return {
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
}
if duplicate_ips.exists():
context['duplicate_ips_table'] = duplicate_ips_table
return context
@register_model_view(IPAddress, 'add', detail=False)
@@ -1206,17 +1038,6 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization()
layout = layout.SimpleLayout(
left_panels=[
panels.VLANGroupPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -1304,32 +1125,19 @@ class VLANTranslationPolicyListView(generic.ObjectListView):
@register_model_view(VLANTranslationPolicy)
class VLANTranslationPolicyView(generic.ObjectView):
class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANTranslationPolicy.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VLANTranslationPolicyPanel(),
],
right_panels=[
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'ipam.vlantranslationrule',
filters={'policy_id': lambda ctx: ctx['object'].pk},
title=_('VLAN translation rules'),
actions=[
actions.AddObject(
'ipam.vlantranslationrule',
url_params={'policy': lambda ctx: ctx['object'].pk},
label=_('Add Rule'),
),
],
),
],
)
def get_extra_context(self, request, instance):
vlan_translation_table = VLANTranslationRuleTable(
data=instance.rules.all(),
orderable=False
)
vlan_translation_table.configure(request)
return {
'vlan_translation_table': vlan_translation_table,
}
@register_model_view(VLANTranslationPolicy, 'add', detail=False)
@@ -1385,17 +1193,13 @@ class VLANTranslationRuleListView(generic.ObjectListView):
@register_model_view(VLANTranslationRule)
class VLANTranslationRuleView(generic.ObjectView):
class VLANTranslationRuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANTranslationRule.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VLANTranslationRulePanel(),
],
right_panels=[
TagsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(VLANTranslationRule, 'add', detail=False)
@@ -1447,36 +1251,7 @@ class FHRPGroupListView(generic.ObjectListView):
@register_model_view(FHRPGroup)
class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = FHRPGroup.objects.annotate(
member_count=count_related(FHRPGroupAssignment, 'group')
)
layout = layout.SimpleLayout(
left_panels=[
panels.FHRPGroupPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.FHRPGroupAuthPanel(),
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'ipam.ipaddress',
filters={'fhrpgroup_id': lambda ctx: ctx['object'].pk},
title=_('Virtual IP addresses'),
actions=[
actions.AddObject(
'ipam.ipaddress',
url_params={'fhrpgroup': lambda ctx: ctx['object'].pk},
label=_('Add IP Address'),
),
],
),
ContextTablePanel('members_table', title=_('Members')),
],
)
queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance):
# Get assigned interfaces
@@ -1501,6 +1276,7 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
),
),
'members_table': members_table,
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
}
@@ -1603,35 +1379,17 @@ class VLANListView(generic.ObjectListView):
@register_model_view(VLAN)
class VLANView(generic.ObjectView):
queryset = VLAN.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VLANPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'ipam.prefix',
filters={'vlan_id': lambda ctx: ctx['object'].pk},
title=_('Prefixes'),
actions=[
actions.AddObject(
'ipam.prefix',
url_params={
'tenant': lambda ctx: ctx['object'].tenant.pk if ctx['object'].tenant else None,
'site': lambda ctx: ctx['object'].site.pk if ctx['object'].site else None,
'vlan': lambda ctx: ctx['object'].pk,
},
label=_('Add a Prefix'),
),
],
),
panels.VLANCustomerVLANsPanel(),
],
)
def get_extra_context(self, request, instance):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
'vrf', 'scope', 'role', 'tenant'
)
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
prefix_table.configure(request)
return {
'prefix_table': prefix_table,
}
@register_model_view(VLAN, 'interfaces')
@@ -1725,16 +1483,6 @@ class ServiceTemplateListView(generic.ObjectListView):
@register_model_view(ServiceTemplate)
class ServiceTemplateView(generic.ObjectView):
queryset = ServiceTemplate.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ServiceTemplatePanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
)
@register_model_view(ServiceTemplate, 'add', detail=False)
@@ -1791,16 +1539,6 @@ class ServiceListView(generic.ObjectListView):
@register_model_view(Service)
class ServiceView(generic.ObjectView):
queryset = Service.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ServicePanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
)
def get_extra_context(self, request, instance):
context = {}

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from rest_framework.fields import CreateOnlyDefault
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldsDataField
from .base import ValidatedModelSerializer
from .nested import NestedTagSerializer
@@ -23,29 +23,6 @@ class CustomFieldModelSerializer(serializers.Serializer):
default=CreateOnlyDefault(CustomFieldDefaultValues())
)
@classmethod
def many_init(cls, *args, **kwargs):
"""
We can't call super().many_init() and change the outcome because by the time it returns,
the plain ListSerializer is already instantiated.
Because every NetBox serializer defines its own Meta which doesn't inherit from a parent Meta,
this would silently not apply to any real serializer.
Thats why this method replicates many_init from parent and changed the default value for list_serializer_class.
"""
list_kwargs = {}
for key in serializers.LIST_SERIALIZER_KWARGS_REMOVE:
value = kwargs.pop(key, None)
if value is not None:
list_kwargs[key] = value
list_kwargs['child'] = cls(*args, **kwargs)
list_kwargs.update({
key: value for key, value in kwargs.items()
if key in serializers.LIST_SERIALIZER_KWARGS
})
meta = getattr(cls, 'Meta', None)
list_serializer_class = getattr(meta, 'list_serializer_class', CustomFieldListSerializer)
return list_serializer_class(*args, **list_kwargs)
class TaggableModelSerializer(serializers.Serializer):
"""

View File

@@ -20,10 +20,6 @@ PLUGINS = [
'netbox.tests.dummy_plugin',
]
RQ = {
'COMMIT_MODE': 'auto',
}
REDIS = {
'tasks': {
'HOST': 'localhost',

View File

@@ -2,7 +2,6 @@ import json
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.db.models.fields.related import ManyToManyRel
from extras.choices import *
from utilities.forms.fields import CommentField, SlugField
@@ -72,49 +71,14 @@ class NetBoxModelForm(
def _post_clean(self):
"""
Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
Handles both forward and reverse M2M relationships, and supports both simple (single field)
and add/remove (dual field) modes.
"""
self.instance._m2m_values = {}
# Collect names to process: local M2M fields (includes TaggableManager from django-taggit)
# plus reverse M2M relations (ManyToManyRel).
names = [field.name for field in self.instance._meta.local_many_to_many]
names += [
field.get_accessor_name()
for field in self.instance._meta.get_fields()
if isinstance(field, ManyToManyRel)
]
for name in names:
if name in self.cleaned_data:
# Simple mode: single multi-select field
self.instance._m2m_values[name] = list(self.cleaned_data[name])
elif f'add_{name}' in self.cleaned_data or f'remove_{name}' in self.cleaned_data:
# Add/remove mode: compute the effective set
current = set(getattr(self.instance, name).values_list('pk', flat=True)) \
if self.instance.pk else set()
add_values = set(
v.pk for v in self.cleaned_data.get(f'add_{name}', [])
)
remove_values = set(
v.pk for v in self.cleaned_data.get(f'remove_{name}', [])
)
self.instance._m2m_values[name] = list((current | add_values) - remove_values)
for field in self.instance._meta.local_many_to_many:
if field.name in self.cleaned_data:
self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
return super()._post_clean()
def _save_m2m(self):
"""
Save many-to-many field values that were computed in _post_clean(). This handles M2M fields
not included in Meta.fields (e.g. those managed via M2MAddRemoveFields).
"""
super()._save_m2m()
meta_fields = self._meta.fields
for field_name, values in self.instance._m2m_values.items():
if not meta_fields or field_name not in meta_fields:
getattr(self.instance, field_name).set(values)
class PrimaryModelForm(OwnerMixin, NetBoxModelForm):
"""

View File

@@ -1,12 +1,10 @@
from typing import NewType
from typing import Union
import strawberry
BigInt = NewType('BigInt', int)
BigIntScalar = strawberry.scalar(
name='BigInt',
BigInt = strawberry.scalar(
Union[int, str], # type: ignore
serialize=lambda v: int(v),
parse_value=lambda v: str(v),
description='BigInt field',
description="BigInt field",
)

View File

@@ -16,8 +16,6 @@ from virtualization.graphql.schema import VirtualizationQuery
from vpn.graphql.schema import VPNQuery
from wireless.graphql.schema import WirelessQuery
from .scalars import BigInt, BigIntScalar
@strawberry.type
class Query(
@@ -38,14 +36,9 @@ class Query(
schema = strawberry.Schema(
query=Query,
config=StrawberryConfig(
auto_camel_case=False,
scalar_map={
BigInt: BigIntScalar,
},
),
config=StrawberryConfig(auto_camel_case=False),
extensions=[
DjangoOptimizerExtension(prefetch_custom_queryset=True),
MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
],
]
)

View File

@@ -168,7 +168,6 @@ REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAM
REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
# Required by extras/migrations/0109_script_models.py
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ = getattr(configuration, 'RQ', {})
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)

View File

@@ -159,7 +159,7 @@ class BaseTable(tables.Table):
columns = None
ordering = None
if request.user.is_authenticated and self.prefixed_order_by_field in request.GET:
if self.prefixed_order_by_field in request.GET:
if request.GET[self.prefixed_order_by_field]:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.

View File

@@ -1,4 +1,3 @@
from django.contrib.auth.models import AnonymousUser
from django.template import Context, Template
from django.test import RequestFactory, TestCase
@@ -47,16 +46,6 @@ class BaseTableTest(TestCase):
prefetch_lookups = table.data.data._prefetch_related_lookups
self.assertEqual(prefetch_lookups, tuple())
def test_configure_anonymous_user_with_ordering(self):
"""
Verify that table.configure() does not raise an error when an anonymous
user sorts a table column.
"""
request = RequestFactory().get('/?sort=name')
request.user = AnonymousUser()
table = DeviceTable(Device.objects.all())
table.configure(request)
class TagColumnTable(NetBoxTable):
tags = columns.TagColumn(url_name='dcim:site_list')

View File

@@ -1,215 +0,0 @@
from django.test import TestCase
from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
from circuits.models import (
Provider,
ProviderNetwork,
VirtualCircuit,
VirtualCircuitTermination,
VirtualCircuitType,
)
from dcim.choices import InterfaceTypeChoices
from dcim.models import Interface
from netbox.ui import attrs
from utilities.testing import create_test_device
from vpn.choices import (
AuthenticationAlgorithmChoices,
AuthenticationMethodChoices,
DHGroupChoices,
EncryptionAlgorithmChoices,
IKEModeChoices,
IKEVersionChoices,
IPSecModeChoices,
)
from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile
class ChoiceAttrTest(TestCase):
"""
Test class for validating the behavior of ChoiceAttr attribute accessor.
This test class verifies that the ChoiceAttr class correctly handles
choice field attributes on Django model instances, including both direct
field access and related object field access. It tests the retrieval of
display values and associated context information such as color values
for choice fields. The test data includes a network topology with devices,
interfaces, providers, and virtual circuits to cover various scenarios of
choice field access patterns.
"""
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
interface = Interface.objects.create(
device=device,
name='vlan.100',
type=InterfaceTypeChoices.TYPE_VIRTUAL,
)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(
provider=provider,
name='Provider Network 1',
)
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1',
)
virtual_circuit = VirtualCircuit.objects.create(
cid='VC-100',
provider_network=provider_network,
type=virtual_circuit_type,
status=CircuitStatusChoices.STATUS_ACTIVE,
)
cls.termination = VirtualCircuitTermination.objects.create(
virtual_circuit=virtual_circuit,
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=interface,
)
def test_choice_attr_direct_accessor(self):
attr = attrs.ChoiceAttr('role')
self.assertEqual(
attr.get_value(self.termination),
self.termination.get_role_display(),
)
self.assertEqual(
attr.get_context(self.termination, {}),
{'bg_color': self.termination.get_role_color()},
)
def test_choice_attr_related_accessor(self):
attr = attrs.ChoiceAttr('interface.type')
self.assertEqual(
attr.get_value(self.termination),
self.termination.interface.get_type_display(),
)
self.assertEqual(
attr.get_context(self.termination, {}),
{'bg_color': None},
)
def test_choice_attr_related_accessor_with_color(self):
attr = attrs.ChoiceAttr('virtual_circuit.status')
self.assertEqual(
attr.get_value(self.termination),
self.termination.virtual_circuit.get_status_display(),
)
self.assertEqual(
attr.get_context(self.termination, {}),
{'bg_color': self.termination.virtual_circuit.get_status_color()},
)
class RelatedObjectListAttrTest(TestCase):
"""
Test suite for RelatedObjectListAttr functionality.
This test class validates the behavior of the RelatedObjectListAttr class,
which is used to render related objects as HTML lists. It tests various
scenarios including direct accessor access, related accessor access through
foreign keys, empty related object sets, and rendering with maximum item
limits and overflow indicators. The tests use IKE and IPSec VPN policy
models to verify proper rendering of one-to-many and many-to-many
relationships between objects.
"""
@classmethod
def setUpTestData(cls):
cls.proposals = (
IKEProposal.objects.create(
name='IKE Proposal 1',
authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
group=DHGroupChoices.GROUP_14,
),
IKEProposal.objects.create(
name='IKE Proposal 2',
authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
group=DHGroupChoices.GROUP_14,
),
IKEProposal.objects.create(
name='IKE Proposal 3',
authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
group=DHGroupChoices.GROUP_14,
),
)
cls.ike_policy = IKEPolicy.objects.create(
name='IKE Policy 1',
version=IKEVersionChoices.VERSION_1,
mode=IKEModeChoices.MAIN,
)
cls.ike_policy.proposals.set(cls.proposals)
cls.empty_ike_policy = IKEPolicy.objects.create(
name='IKE Policy 2',
version=IKEVersionChoices.VERSION_1,
mode=IKEModeChoices.MAIN,
)
cls.ipsec_policy = IPSecPolicy.objects.create(name='IPSec Policy 1')
cls.profile = IPSecProfile.objects.create(
name='IPSec Profile 1',
mode=IPSecModeChoices.ESP,
ike_policy=cls.ike_policy,
ipsec_policy=cls.ipsec_policy,
)
cls.empty_profile = IPSecProfile.objects.create(
name='IPSec Profile 2',
mode=IPSecModeChoices.ESP,
ike_policy=cls.empty_ike_policy,
ipsec_policy=cls.ipsec_policy,
)
def test_related_object_list_attr_direct_accessor(self):
attr = attrs.RelatedObjectListAttr('proposals', linkify=False)
rendered = attr.render(self.ike_policy, {'name': 'proposals'})
self.assertIn('list-unstyled mb-0', rendered)
self.assertInHTML('<li>IKE Proposal 1</li>', rendered)
self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
self.assertInHTML('<li>IKE Proposal 3</li>', rendered)
self.assertEqual(rendered.count('<li'), 3)
def test_related_object_list_attr_related_accessor(self):
attr = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=False)
rendered = attr.render(self.profile, {'name': 'proposals'})
self.assertIn('list-unstyled mb-0', rendered)
self.assertInHTML('<li>IKE Proposal 1</li>', rendered)
self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
self.assertInHTML('<li>IKE Proposal 3</li>', rendered)
self.assertEqual(rendered.count('<li'), 3)
def test_related_object_list_attr_empty_related_accessor(self):
attr = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=False)
self.assertEqual(
attr.render(self.empty_profile, {'name': 'proposals'}),
attr.placeholder,
)
def test_related_object_list_attr_max_items(self):
attr = attrs.RelatedObjectListAttr(
'ike_policy.proposals',
linkify=False,
max_items=2,
overflow_indicator='',
)
rendered = attr.render(self.profile, {'name': 'proposals'})
self.assertInHTML('<li>IKE Proposal 1</li>', rendered)
self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
self.assertNotIn('IKE Proposal 3', rendered)
self.assertIn('', rendered)

View File

@@ -18,7 +18,6 @@ __all__ = (
'NumericAttr',
'ObjectAttribute',
'RelatedObjectAttr',
'RelatedObjectListAttr',
'TemplatedAttr',
'TextAttr',
'TimezoneAttr',
@@ -146,40 +145,22 @@ class ChoiceAttr(ObjectAttribute):
"""
A selection from a set of choices.
The class calls get_FOO_display() on the terminal object resolved by the accessor
to retrieve the human-friendly choice label. For example, accessor="interface.type"
will call interface.get_type_display().
If a get_FOO_color() method exists on that object, it will be used to render a
background color for the attribute value.
The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color()
method exists on the object, it will be used to render a background color for the attribute value.
"""
template_name = 'ui/attrs/choice.html'
def _resolve_target(self, obj):
if not self.accessor or '.' not in self.accessor:
return obj, self.accessor
object_accessor, field_name = self.accessor.rsplit('.', 1)
return resolve_attr_path(obj, object_accessor), field_name
def get_value(self, obj):
target, field_name = self._resolve_target(obj)
if target is None:
return None
display = getattr(target, f'get_{field_name}_display', None)
if callable(display):
return display()
return resolve_attr_path(target, field_name)
try:
return getattr(obj, f'get_{self.accessor}_display')()
except AttributeError:
return resolve_attr_path(obj, self.accessor)
def get_context(self, obj, context):
target, field_name = self._resolve_target(obj)
if target is None:
return {'bg_color': None}
get_color = getattr(target, f'get_{field_name}_color', None)
bg_color = get_color() if callable(get_color) else None
try:
bg_color = getattr(obj, f'get_{self.accessor}_color')()
except AttributeError:
bg_color = None
return {
'bg_color': bg_color,
}
@@ -273,83 +254,6 @@ class RelatedObjectAttr(ObjectAttribute):
}
class RelatedObjectListAttr(RelatedObjectAttr):
"""
An attribute representing a list of related objects.
The accessor may resolve to a related manager or queryset.
Parameters:
max_items (int): Maximum number of items to display
overflow_indicator (str | None): Marker rendered as a final list item when
additional objects exist beyond `max_items`; set to None to suppress it
"""
template_name = 'ui/attrs/object_list.html'
def __init__(self, *args, max_items=None, overflow_indicator='', **kwargs):
super().__init__(*args, **kwargs)
if max_items is not None and (type(max_items) is not int or max_items < 1):
raise ValueError(
_('Invalid max_items value: {max_items}! Must be a positive integer or None.').format(
max_items=max_items
)
)
self.max_items = max_items
self.overflow_indicator = overflow_indicator
def _get_items(self, obj):
"""
Retrieve items from the given object using the accessor path.
Returns a tuple of (items, has_more) where items is a list of resolved objects
and has_more indicates whether additional items exist beyond the max_items limit.
"""
items = resolve_attr_path(obj, self.accessor)
if items is None:
return [], False
if hasattr(items, 'all'):
items = items.all()
if self.max_items is None:
return list(items), False
items = list(items[:self.max_items + 1])
has_more = len(items) > self.max_items
return items[:self.max_items], has_more
def get_context(self, obj, context):
items, has_more = self._get_items(obj)
return {
'linkify': self.linkify,
'items': [
{
'value': item,
'group': getattr(item, self.grouped_by, None) if self.grouped_by else None,
}
for item in items
],
'overflow_indicator': self.overflow_indicator if has_more else None,
}
def render(self, obj, context):
context = context or {}
context_data = self.get_context(obj, context)
if not context_data['items']:
return self.placeholder
return render_to_string(self.template_name, {
'name': context.get('name'),
**context_data,
})
class NestedObjectAttr(ObjectAttribute):
"""
An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the

View File

@@ -23,7 +23,6 @@ __all__ = (
'PluginContentPanel',
'RelatedObjectsPanel',
'TemplatePanel',
'TextCodePanel',
)
@@ -68,7 +67,6 @@ class Panel:
return {
'request': context.get('request'),
'object': context.get('object'),
'perms': context.get('perms'),
'title': self.title,
'actions': self.actions,
'panel_class': self.__class__.__name__,
@@ -330,25 +328,6 @@ class TemplatePanel(Panel):
return render_to_string(self.template_name, context.flatten())
class TextCodePanel(ObjectPanel):
"""
A panel displaying a text field as a pre-formatted code block.
"""
template_name = 'ui/panels/text_code.html'
def __init__(self, field_name, show_sync_warning=False, **kwargs):
super().__init__(**kwargs)
self.field_name = field_name
self.show_sync_warning = show_sync_warning
def get_context(self, context):
return {
**super().get_context(context),
'show_sync_warning': self.show_sync_warning,
'value': getattr(context.get('object'), self.field_name, None),
}
class PluginContentPanel(Panel):
"""
A panel which displays embedded plugin content.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -31,22 +31,22 @@
"gridstack": "12.4.2",
"htmx.org": "2.0.8",
"query-string": "9.3.1",
"sass": "1.98.0",
"sass": "1.97.3",
"tom-select": "2.5.2",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"
},
"devDependencies": {
"@eslint/compat": "^2.0.3",
"@eslint/eslintrc": "^3.3.5",
"@eslint/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.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.57.0",
"@typescript-eslint/parser": "^8.57.0",
"esbuild": "^0.27.4",
"esbuild-sass-plugin": "^3.7.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",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -60,9 +60,7 @@
"@types/bootstrap/**/@popperjs/core": "^2.11.6",
"eslint/**/minimatch": "^3.1.3",
"eslint-plugin-import/**/minimatch": "^3.1.3",
"**/markdown-it": "^14.1.1",
"micromatch/picomatch": "2.3.2",
"tinyglobby/picomatch": "4.0.4"
"**/markdown-it": "^14.1.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -20,7 +20,12 @@ function storeColorMode(mode: ColorMode): void {
}
function updateElements(targetMode: ColorMode): void {
document.documentElement.setAttribute('data-bs-theme', targetMode);
const body = document.querySelector('body');
if (body && targetMode == 'dark') {
body.setAttribute('data-bs-theme', 'dark');
} else if (body) {
body.setAttribute('data-bs-theme', 'light');
}
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const svg = elevation.firstElementChild ?? null;

View File

@@ -1,16 +1,16 @@
import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
import { addClasses } from 'tom-select/src/vanilla.ts';
import queryString from 'query-string';
import TomSelect from 'tom-select';
import type { Stringifiable } from 'query-string';
import { DynamicParamsMap } from './dynamicParamsMap';
import { NetBoxTomSelect } from './netboxTomSelect';
// Transitional
import { QueryFilter, PathFilter } from '../types';
import { getElement, replaceAll } from '../../util';
// Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends NetBoxTomSelect {
// Extends TomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends TomSelect {
public readonly nullOption: Nullable<TomOption> = null;
// Transitional code from APISelect
@@ -71,7 +71,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
this.addEventListeners();
}
load(value: string, preserveValue?: string | string[]) {
load(value: string) {
const self = this;
// Automatically clear any cached options. (Only options included
@@ -107,14 +107,6 @@ export class DynamicTomSelect extends NetBoxTomSelect {
// Pass the options to the callback function
.then(options => {
self.loadCallback(options, []);
// Restore the previous selection if it is still valid under the new filter.
if (preserveValue !== undefined) {
const values = Array.isArray(preserveValue) ? preserveValue : [preserveValue];
const validValues = values.filter(v => v !== '' && v in self.options);
if (validValues.length > 0) {
self.setValue(validValues.length === 1 ? validValues[0] : validValues, true);
}
}
})
.catch(() => {
self.loadCallback([], []);
@@ -346,9 +338,6 @@ export class DynamicTomSelect extends NetBoxTomSelect {
private handleEvent(event: Event): void {
const target = event.target as HTMLSelectElement;
// Save the current selection so we can restore it after loading if it remains valid.
const previousValue = this.getValue();
// Update the element's URL after any changes to a dependency.
this.updateQueryParams(target.name);
this.updatePathValues(target.name);
@@ -356,8 +345,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
// Clear any previous selection(s) as the parent filter has changed
this.clear();
// Load new data, restoring the previous selection if it is still valid under the new filter.
const preserve = previousValue !== '' && previousValue !== null ? previousValue : undefined;
this.load(this.lastValue, preserve);
// Load new data.
this.load(this.lastValue);
}
}

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