Compare commits

...

20 Commits

Author SHA1 Message Date
Jeremy Stretch
d0651f6474 Release v4.5.7 (#21838) 2026-04-03 12:24:24 -04:00
Jeremy Stretch
fecd4e2f97 Closes #21839: Document the RQ configuration parameter 2026-04-03 12:01:15 -04:00
github-actions
f058ee3d60 Update source translation strings 2026-04-03 05:31:13 +00:00
bctiemann
49ba0dd495 Fix filtering of object-type custom fields when "is empty" is selected (#21829) 2026-04-02 16:17:49 -07:00
Martin Hauser
b4ee2cf447 fix(dcim): Refresh stale CablePath references during serialization (#21815)
Cable edits can delete and recreate CablePath rows while endpoint
instances remain in memory. Deferred event serialization can then
encounter a stale `_path` reference and raise `CablePath.DoesNotExist`.

Refresh stale `_path` references through `PathEndpoint.path` and route
internal callers through that accessor. Update `EventContext` to track
the latest serialization source for coalesced duplicate enqueues, while
eagerly freezing delete-event payloads before row removal.

Also avoid mutating `event_rule.action_data` when merging the event
payload.

Fixes #21498
2026-04-02 15:49:42 -07:00
Jason Novinger
34098bb20a Fixes #21760: Add 1C2P:2C1P breakout cable profile (#21824)
* Add Breakout1C2Px2C1PCableProfile class
* Add BREAKOUT_1C2P_2C1P choice
* Add new CableProfileChoices (BREAKOUT_1C2P_2C1P)

---------

Co-authored-by: Paulo Santos <paulo.banon@gmail.com>
2026-04-02 23:33:35 +02:00
Jonathan Senecal
a19daa5466 Fixes #21095: Add IEC unit labels support and rename humanize helpers to be unit-agnostic (#21789) 2026-04-02 14:30:49 -07:00
bctiemann
40eec679d9 Fixes: #21696 - Upgrade to django-rq==4.0.1 (#21805) 2026-04-02 14:09:53 -07:00
Martin Hauser
57556e3fdb fix(tables): Correct sortable column definitions across tables
Fix broken sorting metadata caused by incorrect accessors, field
references, and naming mismatches in several table definitions.

Update accessor paths for provider_account and device order_by; add
order_by mapping for the is_active property column; correct field name
typos such as termination_count to terminations_count; rename the
ssl_validation column to ssl_verification to match the model field; and
mark computed columns as orderable=False where sorting is not supported.

Fixes #21825
2026-04-02 16:20:53 -04:00
Arthur Hanson
f2d8ae29c2 21701 Allow scripts to be uploaded via post to API (#21756)
* #21701 allow upload script via API

* #21701 allow upload script via API

* add extra test

* change to use Script api endpoint

* ruff fix

* review feedback:

* review feedback:

* review feedback:

* Fix permission check, perform_create delegation, and test mock setup

- destroy() now checks extras.delete_script (queryset is Script.objects.all())
- create() delegates to self.perform_create() instead of calling serializer.save() directly
- Add comment explaining why update/partial_update intentionally return 405
- Fix test_upload_script_module: set mock_storage.save.return_value so file_path
  receives a real string after the _save_upload return-value fix; add DB existence check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Return 400 instead of 500 on duplicate script module upload

Catch IntegrityError from the unique (file_root, file_path) constraint
and re-raise as a ValidationError so the API returns a 400 with a clear
message rather than a 500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Validate upload_file + data_source conflict for multipart requests

DRF 3.16 Serializer.get_value() uses parse_html_dict() or empty for all
HTML/multipart input. A flat key like data_source=2 produces an empty
dict ({}), which is falsy, so it falls back to empty and the nested
field is silently skipped. data.get('data_source') is therefore always
None in multipart requests, bypassing the conflict check.

Fix: also check self.initial_data for data_source and data_file in all
three guards in validate(), so the raw submitted value is detected even
when DRF's HTML parser drops the deserialized object.

Add test_upload_with_data_source_fails to cover the multipart conflict
path explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Require data_file when data_source is specified

data_source alone is not a valid creation payload — a data_file must
also be provided to identify which file within the source to sync.
Add the corresponding validation error and a test to cover the case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Align ManagedFileForm validation with API serializer rules

Add the missing checks to ManagedFileForm.clean():
- upload_file + data_source is rejected (matches API)
- data_source without data_file is rejected with a specific message
- Update the 'nothing provided' error to mention data source + data file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "Align ManagedFileForm validation with API serializer rules"

This reverts commit f0ac7c3bd2.

* Align API validation messages with UI; restore complete checks

- Match UI error messages for upload+data_file conflict and no-source case
- Keep API-only guards for upload+data_source and data_source-without-data_file
- Restore test_upload_with_data_source_fails

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Run source/file conflict checks before super().validate() / full_clean()

super().validate() calls full_clean() on the model instance, which raises
a unique-constraint error for (file_root, file_path) when file_path is
empty (e.g. data_source-only requests). Move the conflict guards above the
super() call so they produce clear, actionable error messages before
full_clean() has a chance to surface confusing database-level errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* destroy() deletes ScriptModule, not Script

DELETE /api/extras/scripts/<pk>/ now deletes the entire ScriptModule
(matching the UI's delete view), including modules with no Script
children (e.g. sync hasn't run yet). Permission check updated to
delete_scriptmodule. The queryset restriction for destroy is removed
since the module is deleted via script.module, not super().destroy().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* review feedback:

* cleanup

* cleanup

* cleanup

* cleanup

* change to ScriptModule

* change to ScriptModule

* change to ScriptModule

* update docs

* cleanup

* restore file

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* keep only upload functionality

* cleanup

* cleanup

* cleanup

* change to scripts/upload api

* cleanup

* cleanup

* cleanup

* cleanup

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 08:42:14 -04:00
github-actions
f6eb5dda0f Update source translation strings 2026-04-02 05:30:39 +00:00
Mark Robert Coleman
c7bbfb24c5 Fix single {module} token rejection at nested module bay depth (#21740)
* Fix single {module} token rejection at nested depth (#20474)

A module type with a single {module} placeholder in component template
names could not be installed in a nested module bay (depth > 1) because
the form validation required an exact match between the token count and
the tree depth. This resolves the issue by treating a single {module}
token as a reference to the immediate parent bay's position, regardless
of nesting depth. Multi-token behavior is unchanged.

Refactors resolve_name() and resolve_label() into a shared
_resolve_module_placeholder() helper to eliminate duplication.

Fixes: #20474

* Address review feedback for PR #21740 (fixes #20474)

- Rebase on latest main to resolve merge conflicts
- Extract shared module bay traversal and {module} token resolution
  into dcim/utils.py (get_module_bay_positions, resolve_module_placeholder)
- Update ModuleCommonForm, ModularComponentTemplateModel, and
  ModuleBayTemplate to use shared utility functions
- Add {module} token validation to ModuleSerializer.validate() so the
  API enforces the same rules as the UI form
- Remove duplicated _get_module_bay_tree (form) and _get_module_tree
  (model) methods in favor of the shared routine
2026-04-01 16:19:43 -07: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
bctiemann
b8b12f3f90 #20923 - Convert extras to new declarative UI layout (#21765) 2026-03-31 20:28:16 +02:00
136 changed files with 16458 additions and 17455 deletions

View File

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

View File

@@ -47,8 +47,7 @@ django-rich
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
# See https://github.com/netbox-community/netbox/issues/21696
django-rq<4.0
django-rq
# Provides a variety of storage backends
# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "NetBox REST API",
"version": "4.5.6",
"version": "4.5.7",
"license": {
"name": "Apache v2 License"
}
@@ -25468,7 +25468,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25488,7 +25488,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25501,7 +25501,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25514,7 +25514,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25527,7 +25527,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25540,7 +25540,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25553,7 +25553,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25566,7 +25566,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25579,7 +25579,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25592,7 +25592,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25605,7 +25605,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -25618,7 +25618,7 @@
"type": "array",
"items": {
"type": "string",
"x-spec-enum-id": "5e0f85310f0184ea"
"x-spec-enum-id": "f566e6df6572f5d0"
}
},
"explode": true,
@@ -138591,6 +138591,50 @@
}
}
},
"/api/extras/scripts/upload/": {
"post": {
"operationId": "extras_scripts_upload_create",
"description": "Post a list of script module objects.",
"tags": [
"extras"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptModuleRequest"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/ScriptModuleRequest"
}
}
},
"required": true
},
"security": [
{
"cookieAuth": []
},
{
"tokenAuth": []
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptModule"
}
}
},
"description": ""
}
}
}
},
"/api/extras/subscriptions/": {
"get": {
"operationId": "extras_subscriptions_list",
@@ -228046,13 +228090,14 @@
"trunk-4c6p",
"trunk-4c8p",
"trunk-8c4p",
"breakout-1c2p-2c1p",
"breakout-1c4p-4c1p",
"breakout-1c6p-6c1p",
"breakout-2c4p-8c1p-shuffle"
],
"type": "string",
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "5e0f85310f0184ea"
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "f566e6df6572f5d0"
},
"label": {
"type": "string",
@@ -228078,6 +228123,7 @@
"4C6P trunk",
"4C8P trunk",
"8C4P trunk",
"1C2P:2C1P breakout",
"1C4P:4C1P breakout",
"1C6P:6C1P breakout",
"2C4P:8C1P breakout (shuffle)"
@@ -228282,13 +228328,14 @@
"trunk-4c6p",
"trunk-4c8p",
"trunk-8c4p",
"breakout-1c2p-2c1p",
"breakout-1c4p-4c1p",
"breakout-1c6p-6c1p",
"breakout-2c4p-8c1p-shuffle"
],
"type": "string",
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "5e0f85310f0184ea"
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "f566e6df6572f5d0"
},
"tenant": {
"oneOf": [
@@ -254488,8 +254535,7 @@
"size": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"title": "Size (MB)"
"minimum": 0
},
"owner": {
"oneOf": [
@@ -254774,14 +254820,15 @@
"trunk-4c6p",
"trunk-4c8p",
"trunk-8c4p",
"breakout-1c2p-2c1p",
"breakout-1c4p-4c1p",
"breakout-1c6p-6c1p",
"breakout-2c4p-8c1p-shuffle",
""
],
"type": "string",
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "5e0f85310f0184ea"
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "f566e6df6572f5d0"
},
"tenant": {
"oneOf": [
@@ -262819,15 +262866,13 @@
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Memory (MB)"
"nullable": true
},
"disk": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Disk (MB)"
"nullable": true
},
"description": {
"type": "string",
@@ -270340,6 +270385,56 @@
"data"
]
},
"ScriptModule": {
"type": "object",
"description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"file_path": {
"type": "string",
"readOnly": true
},
"created": {
"type": "string",
"format": "date-time",
"readOnly": true
},
"last_updated": {
"type": "string",
"format": "date-time",
"readOnly": true,
"nullable": true
}
},
"required": [
"created",
"display",
"file_path",
"id",
"last_updated"
]
},
"ScriptModuleRequest": {
"type": "object",
"description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
"properties": {
"file": {
"type": "string",
"format": "binary",
"writeOnly": true
}
},
"required": [
"file"
]
},
"Service": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -275384,8 +275479,7 @@
"size": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"title": "Size (MB)"
"minimum": 0
},
"owner": {
"allOf": [
@@ -275456,8 +275550,7 @@
"size": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"title": "Size (MB)"
"minimum": 0
},
"owner": {
"oneOf": [
@@ -275662,15 +275755,13 @@
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Memory (MB)"
"nullable": true
},
"disk": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Disk (MB)"
"nullable": true
},
"description": {
"type": "string",
@@ -275926,15 +276017,13 @@
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Memory (MB)"
"nullable": true
},
"disk": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Disk (MB)"
"nullable": true
},
"description": {
"type": "string",
@@ -277220,14 +277309,15 @@
"trunk-4c6p",
"trunk-4c8p",
"trunk-8c4p",
"breakout-1c2p-2c1p",
"breakout-1c4p-4c1p",
"breakout-1c6p-6c1p",
"breakout-2c4p-8c1p-shuffle",
""
],
"type": "string",
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "5e0f85310f0184ea"
"description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
"x-spec-enum-id": "f566e6df6572f5d0"
},
"tenant": {
"oneOf": [
@@ -285520,15 +285610,13 @@
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Memory (MB)"
"nullable": true
},
"disk": {
"type": "integer",
"maximum": 2147483647,
"minimum": 0,
"nullable": true,
"title": "Disk (MB)"
"nullable": true
},
"description": {
"type": "string",

View File

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

View File

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

View File

@@ -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,31 @@
# NetBox v4.5
## v4.5.7 (2026-04-03)
### Enhancements
* [#21095](https://github.com/netbox-community/netbox/issues/21095) - Adopt IEC unit labels (e.g. GiB) for virtual machine resources
* [#21696](https://github.com/netbox-community/netbox/issues/21696) - Add support for django-rq 4.0 and introduce `RQ` configuration parameter
* [#21701](https://github.com/netbox-community/netbox/issues/21701) - Support uploading custom scripts via the REST API (`/api/extras/scripts/upload/`)
* [#21760](https://github.com/netbox-community/netbox/issues/21760) - Add a 1C2P:2C1P breakout cable profile
### Performance Improvements
* [#21655](https://github.com/netbox-community/netbox/issues/21655) - Optimize queries for object and multi-object type custom fields
### Bug Fixes
* [#20474](https://github.com/netbox-community/netbox/issues/20474) - Fix installation of modules with placeholder values in component names
* [#21498](https://github.com/netbox-community/netbox/issues/21498) - Fix server error triggered by event rules referencing deleted objects
* [#21533](https://github.com/netbox-community/netbox/issues/21533) - Ensure read-only fields are included in REST API responses upon object creation
* [#21535](https://github.com/netbox-community/netbox/issues/21535) - Fix filtering of object-type custom fields when "is empty" is selected
* [#21784](https://github.com/netbox-community/netbox/issues/21784) - Fix `AttributeError` exception when sorting a table as an anonymous user
* [#21808](https://github.com/netbox-community/netbox/issues/21808) - Fix `RelatedObjectDoesNotExist` exception when viewing an interface with a virtual circuit termination
* [#21810](https://github.com/netbox-community/netbox/issues/21810) - Fix `AttributeError` exception when viewing virtual chassis member
* [#21825](https://github.com/netbox-community/netbox/issues/21825) - Fix sorting by broken columns in several object lists
---
## v4.5.6 (2026-03-31)
### Enhancements

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
from django_rq.settings import get_queues_list, get_queues_map
from django_rq.utils import get_statistics
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQ_Job
@@ -524,13 +524,13 @@ class BackgroundTaskView(BaseRQView):
def get(self, request, job_id):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
config = get_queues_list()[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue_index = get_queues_map()[job.origin]
queue = get_queue_by_index(queue_index)
try:
@@ -640,7 +640,7 @@ class WorkerView(BaseRQView):
def get(self, request, key):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
config = get_queues_list()[0]
worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
# Convert microseconds to milliseconds
worker.total_working_time = worker.total_working_time / 1000

View File

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

View File

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

View File

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

View File

@@ -1776,6 +1776,7 @@ class CableProfileChoices(ChoiceSet):
TRUNK_4C8P = 'trunk-4c8p'
TRUNK_8C4P = 'trunk-8c4p'
# Breakouts
BREAKOUT_1C2P_2C1P = 'breakout-1c2p-2c1p'
BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
@@ -1815,6 +1816,7 @@ class CableProfileChoices(ChoiceSet):
(
_('Breakout'),
(
(BREAKOUT_1C2P_2C1P, _('1C2P:2C1P breakout')),
(BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
(BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
(BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),

View File

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

View File

@@ -160,6 +160,7 @@ class Cable(PrimaryModel):
CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile,
CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,

View File

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

View File

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

View File

@@ -1149,7 +1149,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, PrimaryModelTable):
)
device = tables.Column(
verbose_name=_('Device'),
order_by=('device___name',),
order_by=('device__name',),
linkify=True
)
status = columns.ChoiceFieldColumn(

View File

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

View File

@@ -5,6 +5,7 @@ from circuits.models import *
from core.models import ObjectType
from dcim.choices import *
from dcim.models import *
from extras.events import serialize_for_event
from extras.models import CustomField
from ipam.models import Prefix
from netbox.choices import WeightUnitChoices
@@ -893,6 +894,77 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='Sub-bay 1-1')
self.assertEqual(nested_bay.position, '1-1')
@tag('regression') # #20474
def test_single_module_token_at_nested_depth(self):
"""
A module type with a single {module} token should install at depth > 1
without raising a token count mismatch error, resolving to the immediate
parent bay's position.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Chassis with Rear Card',
slug='chassis-with-rear-card'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Rear card slot',
position='1'
)
rear_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Rear Card'
)
ModuleBayTemplate.objects.create(
module_type=rear_card_type,
name='SFP slot 1',
position='1'
)
ModuleBayTemplate.objects.create(
module_type=rear_card_type,
name='SFP slot 2',
position='2'
)
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='SFP Module'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='SFP {module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
device = Device.objects.create(
name='Test Chassis',
device_type=device_type,
role=device_role,
site=site
)
rear_card_bay = device.modulebays.get(name='Rear card slot')
rear_card = Module.objects.create(
device=device,
module_bay=rear_card_bay,
module_type=rear_card_type
)
sfp_bay = rear_card.modulebays.get(name='SFP slot 2')
sfp_module = Module.objects.create(
device=device,
module_bay=sfp_bay,
module_type=sfp_type
)
interface = sfp_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 2')
@tag('regression') # #20912
def test_module_bay_parent_cleared_when_module_removed(self):
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
@@ -1274,6 +1346,65 @@ class CableTestCase(TestCase):
self.assertEqual(a_terms, [interface1])
self.assertEqual(b_terms, [interface2])
@tag('regression') # #21498
def test_path_refreshes_replaced_cablepath_reference(self):
"""
An already-instantiated interface should refresh its denormalized
`_path` foreign key when the referenced CablePath row has been
replaced in the database.
"""
stale_interface = Interface.objects.get(device__name='TestDevice1', name='eth0')
old_path = CablePath.objects.get(pk=stale_interface._path_id)
new_path = CablePath(
path=old_path.path,
is_active=old_path.is_active,
is_complete=old_path.is_complete,
is_split=old_path.is_split,
)
old_path_id = old_path.pk
old_path.delete()
new_path.save()
# The old CablePath no longer exists
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
# The already-instantiated interface still points to the deleted path
# until the accessor refreshes `_path` from the database.
self.assertEqual(stale_interface._path_id, old_path_id)
self.assertEqual(stale_interface.path.pk, new_path.pk)
@tag('regression') # #21498
def test_serialize_for_event_handles_stale_cablepath_reference_after_retermination(self):
"""
Serializing an interface whose previously cached `_path` row has been
deleted during cable retermination must not raise.
"""
stale_interface = Interface.objects.get(device__name='TestDevice2', name='eth0')
old_path_id = stale_interface._path_id
new_peer = Interface.objects.get(device__name='TestDevice2', name='eth1')
cable = stale_interface.cable
self.assertIsNotNone(cable)
self.assertIsNotNone(old_path_id)
self.assertEqual(stale_interface.cable_end, 'B')
cable.b_terminations = [new_peer]
cable.save()
# The old CablePath was deleted during retrace.
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
# The stale in-memory instance still holds the deleted FK value.
self.assertEqual(stale_interface._path_id, old_path_id)
# Serialization must not raise ObjectDoesNotExist. Because this interface
# was the former B-side termination, it is now disconnected.
data = serialize_for_event(stale_interface)
self.assertIsNone(data['connected_endpoints'])
self.assertIsNone(data['connected_endpoints_type'])
self.assertFalse(data['connected_endpoints_reachable'])
class VirtualDeviceContextTestCase(TestCase):

View File

@@ -420,14 +420,23 @@ 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['object'].master.site_id if ctx['object'].master else '',
'rack': lambda ctx: ctx['object'].master.rack_id if ctx['object'].master else '',
'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 ''
),
},
),
]
@@ -532,7 +541,7 @@ class VirtualCircuitPanel(panels.ObjectPanel):
def render(self, context):
obj = context.get('object')
if not obj or not obj.is_virtual or not obj.virtual_circuit_termination:
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'))

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -74,7 +74,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return custom_fields
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
custom_fields = self.get_queryset().filter(object_types=content_type)
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:

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from jinja2.exceptions import TemplateError
@@ -23,6 +23,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
@@ -40,6 +48,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
@@ -57,6 +66,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 = ()
@@ -128,6 +149,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):
@@ -203,6 +232,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)
@@ -260,6 +299,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)
@@ -321,6 +373,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)
@@ -383,6 +444,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([])
@@ -476,6 +546,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)
@@ -660,6 +739,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)
@@ -716,6 +808,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)
@@ -774,6 +879,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)
@@ -853,6 +970,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)
@@ -915,6 +1044,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
@@ -1034,6 +1173,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):
@@ -642,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):
@@ -990,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

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

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')),

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):
"""

View File

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

View File

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

View File

@@ -159,7 +159,7 @@ class BaseTable(tables.Table):
columns = None
ordering = None
if 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.

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

@@ -23,6 +23,7 @@ __all__ = (
'PluginContentPanel',
'RelatedObjectsPanel',
'TemplatePanel',
'TextCodePanel',
)
@@ -329,6 +330,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.

View File

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

View File

@@ -1,62 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-5">
<div class="card">
<h2 class="card-header">{% trans "Config Context" %}</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 "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">{% trans "Profile" %}</th>
<td>{{ object.profile|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Active" %}</th>
<td>{% checkmark object.is_active %}</td>
</tr>
</table>
</div>
{% include 'core/inc/datafile_panel.html' %}
<div class="card">
<h2 class="card-header">{% trans "Assignment" %}</h2>
<table class="table table-hover attr-table">
{% for title, objects in assigned_objects %}
<tr>
<th scope="row">{{ title }}</th>
<td>
<ul class="list-unstyled mb-0">
{% for object in objects %}
<li>{{ object|linkify }}</li>
{% empty %}
<li class="text-muted">{% trans "None" %}</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="col col-12 col-md-7">
{% include 'inc/sync_warning.html' %}
<div class="card">
{% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,39 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-6">
<div class="card">
<h2 class="card-header">{% trans "Config Context Profile" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'core/inc/datafile_panel.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
</div>
<div class="col col-6">
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "JSON Schema" %}
<div>
{% copy_content "schema" %}
</div>
</h2>
<pre class="card-body rendered-context-data m-0" id="schema">{{ object.schema|json }}</pre>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,100 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Config Template" %}</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 "MIME Type" %}</th>
<td>{{ object.mime_type|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "File Name" %}</th>
<td>{{ object.file_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "File Extension" %}</th>
<td>{{ object.file_extension|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Attachment" %}</th>
<td>{% checkmark object.as_attachment %}</td>
</tr>
<tr>
<th scope="row">{% trans "Data Source" %}</th>
<td>
{% if object.data_source %}
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Data File" %}</th>
<td>
{% if object.data_file %}
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
{% elif object.data_path %}
<div class="float-end text-warning">
<i class="mdi mdi-alert" title="{% trans "The data file associated with this object has been deleted" %}."></i>
</div>
{{ object.data_path }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Data Synced" %}</th>
<td>{{ object.data_synced|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Auto Sync Enabled" %}</th>
<td>{% checkmark object.auto_sync_enabled %}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
<div class="card-body">
{% if object.environment_params %}
<pre>{{ object.environment_params|json }}</pre>
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Template" %}</h2>
<div class="card-body">
{% include 'inc/sync_warning.html' %}
<pre>{{ object.template_code }}</pre>
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% load i18n %}
{% if object.data_file %}
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
{% else %}
<div class="float-end text-warning">
<i class="mdi mdi-alert" title="{% trans "The data file associated with this object has been deleted" %}."></i>
</div>
{{ value }}
{% endif %}

View File

@@ -1,163 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Custom Field" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<td>
{{ object.get_type_display }}
{% if object.related_object_type %}
({{ object.related_object_type.model|bettertitle }})
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group Name" %}</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Required" %}</th>
<td>{% checkmark object.required %}</td>
</tr>
<tr>
<th scope="row">{% trans "Must be Unique" %}</th>
<td>{% checkmark object.unique %}</td>
</tr>
<tr>
<th scope="row">{% trans "Cloneable" %}</th>
<td>{% checkmark object.is_cloneable %}</td>
</tr>
{% if object.choice_set %}
<tr>
<th scope="row">{% trans "Choice Set" %}</th>
<td>{{ object.choice_set|linkify }} ({{ object.choice_set.choices|length }} choices)</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Default Value" %}</th>
<td>{{ object.default }}</td>
</tr>
<tr>
<th scope="row">{% trans "Related object filter" %}</th>
{% if object.related_object_filter %}
<td><pre>{{ object.related_object_filter|json }}</pre></td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Behavior" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Search Weight" %}</th>
<td>
{% if object.search_weight %}
{{ object.search_weight }}
{% else %}
<span class="text-muted">{% trans "Disabled" %}</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Filter Logic" %}</th>
<td>{{ object.get_filter_logic_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Display Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">{% trans "UI Visible" %}</th>
<td>{{ object.get_ui_visible_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "UI Editable" %}</th>
<td>{{ object.get_ui_editable_display }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Object Types" %}</h2>
<table class="table table-hover attr-table">
{% for ct in object.object_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Validation Rules" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Minimum Value" %}</th>
<td>{{ object.validation_minimum|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Value" %}</th>
<td>{{ object.validation_maximum|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Regular Expression" %}</th>
<td>
{% if object.validation_regex %}
<code>{{ object.validation_regex }}</code>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">Related Objects</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for qs in related_models %}
<a class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %}
{% if count %}
<span class="badge text-bg-primary rounded-pill">{{ count }}</span>
{% else %}
<span class="badge text-bg-light rounded-pill">&mdash;</span>
{% endif %}
{% endwith %}
</a>
{% endfor %}
</ul>
</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

@@ -0,0 +1 @@
{% load helpers i18n %}{{ value|linkify }} ({{ value.choices|length }} {% trans "choices" %})

View File

@@ -0,0 +1 @@
{% load helpers %}<pre>{{ value|json }}</pre>

View File

@@ -0,0 +1 @@
{% load i18n %}{% if value %}{{ value }}{% else %}<span class="text-muted">{% trans "Disabled" %}</span>{% endif %}

View File

@@ -0,0 +1 @@
{% load helpers %}{{ object.get_type_display }}{% if object.related_object_type %} ({{ object.related_object_type.model|bettertitle }}){% endif %}

View File

@@ -1,72 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">Custom Field Choice Set</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Base Choices</th>
<td>{{ object.get_base_choices_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>{{ object.choices|length }}</td>
</tr>
<tr>
<th scope="row">Order Alphabetically</th>
<td>{% checkmark object.order_alphabetically %}</td>
</tr>
<tr>
<th scope="row">Used by</th>
<td>
<ul class="list-unstyled mb-0">
{% for cf in object.choices_for.all %}
<li>{{ cf|linkify }}</li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">Choices ({{ object.choices|length }})</h2>
<table class="table table-hover">
<thead>
<tr>
<th>Value</th>
<th>Label</th>
</tr>
</thead>
{% for value, label in choices %}
<tr>
<td>{{ value }}</td>
<td>{{ label }}</td>
</tr>
{% endfor %}
</table>
{% include 'inc/paginator.html' with page=choices %}
</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,71 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-5">
<div class="card">
<h2 class="card-header">{% trans "Custom Link" %}</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 "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Group Name" %}</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">{% trans "Button Class" %}</th>
<td>{{ object.get_button_class_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "New Window" %}</th>
<td>{% checkmark object.new_window %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
<table class="table table-hover attr-table">
{% for ct in object.object_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-7">
<div class="card">
<h2 class="card-header">{% trans "Link Text" %}</h2>
<div class="card-body">
<pre>{{ object.link_text }}</pre>
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Link URL" %}</h2>
<div class="card-body">
<pre>{{ object.link_url }}</pre>
</div>
</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,105 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Event Rule" %}</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 "Enabled" %}</th>
<td>{% checkmark object.enabled %}</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 "Object Types" %}</h2>
<table class="table table-hover attr-table">
{% for object_type in object.object_types.all %}
<tr>
<td>{{ object_type }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Event Types" %}</h2>
<ul class="list-group list-group-flush">
{% for name, event in registry.event_types.items %}
<li class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
{% if name in object.event_types %}
{% checkmark True %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</div>
<div class="col">
{{ event }}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Conditions" %}</h2>
<div class="card-body">
{% if object.conditions %}
<pre>{{ object.conditions|json }}</pre>
{% else %}
<p class="text-muted">{% trans "None" %}</p>
{% endif %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Action" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_action_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Object" %}</th>
<td>
{{ object.action_object|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Data" %}</th>
<td>
{% if object.action_data %}
<pre>{{ object.action_data|json }}</pre>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% 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

@@ -0,0 +1 @@
{% load helpers %}{% if value %}<pre>{{ value|json }}</pre>{% else %}<span class="text-muted">&mdash;</span>{% endif %}

View File

@@ -1,79 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block title %}{{ object.name }}{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Export Template" %}</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 "MIME Type" %}</th>
<td>{{ object.mime_type|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "File Name" %}</th>
<td>{{ object.file_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "File Extension" %}</th>
<td>{{ object.file_extension|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Attachment" %}</th>
<td>{% checkmark object.as_attachment %}</td>
</tr>
</table>
</div>
{% include 'core/inc/datafile_panel.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
<table class="table table-hover attr-table">
{% for object_type in object.object_types.all %}
<tr>
<td>{{ object_type }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
<div class="card-body">
{% if object.environment_params %}
<pre>{{ object.environment_params|json }}</pre>
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Template" %}</h2>
<div class="card-body">
{% include 'inc/sync_warning.html' %}
<pre>{{ object.template_code }}</pre>
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,67 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Image Attachment" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Parent Object" %}</th>
<td>{{ object.parent|linkify }}</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>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "File" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Filename" %}</th>
<td>
<a href="{{ object.image.url }}" target="_blank">{{ object.filename }}</a>
<i class="mdi mdi-open-in-new"></i>
</td>
</tr>
<tr>
<th scope="row">{% trans "Dimensions" %}</th>
<td>{{ object.image_width }} × {{ object.image_height }}</td>
</tr>
<tr>
<th scope="row">{% trans "Size" %}</th>
<td>
<span title="{{ object.size }} {% trans "bytes" %}">{{ object.size|filesizeformat }}</span>
</td>
</tr>
</table>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Image" %}</h2>
<div class="card-body">
<a href="{{ object.image.url }}" title="{{ object.name }}">
{{ object.html_tag }}
</a>
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,42 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% action_url object.assigned_object 'journal' pk=object.assigned_object.pk %}">{{ object.assigned_object }}</a></li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-5">
<div class="card">
<h2 class="card-header">{% trans "Journal Entry" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Object" %}</th>
<td>{{ object.assigned_object|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|isodatetime:"minutes" }}</td>
</tr>
<tr>
<th scope="row">{% trans "Created By" %}</th>
<td>{{ object.created_by }}</td>
</tr>
<tr>
<th scope="row">{% trans "Kind" %}</th>
<td>{% badge object.get_kind_display bg_color=object.get_kind_color %}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
</div>
<div class="col col-md-7">
{% include 'inc/panels/comments.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,57 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Notification Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>
{{ object.name }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>
{{ object.description|placeholder }}
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
{% endfor %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
{% for title, objects in assigned_objects %}
<tr>
<th scope="row">{{ title }}</th>
<td>
<ul class="list-unstyled mb-0">
{% for obj in objects %}
<li>{{ obj|linkify }}</li>
{% empty %}
<li class="text-muted">{% trans "None" %}</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</table>
{% endblock panel_content %}

View File

@@ -0,0 +1,5 @@
{% load helpers i18n %}
{% include 'inc/sync_warning.html' %}
<div class="card">
{% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %}
</div>

View File

@@ -0,0 +1,6 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% block panel_content %}
<pre class="card-body rendered-context-data m-0" id="schema">{{ object.schema|json }}</pre>
{% endblock panel_content %}

View File

@@ -0,0 +1,21 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<ul class="list-group list-group-flush" role="presentation">
{% for qs in related_models %}
<a class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %}
{% if count %}
<span class="badge text-bg-primary rounded-pill">{{ count }}</span>
{% else %}
<span class="badge text-bg-light rounded-pill">&mdash;</span>
{% endif %}
{% endwith %}
</a>
{% empty %}
<span class="list-group-item text-muted">{% trans "None" %}</span>
{% endfor %}
</ul>
{% endblock panel_content %}

View File

@@ -0,0 +1,22 @@
{% extends "ui/panels/_base.html" %}
{% load i18n %}
{% block panel_content %}
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Value" %}</th>
<th>{% trans "Label" %}</th>
</tr>
</thead>
<tbody>
{% for value, label in choices %}
<tr>
<td>{{ value }}</td>
<td>{{ label }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include 'inc/paginator.html' with page=choices %}
{% endblock panel_content %}

View File

@@ -0,0 +1,23 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% block panel_content %}
<ul class="list-group list-group-flush">
{% for name, event in registry.event_types.items %}
<li class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
{% if name in object.event_types %}
{% checkmark True %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</div>
<div class="col">
{{ event }}
</div>
</div>
</li>
{% endfor %}
</ul>
{% endblock panel_content %}

View File

@@ -0,0 +1,24 @@
{% extends "ui/panels/_base.html" %}
{% load i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Filename" %}</th>
<td>
<a href="{{ object.image.url }}" target="_blank">{{ object.filename }}</a>
<i class="mdi mdi-open-in-new"></i>
</td>
</tr>
<tr>
<th scope="row">{% trans "Dimensions" %}</th>
<td>{{ object.image_width }} &times; {{ object.image_height }}</td>
</tr>
<tr>
<th scope="row">{% trans "Size" %}</th>
<td>
<span title="{{ object.size }} {% trans "bytes" %}">{{ object.size|filesizeformat }}</span>
</td>
</tr>
</table>
{% endblock panel_content %}

View File

@@ -0,0 +1,9 @@
{% extends "ui/panels/_base.html" %}
{% block panel_content %}
<div class="card-body">
<a href="{{ object.image.url }}" title="{{ object.name }}">
{{ object.html_tag }}
</a>
</div>
{% endblock panel_content %}

View File

@@ -0,0 +1,12 @@
{% extends "ui/panels/_base.html" %}
{% load i18n %}
{% block panel_content %}
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
{% endfor %}
</div>
{% endblock panel_content %}

View File

@@ -0,0 +1,12 @@
{% extends "ui/panels/_base.html" %}
{% load i18n %}
{% block panel_content %}
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
{% endfor %}
</div>
{% endblock panel_content %}

View File

@@ -0,0 +1,11 @@
{% extends "ui/panels/_base.html" %}
{% block panel_content %}
<table class="table table-hover attr-table">
{% for ct in object.object_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% endfor %}
</table>
{% endblock panel_content %}

View File

@@ -0,0 +1,16 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<div class="list-group list-group-flush" role="presentation">
{% for object_type in object.object_types.all %}
{% with object_type.model_class|validated_viewname:"list" as viewname %}
{% if viewname %}
<a href="{% url viewname %}?{{ object.url_params }}" class="list-group-item list-group-item-action">{{ object_type }}</a>
{% else %}
<div class="list-group-item list-group-item-action">{{ object_type }}</div>
{% endif %}
{% endwith %}
{% endfor %}
</div>
{% endblock panel_content %}

View File

@@ -0,0 +1,15 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% load i18n %}
{% block panel_content %}
<ul class="list-group list-group-flush" role="presentation">
{% for name in object.columns %}
<li class="list-group-item list-group-item-action">
{% with column=columns|get_key:name %}
{{ column.verbose_name }}
{% endwith %}
</li>
{% endfor %}
</ul>
{% endblock panel_content %}

View File

@@ -0,0 +1,22 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% load i18n %}
{% block panel_content %}
<ul class="list-group list-group-flush" role="presentation">
{% for column_name, ascending in object.ordering_items %}
<li class="list-group-item">
{% with column=columns|get_key:column_name %}
{% if ascending %}
<i class="mdi mdi-arrow-down-thick"></i>
{% else %}
<i class="mdi mdi-arrow-up-thick"></i>
{% endif %}
{{ column.verbose_name }}
{% endwith %}
</li>
{% empty %}
<li class="list-group-item text-muted">{% trans "Default" %}</li>
{% endfor %}
</ul>
{% endblock panel_content %}

View File

@@ -0,0 +1,21 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<ul class="list-group list-group-flush" role="presentation">
{% for object_type in object_types %}
{% action_url object_type.content_type.model_class 'list' as list_url %}
{% if list_url %}
<a href="{{ list_url }}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</a>
{% else %}
<li class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</li>
{% endif %}
{% endfor %}
</ul>
{% endblock panel_content %}

View File

@@ -0,0 +1,16 @@
{% extends "ui/panels/_base.html" %}
{% load i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
{% for ct in object.object_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% empty %}
<tr>
<td class="text-muted">{% trans "Any" %}</td>
</tr>
{% endfor %}
</table>
{% endblock panel_content %}

View File

@@ -1,69 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-5">
<div class="card">
<h2 class="card-header">{% trans "Saved Filter" %}</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 "User" %}</th>
<td>{{ object.user|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Shared" %}</th>
<td>{% checkmark object.shared %}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
<div class="list-group list-group-flush" role="presentation">
{% for object_type in object.object_types.all %}
{% with object_type.model_class|validated_viewname:"list" as viewname %}
{% if viewname %}
<a href="{% url viewname %}?{{ object.url_params }}" class="list-group-item list-group-item-action">{{ object_type }}</a>
{% else %}
<div class="list-group-item list-group-item-action">{{ object_type }}</div>
{% endif %}
{% endwith %}
{% endfor %}
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-7">
<div class="card">
<h2 class="card-header">{% trans "Parameters" %}</h2>
<div class="card-body p-0">
<pre>{{ object.parameters|json }}</pre>
</div>
</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,88 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Table Config" %}</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 "Object Type" %}</th>
<td>{{ object.object_type }}</td>
</tr>
<tr>
<th scope="row">{% trans "Table" %}</th>
<td>{{ object.table }}</td>
</tr>
<tr>
<th scope="row">{% trans "User" %}</th>
<td>{{ object.user|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Shared" %}</th>
<td>{% checkmark object.shared %}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Columns Displayed" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for name in object.columns %}
<li class="list-group-item list-group-item-action">
{% with column=columns|get_key:name %}
{{ column.verbose_name }}
{% endwith %}
</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-header">{% trans "Ordering" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for column, ascending in object.ordering_items %}
<li class="list-group-item">
{% with column=columns|get_key:column %}
{% if ascending %}
<i class="mdi mdi-arrow-down-thick"></i>
{% else %}
<i class="mdi mdi-arrow-up-thick"></i>
{% endif %}
{{ column.verbose_name }}
{% endwith %}
</li>
{% empty %}
<li class="list-group-item text-muted">{% trans "Default" %}</li>
{% endfor %}
</ul>
</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,94 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tag" %}</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>
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tagged Items" %}</th>
<td>
{{ taggeditem_table.rows|length }}
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Allowed Object Types" %}</h2>
<table class="table table-hover attr-table">
{% for ct in object.object_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% empty %}
<tr>
<td class="text-muted">{% trans "Any" %}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Tagged Item Types" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for object_type in object_types %}
{% action_url object_type.content_type.model_class 'list' as list_url %}
{% if list_url %}
<a href="{{ list_url }}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</a>
{% else %}
<li class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Tagged Objects" %}</h2>
<div class="table-responsive">
{% render_table taggeditem_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %}
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1 @@
{{ value.count }}

View File

@@ -1,89 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Webhook" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "HTTP Request" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "HTTP Method" %}</th>
<td>{{ object.get_http_method_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Payload URL" %}</th>
<td class="font-monospace">{{ object.payload_url }}</td>
</tr>
<tr>
<th scope="row">{% trans "HTTP Content Type" %}</th>
<td>{{ object.http_content_type }}</td>
</tr>
<tr>
<th scope="row">{% trans "Secret" %}</th>
<td>{{ object.secret|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "SSL" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "SSL Verification" %}</th>
<td>{% checkmark object.ssl_verification %}</td>
</tr>
<tr>
<th scope="row">{% trans "CA File Path" %}</th>
<td>{{ object.ca_file_path|placeholder }}</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Additional Headers" %}</h2>
<div class="card-body">
{% if object.additional_headers %}
<pre>{{ object.additional_headers }}</pre>
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Body Template" %}</h2>
<div class="card-body">
{% if object.body_template %}
<pre>{{ object.body_template }}</pre>
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
</div>
{% 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

@@ -0,0 +1,15 @@
{% extends "ui/panels/_base.html" %}
{% load i18n %}
{% block panel_content %}
<div class="card-body">
{% if value %}
<pre>{{ value }}</pre>
{% else %}
{% if show_sync_warning %}
{% include 'inc/sync_warning.html' %}
{% endif %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
{% endblock panel_content %}

View File

@@ -12,7 +12,7 @@
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td>
{% if memory_sum %}
<span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
<span title={{ memory_sum }}>{{ memory_sum|humanize_ram_capacity }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
@@ -24,7 +24,7 @@
</th>
<td>
{% if disk_sum %}
{{ disk_sum|humanize_disk_megabytes }}
{{ disk_sum|humanize_disk_capacity }}
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -12,7 +12,7 @@
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td>
{% if object.memory %}
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
<span title={{ object.memory }}>{{ object.memory|humanize_ram_capacity }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
@@ -24,7 +24,7 @@
</th>
<td>
{% if object.disk %}
{{ object.disk|humanize_disk_megabytes }}
{{ object.disk|humanize_disk_capacity }}
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -1,2 +1,2 @@
{% load helpers %}
{{ value|humanize_disk_megabytes }}
{{ value|humanize_disk_capacity }}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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