Compare commits

..

58 Commits

Author SHA1 Message Date
Jeremy Stretch
6c08941542 Tweak behavior of include_columns 2026-04-01 14:58:41 -04:00
Jeremy Stretch
be1a29d7ee Misc cleanup 2026-04-01 14:46:53 -04:00
Jeremy Stretch
f06f8f3f1d Exclude assigned object columns from IP addresses table on interface views 2026-04-01 14:25:31 -04:00
Jeremy Stretch
a45ec6620a Protect exempt columns from exclusion 2026-04-01 14:17:57 -04:00
Jeremy Stretch
bd35afe320 Apply column hiding before prefetching 2026-04-01 14:14:13 -04:00
Jeremy Stretch
364868a207 Implement exclude_columns on embedded tables 2026-04-01 13:46:59 -04:00
Jeremy Stretch
d4569df305 Closes #21770: Enable including/excluding columns on ObjectsTablePanel 2026-04-01 13:32:42 -04:00
Jeremy Stretch
b62c5e1ac4 Merge branch 'main' into feature 2026-04-01 13:22:52 -04:00
bctiemann
1277bb6138 Merge pull request #21806 from netbox-community/21771-rest-api-add-remove-tags
Closes #21771: Add `add_tags` & `remove_tags` fields for taggable objects
2026-04-01 13:02:19 -04:00
Fabi
e98e5e11a7 Fixes #21784: Fix AttributeError when an AnonymousUser tries to sort a table (#21817) 2026-04-01 18:36:21 +02:00
Johannes Rueschel
3ce2bf75b4 Fixes #21533: Fix missing family/mask_length in API when creating IP-related objects (#21546) 2026-04-01 11:25:00 -05:00
Martin Hauser
b1af9a7218 fix(dcim): Use hasattr check for virtual_circuit_termination (#21811)
Replace direct attribute access with hasattr() to prevent AttributeError
when the virtual_circuit_termination relation doesn't exist on the
object.

Fixes #21808
2026-04-01 18:06:18 +02:00
Artem Kotik
b73f7f7d00 Fixes #21655: Fix duplicate SQL queries on serializing custom fields (#21750)
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
Co-authored-by: Artem Kotik <artem.i.kotik@ringcentral.com>
2026-04-01 09:52:38 -05:00
Martin Hauser
9492b55f4b fix(dcim): Fix Virtual Chassis Member add action context
Fix context variable references in VirtualChassMembersPanel add action
to use 'virtual_chassis' instead of 'object'. Add safe checks for
master_id existence to prevent errors when master is not set.

Fixes #21810
2026-04-01 08:59:39 -04:00
github-actions
2563122352 Update source translation strings 2026-04-01 05:39:05 +00:00
Martin Hauser
0455e14c29 docs(plugins): Use @register_search in plugin search docs
Align the plugin search example with the recommended registration
pattern used in the general search documentation and NetBox core.

Replace the legacy `indexes = [...]` example with decorator-based
registration to make the preferred approach clearer for plugin authors.
2026-03-31 16:55:27 -04:00
Jeremy Stretch
76c02d5aa9 Raise a validation error if the same tag is present in both add_tags and remove_tags 2026-03-31 16:44:37 -04:00
Jeremy Stretch
8bc691099c Raise a validation error if remove_tags is specified when creating an object 2026-03-31 16:38:15 -04:00
Jeremy Stretch
95011821bb Closes #21771: Add add_tags & remove_tags fields for taggable objects 2026-03-31 16:02:32 -04:00
bctiemann
b8b12f3f90 #20923 - Convert extras to new declarative UI layout (#21765) 2026-03-31 20:28:16 +02:00
Jeremy Stretch
e5b9e5a279 Closes #19025: Add schema validation for JSON custom fields (#21746) 2026-03-31 12:41:49 -05:00
Jeremy Stretch
05059f4a86 Release v4.5.6 2026-03-31 12:43:26 -04:00
Martin Hauser
e4e4c1c56d feat(dcim): Add 50G, 800G, and 1.6T interface speed options (#21796)
Adds support for 50 Gbps, 800 Gbps, and 1.6 Tbps interface speeds to
the InterfaceSpeedChoices to cover newer high-speed networking hardware.
2026-03-31 14:33:23 +02:00
Martin Hauser
c99d8481b2 refactor(ui): Improve object change diff styling and layout
Update change data diff styling with CSS custom properties, better color
contrast, and consistent borders. Replace btn-group with card-actions
for navigation buttons and improve spacing.
2026-03-31 08:26:01 -04:00
Martin Hauser
0923a3dec8 fix(tables): Disable ordering on non-orderable accessor columns
Mark provider, member, and action_object columns as non-orderable since
they use complex accessors that cannot be sorted. Add regression tests
to verify all orderable columns render without exceptions.

Fixes table rendering errors when attempting to sort columns with
multi-level field accessors that don't support database ordering.
2026-03-31 08:18:36 -04:00
Martin Hauser
80b9c25674 feat(dcim): Add 2.5GE SFP interface type (#21794)
Add the `SFP (2.5GE)` interface type for devices with dedicated 2.5G SFP
slots that do not fit the existing SFP or SFP+ options.
2026-03-31 14:09:44 +02:00
github-actions
6d13bc8b96 Update source translation strings 2026-03-31 05:31:31 +00:00
Jeremy Stretch
ee17e83da6 Update CLAUDE.md (#21777) 2026-03-30 16:33:10 -05:00
Jeremy Stretch
5ab9608e38 Revert "Fixes #21747: Skip search caching when encountering an invalid schema during migrations (#21748)" (#21787)
This reverts commit 296b89ae02.
2026-03-30 23:31:41 +02:00
bctiemann
e54ed87863 Merge pull request #21778 from netbox-community/21763-m2m-form-fields
Fixes #21763: Replace M2M selection field with separate add/remove fields
2026-03-30 11:23:36 -04:00
Jeremy Stretch
55daf4c52f Add/fix tests 2026-03-30 10:02:38 -04:00
Jeremy Stretch
a45e8571da Revert changes to ASNForm 2026-03-30 09:29:08 -04:00
Jeremy Stretch
0154a09856 Limit 'add' field choices to objects not already assigned 2026-03-30 09:22:56 -04:00
Jeremy Stretch
757c4f69d2 Annotate current number of assignments if >100 2026-03-30 09:15:35 -04:00
Jeremy Stretch
d5f37d7a87 Use add/remove fields only when assignment count is 100+ 2026-03-30 09:07:15 -04:00
Jeremy Stretch
f30786d8fe Fixes #21763: Replace M2M selection field with separate add/remove fields 2026-03-27 16:45:36 -04:00
github-actions
bb73601d80 Update source translation strings 2026-03-27 05:31:05 +00:00
Arthur Hanson
99e9d96787 #20923: Migrate IPAM views to declarative layouts (#21695)
* #20923: Migrate IPAM views to declarative layouts

* #20923: Migrate IPAM views to declarative layouts

* fix VRF view

* fix Route Target view

* fix addressing details modal

* fix add prefix button

* fix add aggregate button

* fix add VLAN button

* fix breadcrumb on Application Service

* fix breadcrumb on ANS

* move attrs to separate file

* review feedback

* review feedback

* review feedback

* review feedback
2026-03-26 16:55:12 -04:00
bctiemann
f5c97e367c Merge pull request #21754 from netbox-community/20923-core-ui-layouts
#20923: Migrate core app to the new UI layouts
2026-03-26 13:53:20 -04:00
Arthur Hanson
ea756b29e9 #20923 - Convert tenancy to new UI layout (#21745) 2026-03-26 17:16:31 +01:00
Jeremy Stretch
b929e1aa1b Fixes #21747: Skip search caching when encountering an invalid schema during migrations (#21748) 2026-03-26 09:13:28 -07:00
github-actions
91d5382a61 Update source translation strings 2026-03-26 05:30:51 +00:00
Mark Robert Coleman
e76203238d Fix {module} placeholder resolution in module bay position field (#21752)
* Fix {module} placeholder resolution in module bay position field (#20467)

The {module} placeholder in ModuleBayTemplate's position field was not
being resolved when a module was installed, leaving the literal string
"{module}" in the position. This adds a resolve_position() method and
calls it in instantiate(), consistent with how resolve_name() and
resolve_label() already work.

Consolidates the shared resolution logic into _resolve_module_placeholder()
to eliminate duplication across resolve_name, resolve_label, and the new
resolve_position.

Fixes: #20467

* Move resolve_position() to ModuleBayTemplate

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-03-25 15:45:49 -04:00
Jeremy Stretch
3f58648115 Convert DataFileView to a single-column layout 2026-03-25 13:55:07 -04:00
Jeremy Stretch
b904dc5c75 Support translation of headings for embedded table panels 2026-03-25 13:50:41 -04:00
Jeremy Stretch
bf27ff9593 #20923: Initial work on migrating the core app 2026-03-25 12:57:10 -04:00
Martin Hauser
981f31304d Closes #21735: Replace deprecated Strawberry scalar for BigInt (#21736) 2026-03-25 09:36:30 -05:00
Martin Hauser
2a39ab47d6 feat(circuits): Add UI layout panels for circuits app
Implement comprehensive UI panel layouts for all circuit models using
the new panel system. Add panels for providers, circuits, terminations,
groups, and virtual circuits with proper attribute rendering and
actions.
2026-03-25 10:19:26 -04:00
Jeremy Stretch
aa01c16db0 #20923: Migrate remaining DCIM views to new UI layouts (#21706) 2026-03-25 09:08:54 -05:00
github-actions
e04986617c Update source translation strings 2026-03-25 05:28:00 +00:00
bctiemann
83cf193cdc Merge pull request #21680 from netbox-community/21664-update-github-actions-for-nodejs-24-compatibility
Closes #21664: Update and pin GitHub Actions for Node 24 compatibility
2026-03-24 14:34:57 -04:00
bctiemann
d497198f49 Merge pull request #21721 from netbox-community/21698-custom-field-url-filter-is-too-restrictive-for-weird-ports
Fixes #21698: Fix validation of custom field URLs with single-digit ports
2026-03-24 14:25:00 -04:00
pobradovic08
4e479c547f Closes #21480: Add 1.6T Ethernet interface types (#21723)
Add support for IEEE 802.3dj 1.6T fixed interface types and
published 1.6T pluggable form factors.

This adds 1.6TBASE-CR8, 1.6TBASE-KR8, 1.6TBASE-DR8, and
1.6TBASE-DR8-2, plus OSFP1600, OSFP1600-RHS, and QSFP-DD1600
transceiver types.
2026-03-24 10:51:26 +01:00
github-actions
e44c0a2119 Update source translation strings 2026-03-24 05:27:47 +00:00
Martin Hauser
3ab0613708 fix(circuits): Add ProviderAccount fieldsets (#21708) 2026-03-23 16:07:20 -07:00
Martin Hauser
9f16734266 fix(utilities): Allow single-digit port numbers in URL validator
Change port number regex from `\d{2,5}` to `\d{1,5}` to permit valid
single-digit ports (1-9). This aligns with RFC 3986 and fixes
validation for URLs using ports like :8 or :9.

Fixes #21698
2026-03-20 13:40:40 +01:00
Martin Hauser
268ef4f59f chore(ci): Pin CodeQL action to commit SHA
Pin GitHub/codeql-action references to full commit SHA v4.33.0 instead
of version tag to reduce supply chain risk from tag retargeting.
2026-03-16 15:14:23 +01:00
Martin Hauser
671b1cd470 chore(ci): Pin GitHub Actions to commit SHAs
Pin GitHub Actions references to full commit SHAs instead of version
tags to reduce supply chain risk from tag retargeting.

Update actions/checkout to v6.0.2, actions/setup-python to v6.2.0,
actions/setup-node to v6.3.0, actions/stale to v10.2.0, and
dessant/lock-threads to v6.0.0.
2026-03-16 14:35:51 +01:00
289 changed files with 24834 additions and 31749 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.5.5
placeholder: v4.5.6
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.5
placeholder: v4.5.6
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.5
placeholder: v4.5.6
validations:
required: true
- type: dropdown

View File

@@ -53,7 +53,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
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@v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node-version }}
cache: yarn

View File

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

View File

@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1

View File

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

View File

@@ -27,16 +27,16 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
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@v4
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: "/language:${{matrix.language}}"

View File

@@ -19,6 +19,6 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6.0.0
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
discussion-inactive-days: 180

View File

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

View File

@@ -54,7 +54,8 @@ python manage.py nbshell # NetBox-enhanced shell
## Architecture Conventions
- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`.
- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
- **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
- **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
- **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
@@ -68,6 +69,8 @@ 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,9 +416,13 @@
"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",
@@ -448,6 +452,9 @@
"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",
@@ -459,6 +466,7 @@
"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

@@ -20,9 +20,7 @@ There are four core actions that can be permitted for each type of object within
* **Change** - Modify an existing object
* **Delete** - Delete an existing object
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
Some models have registered custom actions that appear as checkboxes when creating or editing a permission. These are grouped by model under "Custom actions" in the permission form. Additional custom actions (such as those not yet registered or for backwards compatibility) can be entered manually in the "Additional actions" field.
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
!!! note
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.

View File

@@ -63,6 +63,7 @@ NetBox supports limited custom validation for custom field values. Following are
* Text: Regular expression (optional)
* Integer: Minimum and/or maximum value (optional)
* Selection: Must exactly match one of the prescribed choices
* JSON: Must adhere to the defined validation schema (if any)
### Custom Selection Fields

View File

@@ -118,3 +118,7 @@ For numeric custom fields only. The maximum valid value (optional).
### Validation Regex
For string-based custom fields only. A regular expression used to validate the field's value (optional).
### Validation Schema
For JSON custom fields, users have the option of defining a [validation schema](https://json-schema.org). Any value applied to this custom field on a model will be validated against the provided schema, if any.

View File

@@ -1,36 +0,0 @@
# Custom Model Actions
Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
## Registering Model Actions
To register custom actions for a model, call `register_model_actions()` in your plugin's `ready()` method:
```python
# __init__.py
from netbox.plugins import PluginConfig
class MyPluginConfig(PluginConfig):
name = 'my_plugin'
# ...
def ready(self):
super().ready()
from utilities.permissions import ModelAction, register_model_actions
from .models import MyModel
register_model_actions(MyModel, [
ModelAction('sync', help_text='Synchronize data from external source'),
ModelAction('export', help_text='Export data to external system'),
])
config = MyPluginConfig
```
Once registered, these actions will appear grouped under your model's name when creating or editing an ObjectPermission that includes your model as an object type.
::: utilities.permissions.ModelAction
::: utilities.permissions.register_model_actions

View File

@@ -1,12 +1,14 @@
# 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 (see the example below).
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.
```python
```python title="search.py"
# search.py
from netbox.search import SearchIndex
from netbox.search import SearchIndex, register_search
from .models import MyModel
@register_search
class MyModelIndex(SearchIndex):
model = MyModel
fields = (
@@ -17,15 +19,11 @@ class MyModelIndex(SearchIndex):
display_attrs = ('site', 'device', 'status', 'description')
```
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.
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.
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
```python
indexes = [MyModelIndex]
```
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.
!!! tip
The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
The legacy `indexes = [...]` list remains supported via `PluginConfig.search_indexes` for backward compatibility and custom loading patterns.
::: netbox.search.SearchIndex

View File

@@ -1,5 +1,24 @@
# NetBox v4.5
## 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

View File

@@ -152,7 +152,6 @@ nav:
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md'
- Permissions: 'plugins/development/permissions.md'
- Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
- User Interface: 'plugins/development/user-interface.md'

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
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
@@ -48,17 +48,42 @@ 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', 'asns', 'description', 'tags'),
FieldSet('name', 'slug', M2MAddRemoveFields('asns'), 'description', 'tags'),
)
class Meta:
model = Provider
fields = [
'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
'name', 'slug', '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(
@@ -68,10 +93,14 @@ class ProviderAccountForm(PrimaryModelForm):
quick_add=True
)
fieldsets = (
FieldSet('provider', 'account', 'name', 'description', 'tags'),
)
class Meta:
model = ProviderAccount
fields = [
'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
'provider', 'account', 'name', 'description', 'owner', 'comments', 'tags',
]

View File

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

View File

@@ -1,23 +1,48 @@
from django.test import RequestFactory, TestCase, tag
from circuits.models import CircuitTermination
from circuits.tables import CircuitTerminationTable
from circuits.models import CircuitGroupAssignment, CircuitTermination
from circuits.tables import CircuitGroupAssignmentTable, 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 dir in ('-', ''):
for direction in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{dir}{col}'
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.as_html(fake_request)

View File

View File

@@ -0,0 +1,139 @@
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,13 +1,23 @@
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
@@ -29,6 +39,37 @@ 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},
exclude_columns=['provider'],
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},
exclude_columns=['provider'],
actions=[
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -44,7 +85,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
'provider_id',
),
),
),
),
}
@@ -108,6 +149,33 @@ 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},
exclude_columns=['provider_account'],
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 {
@@ -174,6 +242,33 @@ 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},
exclude_columns=['provider_network'],
actions=[
actions.AddObject(
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
),
],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -251,6 +346,17 @@ 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 {
@@ -318,6 +424,20 @@ 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)
@@ -390,6 +510,18 @@ 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)
@@ -446,6 +578,17 @@ 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 {
@@ -508,6 +651,15 @@ 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)
@@ -560,6 +712,17 @@ 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 {
@@ -627,6 +790,31 @@ 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},
exclude_columns=['virtual_circuit'],
actions=[
actions.AddObject(
'circuits.VirtualCircuitTermination',
url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
),
],
),
],
)
@register_model_view(VirtualCircuit, 'add', detail=False)
@@ -698,6 +886,16 @@ 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

@@ -1,17 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-31 21:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0021_job_queue_name'),
]
operations = [
migrations.AlterModelOptions(
name='datasource',
options={'ordering': ('name',), 'permissions': [('sync', 'Synchronize data from remote source')]},
),
]

View File

@@ -87,9 +87,6 @@ class DataSource(JobsMixin, PrimaryModel):
ordering = ('name',)
verbose_name = _('data source')
verbose_name_plural = _('data sources')
permissions = [
('sync', 'Synchronize data from remote source'),
]
def __str__(self):
return f'{self.name}'

View File

91
netbox/core/ui/panels.py Normal file
View File

@@ -0,0 +1,91 @@
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

@@ -23,9 +23,20 @@ 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
@@ -48,6 +59,7 @@ 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
@@ -67,6 +79,25 @@ 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},
exclude_columns=['source'],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -157,6 +188,14 @@ 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')
@@ -188,6 +227,17 @@ 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')
@@ -200,6 +250,13 @@ 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)
@@ -241,6 +298,26 @@ 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()
@@ -309,6 +386,14 @@ 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):
"""

View File

@@ -1003,10 +1003,16 @@ 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'
@@ -1034,8 +1040,11 @@ 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'
TYPE_800GE_OSFP = '800gbase-x-osfp'
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'
# Backplane Ethernet
TYPE_1GE_KX = '1000base-kx'
@@ -1049,6 +1058,7 @@ 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'
@@ -1298,12 +1308,21 @@ 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)'),
@@ -1333,6 +1352,9 @@ 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)'),
)
),
(
@@ -1349,6 +1371,7 @@ 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)'),
)
),
(
@@ -1495,9 +1518,12 @@ 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'),
]

View File

@@ -23,7 +23,7 @@ from utilities.forms.fields import (
NumericArrayField,
SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
from utilities.forms.widgets import (
APISelect,
ClearableFileInput,
@@ -144,6 +144,16 @@ 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'),
@@ -153,7 +163,8 @@ class SiteForm(TenancyForm, PrimaryModelForm):
fieldsets = (
FieldSet(
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
'name', 'slug', 'status', 'region', 'group', 'facility', M2MAddRemoveFields('asns'), 'time_zone',
'description', 'tags',
name=_('Site')
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -163,7 +174,7 @@ class SiteForm(TenancyForm, PrimaryModelForm):
class Meta:
model = Site
fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags',
)
widgets = {
@@ -179,6 +190,21 @@ 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

@@ -22,17 +22,21 @@ def load_initial_data(apps, schema_editor):
'power_supply',
'expansion_card'
)
profile_objects = []
for name in initial_profiles:
file_path = DATA_FILES_PATH / f'{name}.json'
with file_path.open('r') as f:
data = json.load(f)
try:
ModuleTypeProfile.objects.using(db_alias).create(**data)
profile = ModuleTypeProfile(**data)
profile_objects.append(profile)
except Exception as e:
print(f"Error loading data from {file_path}")
raise e
ModuleTypeProfile.objects.using(db_alias).bulk_create(profile_objects)
class Migration(migrations.Migration):

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-31 21:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0230_interface_rf_channel_frequency_precision'),
]
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render device configuration')]},
),
]

View File

@@ -197,42 +197,21 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
modules.reverse()
return modules
def resolve_name(self, module=None, device=None):
has_module = MODULE_TOKEN in self.name
has_vc = VC_POSITION_RE.search(self.name) is not None
if not has_module and not has_vc:
return self.name
name = self.name
if has_module and module:
def _resolve_module_placeholder(self, value, module=None, device=None):
if MODULE_TOKEN in value and module:
modules = self._get_module_tree(module)
for m in modules:
name = name.replace(MODULE_TOKEN, m.module_bay.position, 1)
if has_vc:
value = value.replace(MODULE_TOKEN, m.module_bay.position, 1)
if VC_POSITION_RE.search(value) is not None:
resolved_device = (module.device if module else None) or device
name = self._resolve_vc_position(name, resolved_device)
value = self._resolve_vc_position(value, resolved_device)
return value
return name
def resolve_name(self, module=None, device=None):
return self._resolve_module_placeholder(self.name, module, device)
def resolve_label(self, module=None, device=None):
has_module = MODULE_TOKEN in self.label
has_vc = VC_POSITION_RE.search(self.label) is not None
if not has_module and not has_vc:
return self.label
label = self.label
if has_module and module:
modules = self._get_module_tree(module)
for m in modules:
label = label.replace(MODULE_TOKEN, m.module_bay.position, 1)
if has_vc:
resolved_device = (module.device if module else None) or device
label = self._resolve_vc_position(label, resolved_device)
return label
return self._resolve_module_placeholder(self.label, module, device)
class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -766,11 +745,14 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates')
def resolve_position(self, module):
return self._resolve_module_placeholder(self.position, module)
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
position=self.position,
position=self.resolve_position(kwargs.get('module')),
enabled=self.enabled,
**kwargs
)

View File

@@ -759,9 +759,6 @@ class Device(
)
verbose_name = _('device')
verbose_name_plural = _('devices')
permissions = [
('render_config', 'Render device configuration'),
]
def __str__(self):
if self.label and self.asset_tag:

View File

@@ -5,16 +5,17 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework import status
from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from extras.models import ConfigTemplate, Tag
from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from users.constants import TOKEN_PREFIX
from users.models import Token, User
from users.models import ObjectPermission, Token, User
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
@@ -195,6 +196,222 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
},
]
def test_add_tags(self):
"""
Add tags to an existing object via the add_tags field.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set([tags[0], tags[1]])
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'add_tags': [{'name': 'Charlie'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify all three tags are now assigned
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo', 'Charlie'])
# Verify add_tags and remove_tags are not in the response
self.assertNotIn('add_tags', response.data)
self.assertNotIn('remove_tags', response.data)
self.assertIn('tags', response.data)
def test_remove_tags(self):
"""
Remove tags from an existing object via the remove_tags field.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set(tags)
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'remove_tags': [{'name': 'Charlie'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify only Alpha and Bravo remain
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
def test_remove_tags_not_assigned(self):
"""
Removing a tag that is not assigned should not raise an error.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set([tags[0], tags[1]])
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'remove_tags': [{'name': 'Charlie'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Tags should be unchanged
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
def test_add_and_remove_tags(self):
"""
Add and remove tags in the same request.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set([tags[0], tags[1]])
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'add_tags': [{'name': 'Charlie'}],
'remove_tags': [{'name': 'Alpha'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify Bravo and Charlie remain
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Bravo', 'Charlie'])
def test_tags_with_add_tags_error(self):
"""
Specifying tags together with add_tags or remove_tags should raise a validation error.
"""
site = Site.objects.first()
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
))
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'tags': [{'name': 'Alpha'}],
'add_tags': [{'name': 'Bravo'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_create_with_add_tags(self):
"""
Create a new object using add_tags.
"""
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
))
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
data = {
'name': 'Site 10',
'slug': 'site-10',
'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
site = Site.objects.get(pk=response.data['id'])
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
def test_create_with_remove_tags_error(self):
"""
Using remove_tags when creating a new object should raise a validation error.
"""
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
))
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
data = {
'name': 'Site 10',
'slug': 'site-10',
'remove_tags': [{'name': 'Alpha'}],
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_add_and_remove_same_tag_error(self):
"""
Including the same tag in both add_tags and remove_tags should raise a validation error.
"""
site = Site.objects.first()
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
))
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
'remove_tags': [{'name': 'Alpha'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class LocationTest(APIViewTestCases.APIViewTestCase):
model = Location

View File

@@ -10,8 +10,9 @@ from dcim.choices import (
)
from dcim.forms import *
from dcim.models import *
from ipam.models import VLAN
from ipam.models import ASN, RIR, VLAN
from utilities.exceptions import AbortRequest
from utilities.forms.rendering import M2MAddRemoveFields
from utilities.testing import create_test_device
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -500,3 +501,111 @@ 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

@@ -955,6 +955,50 @@ 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') # #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"""

View File

@@ -1,6 +1,8 @@
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 attrs, panels
from netbox.ui import actions, attrs, panels
class SitePanel(panels.ObjectAttributesPanel):
@@ -191,16 +193,261 @@ class PlatformPanel(panels.NestedGroupObjectPanel):
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
class VirtualChassisMembersPanel(panels.ObjectPanel):
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')
bundle = attrs.RelatedObjectAttr('bundle', linkify=True)
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 lists all members of a virtual chassis.
A panel which displays connection information for a cabled object.
"""
template_name = 'dcim/panels/virtual_chassis_members.html'
title = _('Virtual Chassis Members')
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'),
}
@@ -228,3 +475,106 @@ 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

@@ -17,10 +17,12 @@ 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,
@@ -256,6 +258,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.Region',
title=_('Child Regions'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -388,6 +391,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.SiteGroup',
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -538,6 +542,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='dcim.Location',
filters={'site_id': lambda ctx: ctx['object'].pk},
exclude_columns=['site'],
actions=[
actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}),
],
@@ -550,6 +555,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
},
exclude_columns=['site'],
actions=[
actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}),
],
@@ -672,6 +678,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.Location',
title=_('Child Locations'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject(
'dcim.Location',
@@ -690,6 +697,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
},
exclude_columns=['location'],
actions=[
actions.AddObject(
'dcim.Device',
@@ -1664,7 +1672,7 @@ class ModuleTypeProfileListView(generic.ObjectListView):
@register_model_view(ModuleTypeProfile)
class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
class ModuleTypeProfileView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = ModuleTypeProfile.objects.all()
layout = layout.SimpleLayout(
@@ -1684,6 +1692,7 @@ class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
filters={
'profile_id': lambda ctx: ctx['object'].pk,
},
exclude_columns=['profile'],
actions=[
actions.AddObject(
'dcim.ModuleType',
@@ -2425,6 +2434,7 @@ class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.DeviceRole',
title=_('Child Device Roles'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.DeviceRole', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -2525,6 +2535,7 @@ class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.Platform',
title=_('Child Platforms'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.Platform', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -2603,6 +2614,7 @@ class DeviceView(generic.ObjectView):
ObjectsTablePanel(
model='dcim.VirtualDeviceContext',
filters={'device_id': lambda ctx: ctx['object'].pk},
exclude_columns=['device'],
actions=[
actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}),
],
@@ -2615,6 +2627,7 @@ class DeviceView(generic.ObjectView):
model='ipam.Service',
title=_('Application Services'),
filters={'device_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject(
'ipam.Service',
@@ -2642,6 +2655,7 @@ class DeviceView(generic.ObjectView):
vc_members = []
return {
'virtual_chassis': instance.virtual_chassis,
'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}',
}
@@ -2994,6 +3008,28 @@ 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)
@@ -3065,6 +3101,24 @@ 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)
@@ -3136,6 +3190,23 @@ 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)
@@ -3207,6 +3278,22 @@ 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)
@@ -3278,6 +3365,47 @@ 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'),
exclude_columns=['assigned', 'assigned_object', 'assigned_object_parent'],
),
ObjectsTablePanel(
model='dcim.MACAddress',
filters={'interface_id': lambda ctx: ctx['object'].pk},
title=_('MAC Addresses'),
exclude_columns=['assigned_object', 'assigned_object_parent'],
),
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
@@ -3292,30 +3420,29 @@ 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(
bridge_interfaces,
Interface.objects.restrict(request.user, 'view').filter(bridge=instance),
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(
child_interfaces,
Interface.objects.restrict(request.user, 'view').filter(parent=instance),
exclude=('device', 'parent'),
orderable=False
)
child_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 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 VLAN translation rules
vlan_translation_table = None
@@ -3328,7 +3455,6 @@ 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,
@@ -3416,6 +3542,33 @@ 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 {
@@ -3492,6 +3645,31 @@ 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 {
@@ -3568,6 +3746,19 @@ 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)
@@ -3630,6 +3821,19 @@ 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)
@@ -3773,6 +3977,13 @@ 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')
@@ -3854,12 +4065,23 @@ class InventoryItemRoleListView(generic.ObjectListView):
@register_model_view(InventoryItemRole)
class InventoryItemRoleView(generic.ObjectView):
class InventoryItemRoleView(GetRelatedModelsMixin, 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 {
'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(),
'related_models': self.get_related_models(request, instance),
}
@@ -4093,6 +4315,24 @@ 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)
@@ -4225,12 +4465,23 @@ 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):
members = Device.objects.restrict(request.user).filter(virtual_chassis=instance)
vc_members = Device.objects.restrict(request.user).filter(virtual_chassis=instance).order_by('vc_position')
return {
'members': members,
'virtual_chassis': instance,
'vc_members': vc_members,
}
@@ -4470,6 +4721,27 @@ 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 {
@@ -4533,6 +4805,23 @@ 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)
@@ -4601,6 +4890,23 @@ 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 {
@@ -4669,6 +4975,16 @@ 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 ValidationError
from rest_framework.serializers import ListSerializer, ValidationError
from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
@@ -49,8 +49,25 @@ 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():
value = cf.deserialize(obj.get(cf.name))
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))
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
@@ -87,3 +104,32 @@ 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

@@ -65,8 +65,8 @@ class CustomFieldSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedMod
'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type',
'name', 'label', 'group_name', 'description', 'required', 'unique', 'search_weight', 'filter_logic',
'ui_visible', 'ui_editable', 'is_cloneable', 'default', 'related_object_filter', 'weight',
'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'owner', 'comments',
'created', 'last_updated',
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', 'choice_set',
'owner', 'comments', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -7,7 +7,7 @@ from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelBulkEditForm, PrimaryModelBulkEditForm
from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, JSONField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
@@ -88,14 +88,21 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
label=_('Validation regex'),
required=False
)
validation_schema = JSONField(
label=_('Validation schema'),
required=False
)
comments = CommentField()
fieldsets = (
FieldSet('group_name', 'description', 'weight', 'required', 'unique', 'choice_set', name=_('Attributes')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
FieldSet(
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema',
name=_('Validation')
),
)
nullable_fields = ('group_name', 'description', 'choice_set')
nullable_fields = ('group_name', 'description', 'choice_set', 'validation_schema')
class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):

View File

@@ -80,7 +80,8 @@ class CustomFieldImportForm(OwnerCSVMixin, CSVModelForm):
fields = (
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'unique',
'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', 'owner', 'comments',
'validation_maximum', 'validation_regex', 'validation_schema', 'ui_visible', 'ui_editable',
'is_cloneable', 'owner', 'comments',
)

View File

@@ -76,6 +76,11 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all()
)
validation_schema = JSONField(
label=_('Validation schema'),
required=False,
help_text=_('A JSON schema definition for validating the custom field value')
)
comments = CommentField()
fieldsets = (
@@ -144,6 +149,16 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
del self.fields['validation_minimum']
del self.fields['validation_maximum']
# Adjust for JSON fields
if field_type == CustomFieldTypeChoices.TYPE_JSON:
self.fieldsets = (
self.fieldsets[0],
FieldSet('validation_schema', name=_('Validation')),
*self.fieldsets[1:]
)
else:
del self.fields['validation_schema']
# Adjust for object & multi-object fields
if field_type in (
CustomFieldTypeChoices.TYPE_OBJECT,

View File

@@ -0,0 +1,24 @@
from django.db import migrations, models
import utilities.jsonschema
class Migration(migrations.Migration):
dependencies = [
('extras', '0135_configtemplate_debug'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='validation_schema',
field=models.JSONField(
blank=True,
help_text='A JSON schema definition for validating the custom field value',
null=True,
validators=[utilities.jsonschema.validate_schema],
verbose_name='validation schema',
),
),
]

View File

@@ -4,6 +4,7 @@ import re
from datetime import date, datetime
import django_filters
import jsonschema
from django import forms
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
@@ -15,6 +16,7 @@ from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from core.models import ObjectType
from extras.choices import *
@@ -40,6 +42,7 @@ from utilities.forms.fields import (
)
from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.jsonschema import validate_schema
from utilities.querysets import RestrictedQuerySet
from utilities.templatetags.builtins.filters import render_markdown
from utilities.validators import validate_regex
@@ -74,7 +77,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)
custom_fields = self.get_queryset().filter(object_types=content_type).select_related('related_object_type')
# Populate the request cache to avoid redundant lookups
if cache is not None:
@@ -222,6 +225,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
)
)
validation_schema = models.JSONField(
blank=True,
null=True,
validators=[validate_schema],
verbose_name=_('validation schema'),
help_text=_('A JSON schema definition for validating the custom field value')
)
choice_set = models.ForeignKey(
to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
@@ -259,7 +269,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
clone_fields = (
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'unique',
'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
'validation_regex', 'validation_schema', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
)
class Meta:
@@ -389,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
'validation_regex': _("Regular expression validation is supported only for text and URL fields")
})
# Schema validation can be set only for JSON fields
if self.validation_schema and self.type != CustomFieldTypeChoices.TYPE_JSON:
raise ValidationError({
'validation_schema': _("JSON schema validation is supported only for JSON fields")
})
# Uniqueness can not be enforced for boolean fields
if self.unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
raise ValidationError({
@@ -815,6 +831,16 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
if type(id) is not int:
raise ValidationError(_("Found invalid object ID: {id}").format(id=id))
# Validate JSON against schema (if defined)
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
if self.validation_schema:
try:
jsonschema.validate(value, schema=self.validation_schema)
except JSONValidationError as e:
raise ValidationError(
_("Value does not conform to the assigned schema: {error}").format(error=e.message)
)
elif self.required:
raise ValidationError(_("Required field cannot be empty."))

View File

@@ -121,6 +121,10 @@ class CustomFieldTable(NetBoxTable):
validation_regex = tables.Column(
verbose_name=_('Validation Regex'),
)
validation_schema = columns.BooleanColumn(
verbose_name=_('Validation Schema'),
false_mark=None,
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
@@ -132,7 +136,7 @@ class CustomFieldTable(NetBoxTable):
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
'unique', 'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
'is_cloneable', 'weight', 'choice_set', 'choices', 'validation_minimum', 'validation_maximum',
'validation_regex', 'comments', 'created', 'last_updated',
'validation_regex', 'validation_schema', 'comments', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'unique', 'description',
@@ -510,8 +514,9 @@ class EventRuleTable(NetBoxTable):
verbose_name=_('Type'),
)
action_object = tables.Column(
linkify=True,
verbose_name=_('Object'),
orderable=False,
linkify=True,
)
object_types = columns.ContentTypesColumn(
verbose_name=_('Object Types'),

View File

@@ -655,6 +655,45 @@ class CustomFieldTest(TestCase):
default=["xxx"]
).full_clean()
def test_validation_schema_only_for_json_type(self):
schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
},
}
# Valid: schema on a JSON field
CustomField(name='test', type=CustomFieldTypeChoices.TYPE_JSON, validation_schema=schema).full_clean()
# Invalid: schema on a non-JSON field
with self.assertRaises(ValidationError):
CustomField(name='test', type=CustomFieldTypeChoices.TYPE_TEXT, validation_schema=schema).full_clean()
with self.assertRaises(ValidationError):
CustomField(name='test', type=CustomFieldTypeChoices.TYPE_INTEGER, validation_schema=schema).full_clean()
def test_json_schema_default_validation(self):
schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
},
'required': ['name'],
}
# Valid default
CustomField(
name='test', type=CustomFieldTypeChoices.TYPE_JSON,
validation_schema=schema, default={'name': 'test'}
).full_clean()
# Invalid default (missing required 'name')
with self.assertRaises(ValidationError):
CustomField(
name='test', type=CustomFieldTypeChoices.TYPE_JSON,
validation_schema=schema, default={'age': 25}
).full_clean()
class CustomFieldManagerTest(TestCase):
@@ -1322,6 +1361,42 @@ class CustomFieldAPITest(APITestCase):
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_json_schema_validation(self):
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
cf_json = CustomField.objects.get(name='json_field')
cf_json.validation_schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'age': {'type': 'integer'},
},
'required': ['name'],
}
cf_json.save()
# Invalid: missing required 'name' property
data = {'custom_fields': {'json_field': {'age': 25}}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Invalid: 'age' is not an integer
data = {'custom_fields': {'json_field': {'name': 'test', 'age': 'not_an_int'}}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Valid: conforms to schema
data = {'custom_fields': {'json_field': {'name': 'test', 'age': 25}}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Valid: null value (schema not enforced on empty)
data = {'custom_fields': {'json_field': None}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_uniqueness_validation(self):
# Create a unique custom field
cf_text = CustomField.objects.get(name='text_field')

View File

@@ -22,7 +22,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet
ignore_fields = ('default', 'related_object_filter')
ignore_fields = ('default', 'related_object_filter', 'validation_schema')
@classmethod
def setUpTestData(cls):

View File

@@ -0,0 +1,24 @@
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,16 +2,55 @@ 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, panels
from netbox.ui import actions, attrs, 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.
@@ -73,3 +112,404 @@ 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'))
debug = attrs.BooleanAttr('debug')
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 as _
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from core.choices import ManagedFileRootPathChoices
@@ -22,6 +22,14 @@ 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
@@ -39,6 +47,7 @@ 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
@@ -56,6 +65,18 @@ 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 = ()
@@ -127,6 +148,14 @@ 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):
@@ -202,6 +231,16 @@ 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)
@@ -259,6 +298,19 @@ 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)
@@ -320,6 +372,15 @@ 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)
@@ -382,6 +443,15 @@ 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([])
@@ -475,6 +545,15 @@ 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)
@@ -659,6 +738,19 @@ 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)
@@ -715,6 +807,19 @@ 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)
@@ -773,6 +878,18 @@ 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)
@@ -852,6 +969,18 @@ 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)
@@ -914,6 +1043,16 @@ 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
@@ -1033,6 +1172,18 @@ 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)
@@ -1151,6 +1302,17 @@ 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)
@@ -1215,6 +1377,16 @@ 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

@@ -159,9 +159,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
@property
def family(self):
if self.prefix:
return self.prefix.version
return None
if not self.prefix:
return None
if isinstance(self.prefix, str):
return netaddr.IPNetwork(self.prefix).version
return self.prefix.version
@property
def ipv6_full(self):
@@ -335,11 +337,19 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
@property
def family(self):
return self.prefix.version if self.prefix else None
if not self.prefix:
return None
if isinstance(self.prefix, str):
return netaddr.IPNetwork(self.prefix).version
return self.prefix.version
@property
def mask_length(self):
return self.prefix.prefixlen if self.prefix else None
if not self.prefix:
return None
if isinstance(self.prefix, str):
return netaddr.IPNetwork(self.prefix).prefixlen
return self.prefix.prefixlen
@property
def ipv6_full(self):
@@ -367,6 +377,16 @@ 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.
@@ -632,7 +652,11 @@ class IPRange(ContactsMixin, PrimaryModel):
@property
def family(self):
return self.start_address.version if self.start_address else None
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
@property
def range(self):
@@ -980,9 +1004,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
@property
def family(self):
if self.address:
return self.address.version
return None
if not self.address:
return None
if isinstance(self.address, str):
return netaddr.IPNetwork(self.address).version
return self.address.version
@property
def is_oob_ip(self):

View File

@@ -11,6 +11,13 @@ 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)
@@ -40,6 +47,13 @@ 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')
@@ -90,6 +104,20 @@ 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')),
@@ -533,6 +561,13 @@ 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')),

24
netbox/ipam/ui/attrs.py Normal file
View File

@@ -0,0 +1,24 @@
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,14 +2,15 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, panels
from netbox.ui import actions, attrs, panels
from .attrs import VRFDisplayAttr
class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
"""
A panel which lists all FHRP group assignments for a given object.
"""
template_name = 'ipam/panels/fhrp_groups.html'
title = _('FHRP Groups')
actions = [
@@ -35,3 +36,221 @@ 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'))
role = attrs.RelatedObjectAttr('role', linkify=True)
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,8 +9,16 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
from utilities.tables import get_table_ordering
@@ -23,6 +31,7 @@ from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
from .constants import *
from .models import *
from .ui import panels
from .utils import add_available_vlans, add_requested_prefixes, annotate_ip_space
#
@@ -41,6 +50,27 @@ class VRFListView(generic.ObjectListView):
@register_model_view(VRF)
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VRF.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
panels.VRFPanel(),
TagsPanel(),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
),
),
layout.Row(
layout.Column(
ContextTablePanel('import_targets_table', title=_('Import route targets')),
),
layout.Column(
ContextTablePanel('export_targets_table', title=_('Export route targets')),
),
),
)
def get_extra_context(self, request, instance):
import_targets_table = tables.RouteTargetTable(
@@ -134,6 +164,50 @@ class RouteTargetListView(generic.ObjectListView):
@register_model_view(RouteTarget)
class RouteTargetView(generic.ObjectView):
queryset = RouteTarget.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
),
),
layout.Row(
layout.Column(
ObjectsTablePanel(
'ipam.vrf',
filters={'import_target_id': lambda ctx: ctx['object'].pk},
title=_('Importing VRFs'),
),
),
layout.Column(
ObjectsTablePanel(
'ipam.vrf',
filters={'export_target_id': lambda ctx: ctx['object'].pk},
title=_('Exporting VRFs'),
),
),
),
layout.Row(
layout.Column(
ObjectsTablePanel(
'vpn.l2vpn',
filters={'import_target_id': lambda ctx: ctx['object'].pk},
title=_('Importing L2VPNs'),
),
),
layout.Column(
ObjectsTablePanel(
'vpn.l2vpn',
filters={'export_target_id': lambda ctx: ctx['object'].pk},
title=_('Exporting L2VPNs'),
),
),
),
)
@register_model_view(RouteTarget, 'add', detail=False)
@@ -192,6 +266,17 @@ class RIRListView(generic.ObjectListView):
@register_model_view(RIR)
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RIR.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RIRPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -257,6 +342,16 @@ class ASNRangeListView(generic.ObjectListView):
@register_model_view(ASNRange)
class ASNRangeView(generic.ObjectView):
queryset = ASNRange.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ASNRangePanel(),
TagsPanel(),
],
right_panels=[
CommentsPanel(),
CustomFieldsPanel(),
],
)
@register_model_view(ASNRange, 'asns')
@@ -337,6 +432,17 @@ class ASNListView(generic.ObjectListView):
@register_model_view(ASN)
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ASN.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ASNPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -412,6 +518,16 @@ class AggregateListView(generic.ObjectListView):
@register_model_view(Aggregate)
class AggregateView(generic.ObjectView):
queryset = Aggregate.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.AggregatePanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
)
@register_model_view(Aggregate, 'prefixes')
@@ -507,6 +623,17 @@ class RoleListView(generic.ObjectListView):
@register_model_view(Role)
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Role.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RolePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -570,15 +697,23 @@ 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)
@@ -609,11 +744,12 @@ class PrefixView(generic.ObjectView):
)
duplicate_prefix_table.configure(request)
return {
'aggregate': aggregate,
context = {
'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')
@@ -767,6 +903,19 @@ class IPRangeListView(generic.ObjectListView):
@register_model_view(IPRange)
class IPRangeView(generic.ObjectView):
queryset = IPRange.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPRangePanel(),
],
right_panels=[
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
],
)
def get_extra_context(self, request, instance):
@@ -864,6 +1013,23 @@ class IPAddressListView(generic.ObjectListView):
@register_model_view(IPAddress)
class IPAddressView(generic.ObjectView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
layout = layout.SimpleLayout(
left_panels=[
panels.IPAddressPanel(),
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
right_panels=[
ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
ContextTablePanel('duplicate_ips_table', title=_('Duplicate IPs')),
ObjectsTablePanel(
'ipam.service',
filters={'ip_address_id': lambda ctx: ctx['object'].pk},
title=_('Application services'),
),
],
)
def get_extra_context(self, request, instance):
# Parent prefixes table
@@ -896,10 +1062,12 @@ class IPAddressView(generic.ObjectView):
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
duplicate_ips_table.configure(request)
return {
context = {
'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)
@@ -1049,6 +1217,17 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization()
layout = layout.SimpleLayout(
left_panels=[
panels.VLANGroupPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -1136,19 +1315,33 @@ class VLANTranslationPolicyListView(generic.ObjectListView):
@register_model_view(VLANTranslationPolicy)
class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView):
class VLANTranslationPolicyView(generic.ObjectView):
queryset = VLANTranslationPolicy.objects.all()
def get_extra_context(self, request, instance):
vlan_translation_table = VLANTranslationRuleTable(
data=instance.rules.all(),
orderable=False
)
vlan_translation_table.configure(request)
return {
'vlan_translation_table': vlan_translation_table,
}
layout = layout.SimpleLayout(
left_panels=[
panels.VLANTranslationPolicyPanel(),
],
right_panels=[
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'ipam.vlantranslationrule',
filters={'policy_id': lambda ctx: ctx['object'].pk},
title=_('VLAN translation rules'),
exclude_columns=['policy'],
actions=[
actions.AddObject(
'ipam.vlantranslationrule',
url_params={'policy': lambda ctx: ctx['object'].pk},
label=_('Add Rule'),
),
],
),
],
)
@register_model_view(VLANTranslationPolicy, 'add', detail=False)
@@ -1204,13 +1397,17 @@ class VLANTranslationRuleListView(generic.ObjectListView):
@register_model_view(VLANTranslationRule)
class VLANTranslationRuleView(GetRelatedModelsMixin, generic.ObjectView):
class VLANTranslationRuleView(generic.ObjectView):
queryset = VLANTranslationRule.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
layout = layout.SimpleLayout(
left_panels=[
panels.VLANTranslationRulePanel(),
],
right_panels=[
TagsPanel(),
CustomFieldsPanel(),
],
)
@register_model_view(VLANTranslationRule, 'add', detail=False)
@@ -1262,7 +1459,36 @@ class FHRPGroupListView(generic.ObjectListView):
@register_model_view(FHRPGroup)
class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = FHRPGroup.objects.all()
queryset = FHRPGroup.objects.annotate(
member_count=count_related(FHRPGroupAssignment, 'group')
)
layout = layout.SimpleLayout(
left_panels=[
panels.FHRPGroupPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.FHRPGroupAuthPanel(),
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'ipam.ipaddress',
filters={'fhrpgroup_id': lambda ctx: ctx['object'].pk},
title=_('Virtual IP addresses'),
actions=[
actions.AddObject(
'ipam.ipaddress',
url_params={'fhrpgroup': lambda ctx: ctx['object'].pk},
label=_('Add IP Address'),
),
],
),
ContextTablePanel('members_table', title=_('Members')),
],
)
def get_extra_context(self, request, instance):
# Get assigned interfaces
@@ -1287,7 +1513,6 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
),
),
'members_table': members_table,
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
}
@@ -1390,17 +1615,36 @@ class VLANListView(generic.ObjectListView):
@register_model_view(VLAN)
class VLANView(generic.ObjectView):
queryset = VLAN.objects.all()
def get_extra_context(self, request, instance):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
'vrf', 'scope', 'role', 'tenant'
)
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
prefix_table.configure(request)
return {
'prefix_table': prefix_table,
}
layout = layout.SimpleLayout(
left_panels=[
panels.VLANPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'ipam.prefix',
filters={'vlan_id': lambda ctx: ctx['object'].pk},
title=_('Prefixes'),
exclude_columns=['vlan'],
actions=[
actions.AddObject(
'ipam.prefix',
url_params={
'tenant': lambda ctx: ctx['object'].tenant.pk if ctx['object'].tenant else None,
'site': lambda ctx: ctx['object'].site.pk if ctx['object'].site else None,
'vlan': lambda ctx: ctx['object'].pk,
},
label=_('Add a Prefix'),
),
],
),
panels.VLANCustomerVLANsPanel(),
],
)
@register_model_view(VLAN, 'interfaces')
@@ -1494,6 +1738,16 @@ class ServiceTemplateListView(generic.ObjectListView):
@register_model_view(ServiceTemplate)
class ServiceTemplateView(generic.ObjectView):
queryset = ServiceTemplate.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ServiceTemplatePanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
)
@register_model_view(ServiceTemplate, 'add', detail=False)
@@ -1550,6 +1804,16 @@ class ServiceListView(generic.ObjectListView):
@register_model_view(Service)
class ServiceView(generic.ObjectView):
queryset = Service.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ServicePanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
)
def get_extra_context(self, request, instance):
context = {}

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from rest_framework.fields import CreateOnlyDefault
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldsDataField
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField
from .base import ValidatedModelSerializer
from .nested import NestedTagSerializer
@@ -23,6 +23,29 @@ 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):
"""
@@ -30,17 +53,78 @@ class TaggableModelSerializer(serializers.Serializer):
on create() and update().
"""
tags = NestedTagSerializer(many=True, required=False)
add_tags = NestedTagSerializer(many=True, required=False, write_only=True)
remove_tags = NestedTagSerializer(many=True, required=False, write_only=True)
def to_internal_value(self, data):
ret = super().to_internal_value(data)
# Workaround to bypass requirement to include add_tags/remove_tags in Meta.fields on every serializer
if type(data) is dict:
tag_serializer = NestedTagSerializer(many=True)
for field_name in ('add_tags', 'remove_tags'):
if field_name in data:
ret[field_name] = tag_serializer.to_internal_value(data[field_name])
return ret
def validate(self, data):
# Skip validation for nested serializer representations (e.g. when used as a related field)
if type(data) is not dict:
return super().validate(data)
if data.get('tags') and (data.get('add_tags') or data.get('remove_tags')):
raise serializers.ValidationError({
'tags': 'Cannot specify "tags" together with "add_tags" or "remove_tags".'
})
if self.instance is None and data.get('remove_tags'):
raise serializers.ValidationError({
'remove_tags': 'Cannot use "remove_tags" when creating a new object.'
})
if data.get('add_tags') and data.get('remove_tags'):
add_pks = {t.pk for t in data['add_tags']}
remove_pks = {t.pk for t in data['remove_tags']}
overlap = [t for t in data['add_tags'] if t.pk in (add_pks & remove_pks)]
if overlap:
raise serializers.ValidationError({
'remove_tags':
f'Tags may not be present in both "add_tags" and "remove_tags": '
f'{", ".join(t.name for t in overlap)}'
})
# Pop add_tags/remove_tags before calling super() to prevent them from being passed
# to the model constructor during ValidatedModelSerializer validation
add_tags = data.pop('add_tags', None)
remove_tags = data.pop('remove_tags', None)
data = super().validate(data)
# Restore for use in create()/update()
if add_tags is not None:
data['add_tags'] = add_tags
if remove_tags is not None:
data['remove_tags'] = remove_tags
return data
def create(self, validated_data):
tags = validated_data.pop('tags', None)
add_tags = validated_data.pop('add_tags', None)
validated_data.pop('remove_tags', None)
instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags)
if add_tags is not None:
instance.tags.add(*[t.name for t in add_tags])
return instance
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
add_tags = validated_data.pop('add_tags', None)
remove_tags = validated_data.pop('remove_tags', None)
# Cache tags on instance for change logging
instance._tags = tags or []
@@ -49,6 +133,13 @@ class TaggableModelSerializer(serializers.Serializer):
if tags is not None:
return self._save_tags(instance, tags)
if add_tags is not None:
instance.tags.add(*[t.name for t in add_tags])
if remove_tags is not None:
instance.tags.remove(*[t.name for t in remove_tags])
if add_tags is not None or remove_tags is not None:
instance._tags = instance.tags.all()
return instance
def _save_tags(self, instance, tags):

View File

@@ -2,6 +2,7 @@ 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
@@ -71,14 +72,49 @@ 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 = {}
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])
# 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)
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,10 +1,12 @@
from typing import Union
from typing import NewType
import strawberry
BigInt = strawberry.scalar(
Union[int, str], # type: ignore
BigInt = NewType('BigInt', int)
BigIntScalar = strawberry.scalar(
name='BigInt',
serialize=lambda v: int(v),
parse_value=lambda v: str(v),
description="BigInt field",
description='BigInt field',
)

View File

@@ -16,6 +16,8 @@ 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(
@@ -36,9 +38,14 @@ class Query(
schema = strawberry.Schema(
query=Query,
config=StrawberryConfig(auto_camel_case=False),
config=StrawberryConfig(
auto_camel_case=False,
scalar_map={
BigInt: BigIntScalar,
},
),
extensions=[
DjangoOptimizerExtension(prefetch_custom_queryset=True),
MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
]
],
)

View File

@@ -25,7 +25,6 @@ from netbox.registry import registry
from netbox.signals import post_clean
from netbox.utils import register_model_feature
from utilities.json import CustomFieldJSONEncoder
from utilities.permissions import ModelAction, register_model_actions
from utilities.serialization import serialize_object
__all__ = (
@@ -753,12 +752,3 @@ def register_models(*models):
register_model_view(model, 'sync', kwargs={'model': model})(
'netbox.views.generic.ObjectSyncDataView'
)
# Auto-register custom permission actions declared in Meta.permissions
if meta_permissions := getattr(model._meta, 'permissions', None):
actions = [
ModelAction(codename, help_text=_(name))
for codename, name in meta_permissions
]
if actions:
register_model_actions(model, actions)

View File

@@ -28,7 +28,6 @@ registry = Registry({
'denormalized_fields': collections.defaultdict(list),
'event_types': dict(),
'filtersets': dict(),
'model_actions': collections.defaultdict(list),
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),

View File

@@ -159,7 +159,7 @@ class BaseTable(tables.Table):
columns = None
ordering = None
if self.prefixed_order_by_field in request.GET:
if request.user.is_authenticated and 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.
@@ -185,6 +185,18 @@ class BaseTable(tables.Table):
columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
self._set_columns(columns)
# Apply column inclusion/exclusion (overrides user preferences)
if columns_param := request.GET.get('include_columns'):
for column_name in columns_param.split(','):
if column_name in self.columns.names():
self.columns.show(column_name)
if exclude_columns := request.GET.get('exclude_columns'):
exclude_columns = exclude_columns.split(',')
for column_name in exclude_columns:
if column_name in self.columns.names() and column_name not in self.exempt_columns:
self.columns.hide(column_name)
self._apply_prefetching()
if ordering is not None:
self.order_by = ordering

View File

@@ -1,3 +1,4 @@
from django.contrib.auth.models import AnonymousUser
from django.template import Context, Template
from django.test import RequestFactory, TestCase
@@ -46,6 +47,16 @@ 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

@@ -0,0 +1,215 @@
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,6 +18,7 @@ __all__ = (
'NumericAttr',
'ObjectAttribute',
'RelatedObjectAttr',
'RelatedObjectListAttr',
'TemplatedAttr',
'TextAttr',
'TimezoneAttr',
@@ -145,22 +146,40 @@ class ChoiceAttr(ObjectAttribute):
"""
A selection from a set of choices.
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.
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.
"""
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):
try:
return getattr(obj, f'get_{self.accessor}_display')()
except AttributeError:
return resolve_attr_path(obj, self.accessor)
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)
def get_context(self, obj, context):
try:
bg_color = getattr(obj, f'get_{self.accessor}_color')()
except AttributeError:
bg_color = None
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
return {
'bg_color': bg_color,
}
@@ -254,6 +273,83 @@ 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,6 +23,7 @@ __all__ = (
'PluginContentPanel',
'RelatedObjectsPanel',
'TemplatePanel',
'TextCodePanel',
)
@@ -67,6 +68,7 @@ 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__,
@@ -280,11 +282,13 @@ class ObjectsTablePanel(Panel):
model (str): The dotted label of the model to be added (e.g. "dcim.site")
filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is
a callable, it will be passed the current template context.
include_columns (list): A list of column names to always display (overrides user preferences)
exclude_columns (list): A list of column names to hide from the table (overrides user preferences)
"""
template_name = 'ui/panels/objects_table.html'
title = None
def __init__(self, model, filters=None, **kwargs):
def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs):
super().__init__(**kwargs)
# Resolve the model class from its app.name label
@@ -295,6 +299,8 @@ class ObjectsTablePanel(Panel):
raise ValueError(f"Invalid model label: {model}")
self.filters = filters or {}
self.include_columns = include_columns or []
self.exclude_columns = exclude_columns or []
# If no title is specified, derive one from the model name
if self.title is None:
@@ -306,6 +312,10 @@ class ObjectsTablePanel(Panel):
}
if 'return_url' not in url_params and 'object' in context:
url_params['return_url'] = context['object'].get_absolute_url()
if self.include_columns:
url_params['include_columns'] = ','.join(self.include_columns)
if self.exclude_columns:
url_params['exclude_columns'] = ','.join(self.exclude_columns)
return {
**super().get_context(context),
'viewname': get_viewname(self.model, 'list'),
@@ -328,6 +338,25 @@ 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

@@ -60,7 +60,9 @@
"@types/bootstrap/**/@popperjs/core": "^2.11.6",
"eslint/**/minimatch": "^3.1.3",
"eslint-plugin-import/**/minimatch": "^3.1.3",
"**/markdown-it": "^14.1.1"
"**/markdown-it": "^14.1.1",
"micromatch/picomatch": "2.3.2",
"tinyglobby/picomatch": "4.0.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -1,17 +1,10 @@
import { initClearField } from './clearField';
import { initFormElements } from './elements';
import { initFilterModifiers } from './filterModifiers';
import { initRegisteredActions } from './registeredActions';
import { initSpeedSelector } from './speedSelector';
export function initForms(): void {
for (const func of [
initFormElements,
initSpeedSelector,
initFilterModifiers,
initClearField,
initRegisteredActions,
]) {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
func();
}
}

View File

@@ -1,60 +0,0 @@
import { getElements } from '../util';
/**
* Enable/disable registered action checkboxes based on selected object_types.
*/
export function initRegisteredActions(): void {
const selectedList = document.getElementById('id_object_types_1') as HTMLSelectElement;
if (!selectedList) {
return;
}
const actionCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-models]'),
);
if (actionCheckboxes.length === 0) {
return;
}
function updateState(): void {
const selectedModels = new Set<string>();
// Get model keys from selected options
for (const option of Array.from(selectedList.options)) {
const modelKey = option.dataset.modelKey;
if (modelKey) {
selectedModels.add(modelKey);
}
}
// Enable a checkbox if any of its supported models is selected
for (const checkbox of actionCheckboxes) {
const modelKeys = (checkbox.dataset.models ?? '').split(',').filter(Boolean);
const enabled = modelKeys.some(m => selectedModels.has(m));
checkbox.disabled = !enabled;
if (!enabled) {
checkbox.checked = false;
}
checkbox.style.opacity = enabled ? '' : '0.75';
// Fade the label text when disabled
const label = checkbox.nextElementSibling as HTMLElement | null;
if (label) {
label.style.opacity = enabled ? '' : '0.5';
}
}
}
// Initial update
updateState();
// Listen to move button clicks
for (const btn of getElements<HTMLButtonElement>('.move-option')) {
btn.addEventListener('click', () => {
// Wait for DOM update
setTimeout(updateState, 50);
});
}
}

View File

@@ -1,46 +1,50 @@
@use 'sass:map';
// Serialized data from change records
pre.change-data {
border-radius: 0;
padding: 0;
// Remove card-body padding
margin-inline: -0.75rem;
// Display each line individually for highlighting
> span {
display: block;
padding-right: $spacer;
padding-left: $spacer;
width: 100%;
padding-inline: map.get($spacers, 2);
max-width: 100%;
min-width: fit-content;
border-left: map.get($spacers, 1) solid transparent;
&.added {
color: var(--tblr-dark);
background-color: $green-300;
background-color: var(--tblr-green-200);
border-left-color: var(--tblr-green-darken);
}
&.removed {
color: var(--tblr-dark);
background-color: $red-300;
background-color: var(--tblr-red-200);
border-left-color: var(--tblr-red-darken);
}
}
}
// Change data diff w/added & removed data
pre.change-diff {
border-color: transparent;
border: var(--tblr-border-width) solid transparent;
&.change-added {
color: var(--tblr-dark);
background-color: $green-300;
background-color: var(--tblr-green-lt);
border-color: var(--tblr-green);
}
&.change-removed {
color: var(--tblr-dark);
background-color: $red-300;
background-color: var(--tblr-red-lt);
border-color: var(--tblr-red);
}
}
// <pre> elements displayed with a border
pre.block {
padding: $spacer;
border: 1px solid $border-color;
border: var(--tblr-border-width) solid $border-color;
border-radius: $border-radius;
}

View File

@@ -2076,9 +2076,9 @@ flatpickr@4.6.13:
integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
flatted@^3.2.9:
version "3.3.3"
resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
version "3.4.2"
resolved "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
for-each@^0.3.3:
version "0.3.3"
@@ -2993,15 +2993,25 @@ path-parse@^1.0.7:
resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
picomatch@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
version "2.3.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
possible-typed-array-names@^1.0.0:
version "1.0.0"

View File

@@ -1,3 +1,3 @@
version: "4.5.5"
version: "4.5.6"
edition: "Community"
published: "2026-03-17"
published: "2026-03-31"

View File

@@ -1,104 +1,6 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a></li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Account" %}</th>
<td>{{ object.provider_account|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Circuit ID" %}</th>
<td>{{ object.cid }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Distance" %}</th>
<td>
{% if object.distance is not None %}
{{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Install Date" %}</th>
<td>{{ object.install_date|isodate|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Termination Date" %}</th>
<td>{{ object.termination_date|isodate|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Commit Rate" %}</th>
<td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">
{% trans "Group Assignments" %}
{% if perms.circuits.add_circuitgroupassignment %}
<div class="card-actions">
<a href="{% url 'circuits:circuitgroupassignment_add' %}?member_type={{ object|content_type_id }}&member={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'circuits:circuitgroupassignment_list' circuit_id=object.pk %}
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,2 @@
{% load helpers %}
{{ value|humanize_speed }}

View File

@@ -1,30 +0,0 @@
{% extends 'generic/confirmation_form.html' %}
{% load i18n %}
{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
{% block message %}
<p>
{% blocktrans trimmed %}
Swap these terminations for circuit {{ circuit }}?
{% endblocktrans %}
</p>
<ul>
<li>
<strong>{% trans "A side" %}:</strong>
{% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %}
{% trans "None" %}
{% endif %}
</li>
<li>
<strong>{% trans "Z side" %}:</strong>
{% if termination_z %}
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
{% else %}
{% trans "None" %}
{% endif %}
</li>
</ul>
{% endblock %}

View File

@@ -1,8 +1,4 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
@@ -17,40 +13,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,9 +1,4 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@@ -11,42 +6,3 @@
<a href="{% url 'circuits:circuitgroupassignment_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Group Assignment" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.member.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>{{ object.member|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Priority" %}</th>
<td>{{ object.get_priority_display }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -7,45 +7,3 @@
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
{% if object %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>
{{ object.circuit|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>
{{ object.circuit.provider|linkify }}
</td>
</tr>
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
</table>
{% else %}
<div class="card-body">
<span class="text-muted">{% trans "None" %}</span>
</div>
{% endif %}
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
@@ -11,46 +8,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Color" %}</th>
<td>
{% if object.color %}
<span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -55,31 +55,33 @@
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Speed" %}</th>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Speed" %}</th>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<th scope="row">{% trans "Cross-Connect" %}</th>
<td>{{ termination.xconnect_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Cross-Connect" %}</th>
<td>{{ termination.xconnect_id|placeholder }}</td>
<th scope="row">{% trans "Patch Panel/Port" %}</th>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Patch Panel/Port" %}</th>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ termination.description|placeholder }}</td>
<th scope="row">{% trans "Description" %}</th>
<td>{{ termination.description|placeholder }}</td>
</tr>

View File

@@ -0,0 +1,69 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% blocktrans %}Termination{% endblocktrans %} {{ side }}
<div class="card-actions">
{% if not termination and perms.circuits.add_circuittermination %}
<a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add" %}
</a>
{% endif %}
{% if termination and perms.circuits.change_circuittermination %}
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
</a>
{% endif %}
{% if termination and perms.circuits.delete_circuittermination %}
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
</a>
{% endif %}
</div>
</h2>
{% if termination %}
<table class="table table-hover attr-table">
{% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
<tr>
<th scope="row">{% trans "Tags" %}</th>
<td>
{% for tag in termination.tags.all %}
{% tag tag %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
</td>
</tr>
{% for group_name, fields in termination.get_custom_fields_by_group.items %}
<tr>
<td colspan="2">
{% trans "Custom Fields" as default_group_label %}
<strong>{{ group_name|default:default_group_label }}</strong>
</td>
</tr>
{% for field, value in fields.items %}
<tr>
<th scope="row">{{ field }}
{% if field.description %}
<i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th>
<td>
{% customfield_value field value %}
</td>
</tr>
{% endfor %}
{% endfor %}
</table>
{% else %}
<div class="card-body">
<span class="text-muted">{% trans "None" %}</span>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,16 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>{{ object.circuit|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.circuit.provider|linkify|placeholder }}</td>
</tr>
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
</table>
{% endblock panel_content %}

View File

@@ -12,52 +12,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Provider" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "ASNs" %}</th>
<td>
{% for asn in object.asns.all %}
{{ asn|linkify }}{% if not forloop.last %}, {% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Provider Accounts" %}</h2>
{% htmx_table 'circuits:provideraccount_list' provider_id=object.pk %}
</div>
</div>
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Circuits" %}</h2>
{% htmx_table 'circuits:circuit_list' provider_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,54 +1,6 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Provider Account" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Account" %}</th>
<td>{{ object.account }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Circuits" %}</h2>
{% htmx_table 'circuits:circuit_list' provider_account_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,69 +1,6 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:providernetwork_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Provider Network" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Service ID" %}</th>
<td>{{ object.service_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Circuits" %}</h2>
{% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
</div>
<div class="card">
<h2 class="card-header">
{% trans "Virtual Circuits" %}
{% if perms.circuits.add_virtualcircuit %}
<div class="card-actions">
<a href="{% url 'circuits:virtualcircuit_add' %}?provider_network={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Virtual Circuit" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'circuits:virtualcircuit_list' provider_network_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@@ -12,90 +9,3 @@
<a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.provider_network.pk }}">{{ object.provider_network }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual circuit" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ object.provider_network|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider account" %}</th>
<td>{{ object.provider_account|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Circuit ID" %}</th>
<td>{{ object.cid }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
<div class="card">
<h2 class="card-header">
{% trans "Group Assignments" %}
{% if perms.circuits.add_circuitgroupassignment %}
<div class="card-actions">
<a href="{% url 'circuits:circuitgroupassignment_add' %}?member_type={{ object|content_type_id }}&member={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'circuits:circuitgroupassignment_list' virtual_circuit_id=object.pk %}
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Terminations" %}
{% if perms.circuits.add_virtualcircuittermination %}
<div class="card-actions">
<a href="{% url 'circuits:virtualcircuittermination_add' %}?virtual_circuit={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Termination" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'circuits:virtualcircuittermination_list' virtual_circuit_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
@@ -15,67 +13,3 @@
<a href="{% url 'circuits:virtualcircuittermination_list' %}?virtual_circuit_id={{ object.virtual_circuit.pk }}">{{ object.virtual_circuit }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.virtual_circuit.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ object.virtual_circuit.provider_network|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider account" %}</th>
<td>{{ object.virtual_circuit.provider_account|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Virtual circuit" %}</th>
<td>{{ object.virtual_circuit|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.interface.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ object.interface|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.interface.get_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.interface.description|placeholder }}</td>
</tr>
</table>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
@@ -11,46 +8,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual Circuit Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Color" %}</th>
<td>
{% if object.color %}
<span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,10 +1,7 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load perms %}
{% load plugins %}
{% load static %}
{% load i18n %}
{% block breadcrumbs %}
@@ -27,22 +24,3 @@
</div>
{% endif %}
{% endblock subtitle %}
{% block content %}
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Configuration Data" %}</h2>
{% include 'core/inc/config_data.html' %}
</div>
<div class="card">
<h2 class="card-header">{% trans "Comment" %}</h2>
<div class="card-body">
{{ object.comment|placeholder }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,62 +1,7 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load perms %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<div class="card">
<h2 class="card-header">{% trans "Data File" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Source" %}</th>
<td>{{ object.source|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Path" %}</th>
<td>
<span class="font-monospace" id="datafile_path">{{ object.path }}</span>
{% copy_content "datafile_path" %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Last Updated" %}</th>
<td>{{ object.last_updated }}</td>
</tr>
<tr>
<th scope="row">{% trans "Size" %}</th>
<td>{{ object.size }} {% trans "bytes" %}</td>
</tr>
<tr>
<th scope="row">{% trans "SHA256 Hash" %}</th>
<td>
<span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
{% copy_content "datafile_hash" %}
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Content" %}</h2>
<div class="card-body">
<pre>{{ object.data_as_string }}</pre>
</div>
</div>
{% plugin_left_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1 @@
{% load i18n %}{{ value }} {% trans "bytes" %}

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