netbox-custom-field-profiles 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_custom_field_profiles-0.1.0/LICENSE +21 -0
- netbox_custom_field_profiles-0.1.0/PKG-INFO +156 -0
- netbox_custom_field_profiles-0.1.0/README.md +116 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/__init__.py +24 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/__init__.py +0 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/serializers.py +12 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/urls.py +3 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/views.py +1 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/forms.py +251 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/management/__init__.py +0 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/management/commands/__init__.py +0 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/matching.py +56 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/metadata.py +18 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/middleware.py +48 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/0001_initial.py +70 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/0002_profiles.py +63 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/0003_retire_legacy_configuration.py +44 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/__init__.py +0 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/models.py +59 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/navigation.py +19 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/presentation.py +65 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/signals.py +103 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/static/netbox_custom_field_profiles/profile_fields.js +74 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tables.py +19 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/template_content.py +4 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/customfieldprofile.html +20 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/customfieldprofile_edit.html +41 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/device_parameters.html +75 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/device_parameters_edit.html +23 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/parameter_value.html +19 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templatetags/__init__.py +0 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templatetags/parameter_tags.py +16 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/__init__.py +1 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/runner.py +26 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_lifecycle.py +389 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_matching.py +165 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameter_editing.py +269 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameter_permissions.py +349 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameter_rendering.py +260 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameters.py +192 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_permissions.py +165 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_plugin_documentation.py +88 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_preservation.py +156 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_schema.py +95 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_selection.py +349 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_tracer.py +123 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/urls.py +10 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/views.py +95 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/PKG-INFO +156 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/SOURCES.txt +54 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/dependency_links.txt +1 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/entry_points.txt +2 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/requires.txt +4 -0
- netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/top_level.txt +1 -0
- netbox_custom_field_profiles-0.1.0/pyproject.toml +35 -0
- netbox_custom_field_profiles-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maarten Wallraf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netbox-custom-field-profiles
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable native custom field profiles for NetBox Devices
|
|
5
|
+
Author: Maarten Wallraf
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Maarten Wallraf
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/mwallraf/netbox-custom-params-tab
|
|
29
|
+
Project-URL: Repository, https://github.com/mwallraf/netbox-custom-params-tab
|
|
30
|
+
Project-URL: Documentation, https://github.com/mwallraf/netbox-custom-params-tab/blob/HEAD/docs/README.md
|
|
31
|
+
Project-URL: Installation, https://github.com/mwallraf/netbox-custom-params-tab/blob/HEAD/docs/installation.md
|
|
32
|
+
Project-URL: Issues, https://github.com/mwallraf/netbox-custom-params-tab/issues
|
|
33
|
+
Requires-Python: >=3.10
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
License-File: LICENSE
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest; extra == "test"
|
|
38
|
+
Requires-Dist: pytest-django; extra == "test"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# NetBox Custom Field Profiles
|
|
42
|
+
|
|
43
|
+
Show the custom fields that matter for each Device, without cluttering its main
|
|
44
|
+
page. Custom Field Profiles groups existing NetBox custom fields into reusable
|
|
45
|
+
sections on a dedicated **Custom Fields** tab.
|
|
46
|
+
|
|
47
|
+
For example, a CMTS can show 20–30 service fields while a CPE shows only two,
|
|
48
|
+
with shared fields appearing on both.
|
|
49
|
+
|
|
50
|
+
**Your fields and values stay in NetBox.** The plugin creates no field definitions
|
|
51
|
+
and has no separate value storage. **Edit Custom Fields** updates native custom field
|
|
52
|
+
values using NetBox’s permissions and validation, so existing scripts and APIs
|
|
53
|
+
continue to work with the same data.
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- Manage reusable profiles under **Plugins → Custom Field Profiles**.
|
|
58
|
+
- Match Devices automatically by Device Type, role and tags.
|
|
59
|
+
- Choose and order specific fields, or include all applicable fields dynamically.
|
|
60
|
+
- Display each matching profile as a section; edit repeated fields only once.
|
|
61
|
+
- Hide fields on the normal Device page using native **UI visibility → Hidden**,
|
|
62
|
+
while keeping them available in Custom Fields. Native editability still applies.
|
|
63
|
+
|
|
64
|
+
## Screenshots
|
|
65
|
+
|
|
66
|
+
**Device Custom Fields:** a matching profile presents existing native field values,
|
|
67
|
+
with one action to edit the relevant custom fields.
|
|
68
|
+
|
|
69
|
+

|
|
70
|
+
|
|
71
|
+
<details>
|
|
72
|
+
<summary><strong>Profile configuration</strong> — field selection, order and matching rules</summary>
|
|
73
|
+
|
|
74
|
+
Select existing Device fields, arrange their order, and choose which Devices match.
|
|
75
|
+
|
|
76
|
+

|
|
77
|
+
|
|
78
|
+
</details>
|
|
79
|
+
|
|
80
|
+
## Compatibility
|
|
81
|
+
|
|
82
|
+
Supports **Devices**. Tested with **NetBox 4.7.4** and NetBox Docker **5.1.1**.
|
|
83
|
+
The declared minimum is NetBox 4.7.0; other versions have not been verified.
|
|
84
|
+
|
|
85
|
+
## Quick installation
|
|
86
|
+
|
|
87
|
+
For a standard NetBox installation under `/opt/netbox`, install directly from
|
|
88
|
+
GitHub using NetBox’s normal plugin installation process:
|
|
89
|
+
|
|
90
|
+
1. Add this line to `/opt/netbox/local_requirements.txt`:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
netbox-custom-field-profiles @ git+https://github.com/mwallraf/netbox-custom-params-tab.git
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
2. Add the plugin to the existing `PLUGINS` list in NetBox’s `configuration.py`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
PLUGINS = [
|
|
100
|
+
# Your other plugins...
|
|
101
|
+
'netbox_custom_field_profiles',
|
|
102
|
+
]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
3. Run NetBox’s upgrade script to install requirements, apply migrations and
|
|
106
|
+
collect static files, then restart its services:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
sudo /opt/netbox/upgrade.sh
|
|
110
|
+
sudo systemctl restart netbox netbox-rq
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
No plugin-specific settings are required. For Docker, wheel installation and
|
|
114
|
+
runtime details, see the [installation guide](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/installation.md)
|
|
115
|
+
and the [NetBox plugin installation documentation](https://netboxlabs.com/docs/netbox/plugins/installation/).
|
|
116
|
+
|
|
117
|
+
## Get started
|
|
118
|
+
|
|
119
|
+
1. Create your fields in NetBox’s built-in **Custom Fields** administration and
|
|
120
|
+
make them applicable to Devices.
|
|
121
|
+
2. Open **Plugins → Custom Field Profiles**, add a profile, select its fields,
|
|
122
|
+
and optionally set Device Type, role or tag rules. Enable it and save.
|
|
123
|
+
3. Open a matching Device’s **Custom Fields** tab. Users with native Device change
|
|
124
|
+
permission can choose **Edit Custom Fields** to update editable values.
|
|
125
|
+
|
|
126
|
+
Different rule types combine with **AND**; values within a rule combine with
|
|
127
|
+
**OR**. A tag rule matches any selected tag. Profiles without rules apply to all
|
|
128
|
+
Devices. Matching never grants additional permissions.
|
|
129
|
+
|
|
130
|
+
## Documentation
|
|
131
|
+
|
|
132
|
+
- [Documentation index](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/README.md) — choose the right user or maintainer guide.
|
|
133
|
+
- [User guide](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/user-guide.md) — profiles, native values and reducing clutter.
|
|
134
|
+
- [Installation guide](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/installation.md) — supported runtime and deployment details.
|
|
135
|
+
- [Profile administration](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/administration.md) — ordering, rules and field lifecycle behavior.
|
|
136
|
+
- [Troubleshooting](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/troubleshooting.md) — common UI and deployment problems.
|
|
137
|
+
- [Maintainer guides](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/development/README.md) — sandbox, architecture and testing.
|
|
138
|
+
- [Report an issue](https://github.com/mwallraf/netbox-custom-params-tab/issues).
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
With Docker Compose, Python 3 and Make installed:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
git clone https://github.com/mwallraf/netbox-custom-params-tab.git
|
|
146
|
+
cd netbox-custom-params-tab
|
|
147
|
+
make setup
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Open <http://127.0.0.1:18047>. Generated administrator credentials are in the local,
|
|
151
|
+
ignored `.env` file. Run `make test` for baseline checks, `make test-profiles` for
|
|
152
|
+
the native plugin suite, and `make release-check` for installed-wheel verification.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
[MIT](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/LICENSE) © 2026 [Maarten Wallraf](https://github.com/mwallraf).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# NetBox Custom Field Profiles
|
|
2
|
+
|
|
3
|
+
Show the custom fields that matter for each Device, without cluttering its main
|
|
4
|
+
page. Custom Field Profiles groups existing NetBox custom fields into reusable
|
|
5
|
+
sections on a dedicated **Custom Fields** tab.
|
|
6
|
+
|
|
7
|
+
For example, a CMTS can show 20–30 service fields while a CPE shows only two,
|
|
8
|
+
with shared fields appearing on both.
|
|
9
|
+
|
|
10
|
+
**Your fields and values stay in NetBox.** The plugin creates no field definitions
|
|
11
|
+
and has no separate value storage. **Edit Custom Fields** updates native custom field
|
|
12
|
+
values using NetBox’s permissions and validation, so existing scripts and APIs
|
|
13
|
+
continue to work with the same data.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Manage reusable profiles under **Plugins → Custom Field Profiles**.
|
|
18
|
+
- Match Devices automatically by Device Type, role and tags.
|
|
19
|
+
- Choose and order specific fields, or include all applicable fields dynamically.
|
|
20
|
+
- Display each matching profile as a section; edit repeated fields only once.
|
|
21
|
+
- Hide fields on the normal Device page using native **UI visibility → Hidden**,
|
|
22
|
+
while keeping them available in Custom Fields. Native editability still applies.
|
|
23
|
+
|
|
24
|
+
## Screenshots
|
|
25
|
+
|
|
26
|
+
**Device Custom Fields:** a matching profile presents existing native field values,
|
|
27
|
+
with one action to edit the relevant custom fields.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
<details>
|
|
32
|
+
<summary><strong>Profile configuration</strong> — field selection, order and matching rules</summary>
|
|
33
|
+
|
|
34
|
+
Select existing Device fields, arrange their order, and choose which Devices match.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
</details>
|
|
39
|
+
|
|
40
|
+
## Compatibility
|
|
41
|
+
|
|
42
|
+
Supports **Devices**. Tested with **NetBox 4.7.4** and NetBox Docker **5.1.1**.
|
|
43
|
+
The declared minimum is NetBox 4.7.0; other versions have not been verified.
|
|
44
|
+
|
|
45
|
+
## Quick installation
|
|
46
|
+
|
|
47
|
+
For a standard NetBox installation under `/opt/netbox`, install directly from
|
|
48
|
+
GitHub using NetBox’s normal plugin installation process:
|
|
49
|
+
|
|
50
|
+
1. Add this line to `/opt/netbox/local_requirements.txt`:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
netbox-custom-field-profiles @ git+https://github.com/mwallraf/netbox-custom-params-tab.git
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
2. Add the plugin to the existing `PLUGINS` list in NetBox’s `configuration.py`:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
PLUGINS = [
|
|
60
|
+
# Your other plugins...
|
|
61
|
+
'netbox_custom_field_profiles',
|
|
62
|
+
]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
3. Run NetBox’s upgrade script to install requirements, apply migrations and
|
|
66
|
+
collect static files, then restart its services:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
sudo /opt/netbox/upgrade.sh
|
|
70
|
+
sudo systemctl restart netbox netbox-rq
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
No plugin-specific settings are required. For Docker, wheel installation and
|
|
74
|
+
runtime details, see the [installation guide](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/installation.md)
|
|
75
|
+
and the [NetBox plugin installation documentation](https://netboxlabs.com/docs/netbox/plugins/installation/).
|
|
76
|
+
|
|
77
|
+
## Get started
|
|
78
|
+
|
|
79
|
+
1. Create your fields in NetBox’s built-in **Custom Fields** administration and
|
|
80
|
+
make them applicable to Devices.
|
|
81
|
+
2. Open **Plugins → Custom Field Profiles**, add a profile, select its fields,
|
|
82
|
+
and optionally set Device Type, role or tag rules. Enable it and save.
|
|
83
|
+
3. Open a matching Device’s **Custom Fields** tab. Users with native Device change
|
|
84
|
+
permission can choose **Edit Custom Fields** to update editable values.
|
|
85
|
+
|
|
86
|
+
Different rule types combine with **AND**; values within a rule combine with
|
|
87
|
+
**OR**. A tag rule matches any selected tag. Profiles without rules apply to all
|
|
88
|
+
Devices. Matching never grants additional permissions.
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
- [Documentation index](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/README.md) — choose the right user or maintainer guide.
|
|
93
|
+
- [User guide](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/user-guide.md) — profiles, native values and reducing clutter.
|
|
94
|
+
- [Installation guide](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/installation.md) — supported runtime and deployment details.
|
|
95
|
+
- [Profile administration](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/administration.md) — ordering, rules and field lifecycle behavior.
|
|
96
|
+
- [Troubleshooting](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/troubleshooting.md) — common UI and deployment problems.
|
|
97
|
+
- [Maintainer guides](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/docs/development/README.md) — sandbox, architecture and testing.
|
|
98
|
+
- [Report an issue](https://github.com/mwallraf/netbox-custom-params-tab/issues).
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
With Docker Compose, Python 3 and Make installed:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
git clone https://github.com/mwallraf/netbox-custom-params-tab.git
|
|
106
|
+
cd netbox-custom-params-tab
|
|
107
|
+
make setup
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Open <http://127.0.0.1:18047>. Generated administrator credentials are in the local,
|
|
111
|
+
ignored `.env` file. Run `make test` for baseline checks, `make test-profiles` for
|
|
112
|
+
the native plugin suite, and `make release-check` for installed-wheel verification.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
[MIT](https://github.com/mwallraf/netbox-custom-params-tab/blob/v0.1.0/LICENSE) © 2026 [Maarten Wallraf](https://github.com/mwallraf).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
from .metadata import SUMMARY
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CustomFieldProfilesConfig(PluginConfig):
|
|
6
|
+
name = 'netbox_custom_field_profiles'
|
|
7
|
+
verbose_name = 'NetBox Custom Field Profiles'
|
|
8
|
+
version = '0.1.0'
|
|
9
|
+
author = 'Maarten Wallraf'
|
|
10
|
+
description = SUMMARY
|
|
11
|
+
min_version = '4.7.0'
|
|
12
|
+
base_url = 'custom-field-profiles'
|
|
13
|
+
menu_items = 'navigation.menu_items'
|
|
14
|
+
middleware = ['netbox_custom_field_profiles.middleware.PluginMetadataMiddleware']
|
|
15
|
+
|
|
16
|
+
def ready(self):
|
|
17
|
+
super().ready()
|
|
18
|
+
from . import signals
|
|
19
|
+
signals.register()
|
|
20
|
+
# Core Device URLs are assembled before plugin URL modules are loaded.
|
|
21
|
+
from . import views # noqa: F401
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
config = CustomFieldProfilesConfig
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
2
|
+
from ..models import CustomFieldProfile
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CustomFieldProfileSerializer(NetBoxModelSerializer):
|
|
6
|
+
"""Native event payload; profile management has no public REST endpoint."""
|
|
7
|
+
|
|
8
|
+
class Meta:
|
|
9
|
+
model = CustomFieldProfile
|
|
10
|
+
fields = ('id', 'display', 'title', 'content_type', 'display_order', 'enabled',
|
|
11
|
+
'field_mode', 'created', 'last_updated')
|
|
12
|
+
read_only_fields = fields
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""No public plugin API; profiles are managed through native plugin pages."""
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
|
|
4
|
+
from django import forms
|
|
5
|
+
from django.db import router, transaction
|
|
6
|
+
from django.contrib.contenttypes.models import ContentType
|
|
7
|
+
from django.db.models import Q
|
|
8
|
+
from netbox.forms import NetBoxModelForm
|
|
9
|
+
from extras.choices import CustomFieldStatusChoices
|
|
10
|
+
from extras.models import CustomField, Tag
|
|
11
|
+
from dcim.models import Device, DeviceType, DeviceRole
|
|
12
|
+
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
|
13
|
+
from utilities.exceptions import AbortRequest
|
|
14
|
+
from .models import CustomFieldProfile, ProfileField
|
|
15
|
+
from .signals import lock_profile_fields
|
|
16
|
+
from .matching import get_matching_profiles, get_profile_fields
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeviceParametersForm(NetBoxModelForm):
|
|
20
|
+
"""A partial native Device form; only current editable profile fields bind."""
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
model = Device
|
|
24
|
+
fields = ()
|
|
25
|
+
|
|
26
|
+
def _get_custom_fields(self, content_type):
|
|
27
|
+
fields = {}
|
|
28
|
+
for profile in get_matching_profiles(self.instance):
|
|
29
|
+
for field in get_profile_fields(profile):
|
|
30
|
+
if field.ui_editable == 'yes':
|
|
31
|
+
fields.setdefault(field.pk, field)
|
|
32
|
+
return list(fields.values())
|
|
33
|
+
|
|
34
|
+
def __init__(self, *args, user, **kwargs):
|
|
35
|
+
from django.core import signing
|
|
36
|
+
from utilities.forms.utils import restrict_form_fields
|
|
37
|
+
|
|
38
|
+
super().__init__(*args, **kwargs)
|
|
39
|
+
self.fields.pop('tags', None)
|
|
40
|
+
restrict_form_fields(self, user)
|
|
41
|
+
# Never expose an inaccessible saved reference via widget initial data,
|
|
42
|
+
# nor silently clear it when the remaining parameters are saved.
|
|
43
|
+
for name, customfield in list(self.custom_fields.items()):
|
|
44
|
+
field = self.fields[name]
|
|
45
|
+
if hasattr(field, 'queryset'):
|
|
46
|
+
raw = self.instance.custom_field_data.get(customfield.name)
|
|
47
|
+
ids = raw if isinstance(raw, list) else ([] if raw is None else [raw])
|
|
48
|
+
if set(ids) - set(field.queryset.filter(pk__in=ids).values_list('pk', flat=True)):
|
|
49
|
+
del self.fields[name]
|
|
50
|
+
del self.custom_fields[name]
|
|
51
|
+
self.scope = [(field.pk, field.name, field.type, str(field.last_updated))
|
|
52
|
+
for field in self.custom_fields.values()]
|
|
53
|
+
self.fields['_parameter_scope'] = forms.CharField(
|
|
54
|
+
widget=forms.HiddenInput,
|
|
55
|
+
initial=signing.dumps(self.scope, salt='device-parameters'),
|
|
56
|
+
)
|
|
57
|
+
editable_names = {field.name for field in self.custom_fields.values()}
|
|
58
|
+
self.untouched_values = {
|
|
59
|
+
name: deepcopy(value) for name, value in self.instance.custom_field_data.items()
|
|
60
|
+
if name not in editable_names
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def _post_clean(self):
|
|
64
|
+
super()._post_clean()
|
|
65
|
+
# Native model validation prunes orphaned keys. A partial editor owns
|
|
66
|
+
# only its edit set, so retain all pre-existing data outside that set.
|
|
67
|
+
self.instance.custom_field_data.update(self.untouched_values)
|
|
68
|
+
|
|
69
|
+
def clean(self):
|
|
70
|
+
from django.core import signing
|
|
71
|
+
|
|
72
|
+
super().clean()
|
|
73
|
+
if not self.custom_fields:
|
|
74
|
+
raise forms.ValidationError('No editable custom fields are available for this device.')
|
|
75
|
+
try:
|
|
76
|
+
scope = signing.loads(self.cleaned_data.get('_parameter_scope', ''), salt='device-parameters')
|
|
77
|
+
if scope != [list(item) for item in self.scope]:
|
|
78
|
+
raise signing.BadSignature()
|
|
79
|
+
except signing.BadSignature:
|
|
80
|
+
raise forms.ValidationError('The available custom fields changed. Reload this page before saving.')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CustomFieldProfileForm(NetBoxModelForm):
|
|
84
|
+
rule_fields = ('device_types', 'device_roles', 'applicability_tags')
|
|
85
|
+
device_types = DynamicModelMultipleChoiceField(
|
|
86
|
+
queryset=DeviceType.objects.all(), required=False,
|
|
87
|
+
help_text='Match any selected Device Type. Empty means any type.',
|
|
88
|
+
)
|
|
89
|
+
device_roles = DynamicModelMultipleChoiceField(
|
|
90
|
+
queryset=DeviceRole.objects.all(), required=False,
|
|
91
|
+
label='Device roles', help_text='Match any selected role. Empty means any role.',
|
|
92
|
+
)
|
|
93
|
+
applicability_tags = DynamicModelMultipleChoiceField(
|
|
94
|
+
queryset=Tag.objects.none(), required=False, label='Device tags',
|
|
95
|
+
help_text='Match any selected tag. Empty means no tag restriction.',
|
|
96
|
+
)
|
|
97
|
+
selected_fields = forms.ModelMultipleChoiceField(
|
|
98
|
+
queryset=CustomField.objects.none(), required=False,
|
|
99
|
+
help_text='Choose applicable fields, then arrange their display order below.',
|
|
100
|
+
)
|
|
101
|
+
field_order = forms.CharField(required=False, widget=forms.HiddenInput)
|
|
102
|
+
remembered_fields = forms.CharField(required=False, widget=forms.HiddenInput)
|
|
103
|
+
content_type = forms.ModelChoiceField(
|
|
104
|
+
queryset=ContentType.objects.filter(app_label='dcim', model='device'),
|
|
105
|
+
label='Object Type',
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
class Meta:
|
|
109
|
+
model = CustomFieldProfile
|
|
110
|
+
fields = ('title', 'content_type', 'display_order', 'enabled', 'field_mode',
|
|
111
|
+
'selected_fields', 'field_order', 'remembered_fields', 'device_types', 'device_roles',
|
|
112
|
+
'applicability_tags')
|
|
113
|
+
|
|
114
|
+
def __init__(self, *args, **kwargs):
|
|
115
|
+
super().__init__(*args, **kwargs)
|
|
116
|
+
if (self.is_bound and self.data.get('field_mode') == CustomFieldProfile.FIELD_MODE_ALL
|
|
117
|
+
and 'remembered_fields' in self.data and 'selected_fields' not in self.data):
|
|
118
|
+
try:
|
|
119
|
+
remembered = self._parse_field_ids(self.data.get('remembered_fields'))
|
|
120
|
+
except (ValueError, TypeError):
|
|
121
|
+
pass # clean() reports malformed remembered state without saving it.
|
|
122
|
+
else:
|
|
123
|
+
# All disables the visible selector. Rebind its explicit remembered
|
|
124
|
+
# values so native field validation/permission restrictions still
|
|
125
|
+
# apply, and a failed form redisplays the pending choices.
|
|
126
|
+
self.data = self.data.copy()
|
|
127
|
+
if hasattr(self.data, 'setlist'):
|
|
128
|
+
self.data.setlist('selected_fields', [str(pk) for pk in remembered])
|
|
129
|
+
else:
|
|
130
|
+
self.data['selected_fields'] = remembered
|
|
131
|
+
device_ct = self.fields['content_type'].queryset.first()
|
|
132
|
+
self.fields['applicability_tags'].queryset = Tag.objects.filter(
|
|
133
|
+
Q(object_types=device_ct) | Q(object_types__isnull=True)).distinct()
|
|
134
|
+
self.fields['applicability_tags'].query_params = {'for_object_type_id': device_ct.pk}
|
|
135
|
+
self.fields['selected_fields'].queryset = CustomField.objects.filter(
|
|
136
|
+
object_types__app_label='dcim', object_types__model='device',
|
|
137
|
+
).exclude(status=CustomFieldStatusChoices.STATUS_DELETING).distinct()
|
|
138
|
+
self.fields['selected_fields'].widget.attrs['class'] = 'form-select'
|
|
139
|
+
ordered = []
|
|
140
|
+
if not self.instance.pk:
|
|
141
|
+
self.initial.setdefault('content_type', self.fields['content_type'].queryset.first())
|
|
142
|
+
self.initial.setdefault('field_mode', CustomFieldProfile.FIELD_MODE_ALL)
|
|
143
|
+
else:
|
|
144
|
+
ordered = list(self.instance.profile_fields.values_list('field_id', flat=True))
|
|
145
|
+
self.initial.setdefault('selected_fields', ordered)
|
|
146
|
+
self.initial.setdefault('field_order', json.dumps(ordered))
|
|
147
|
+
self.initial.setdefault('remembered_fields', json.dumps(ordered))
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _parse_field_ids(raw):
|
|
151
|
+
ids = json.loads(raw)
|
|
152
|
+
if (not isinstance(ids, list) or any(type(pk) is not int or pk <= 0 for pk in ids)
|
|
153
|
+
or len(ids) != len(set(ids))):
|
|
154
|
+
raise ValueError
|
|
155
|
+
return ids
|
|
156
|
+
|
|
157
|
+
def full_clean(self):
|
|
158
|
+
# Dynamic fields narrow their queryset to bound values when constructing
|
|
159
|
+
# BoundFields. Capture native permission restrictions before that happens.
|
|
160
|
+
self.rule_authority = {name: self.fields[name].queryset.all() for name in self.rule_fields}
|
|
161
|
+
super().full_clean()
|
|
162
|
+
|
|
163
|
+
def clean(self):
|
|
164
|
+
super().clean()
|
|
165
|
+
cleaned = self.cleaned_data
|
|
166
|
+
if self.instance.pk:
|
|
167
|
+
for name in self.rule_fields:
|
|
168
|
+
# Native views restrict these querysets by the editor's authority.
|
|
169
|
+
# A hidden/inaccessible saved reference must not become an empty rule.
|
|
170
|
+
existing = getattr(self.instance, name).values_list('pk', flat=True)
|
|
171
|
+
if set(existing) - set(self.rule_authority[name].values_list('pk', flat=True)):
|
|
172
|
+
self.add_error(name, 'This profile has saved references you cannot access. '
|
|
173
|
+
'Ask an administrator to review its applicability.')
|
|
174
|
+
self.ordered_field_ids = None
|
|
175
|
+
selected_mode = cleaned.get('field_mode') == CustomFieldProfile.FIELD_MODE_SELECTED
|
|
176
|
+
if not selected_mode and 'remembered_fields' not in self.data:
|
|
177
|
+
# Clients without an explicit remembered payload preserve saved state.
|
|
178
|
+
return cleaned
|
|
179
|
+
selected = cleaned.get('selected_fields')
|
|
180
|
+
if selected is None:
|
|
181
|
+
return cleaned
|
|
182
|
+
if selected_mode and not selected:
|
|
183
|
+
self.add_error('selected_fields', 'Select at least one applicable field.')
|
|
184
|
+
selected_ids = {field.pk for field in selected}
|
|
185
|
+
if 'remembered_fields' in self.data:
|
|
186
|
+
try:
|
|
187
|
+
remembered = self._parse_field_ids(cleaned.get('remembered_fields'))
|
|
188
|
+
if set(remembered) != selected_ids:
|
|
189
|
+
raise ValueError
|
|
190
|
+
except (ValueError, TypeError):
|
|
191
|
+
self.add_error('remembered_fields', 'Remembered fields must contain each selected field exactly once. '
|
|
192
|
+
'Review the selection and try again.')
|
|
193
|
+
raw_selection = (self.data.getlist('selected_fields') if hasattr(self.data, 'getlist')
|
|
194
|
+
else self.data.get('selected_fields', []))
|
|
195
|
+
if len(raw_selection) != len(set(raw_selection)):
|
|
196
|
+
self.add_error('selected_fields', 'Select each field only once.')
|
|
197
|
+
try:
|
|
198
|
+
ordered = self._parse_field_ids(cleaned.get('field_order') or '[]')
|
|
199
|
+
if set(ordered) != selected_ids:
|
|
200
|
+
raise ValueError
|
|
201
|
+
except (ValueError, TypeError):
|
|
202
|
+
self.add_error('field_order', 'Field order must contain each selected field exactly once.')
|
|
203
|
+
else:
|
|
204
|
+
self.ordered_field_ids = ordered
|
|
205
|
+
return cleaned
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def applies_to_all_devices(self):
|
|
209
|
+
if self.is_bound:
|
|
210
|
+
return not any(self.data.get(name) for name in self.rule_fields)
|
|
211
|
+
return not any(self.initial.get(name) for name in self.rule_fields)
|
|
212
|
+
|
|
213
|
+
def _guard_save(self, using):
|
|
214
|
+
# Split saves must keep these locks through model.save() and save_m2m().
|
|
215
|
+
# The normal native edit view already supplies that transaction.
|
|
216
|
+
if not transaction.get_connection(using).in_atomic_block:
|
|
217
|
+
raise AbortRequest('Save the profile and its field selection in one transaction.')
|
|
218
|
+
field_ids = self.ordered_field_ids
|
|
219
|
+
if field_ids is not None:
|
|
220
|
+
lock_profile_fields(field_ids, using)
|
|
221
|
+
if self.instance.pk:
|
|
222
|
+
CustomFieldProfile.objects.using(using).select_for_update().get(pk=self.instance.pk)
|
|
223
|
+
if field_ids is not None:
|
|
224
|
+
applicable_ids = set(CustomField.objects.using(using).filter(
|
|
225
|
+
pk__in=field_ids, object_types__app_label='dcim', object_types__model='device',
|
|
226
|
+
).exclude(status=CustomFieldStatusChoices.STATUS_DELETING).values_list('pk', flat=True))
|
|
227
|
+
if set(field_ids) != applicable_ids:
|
|
228
|
+
raise AbortRequest('The selected custom fields changed while this form was open. '
|
|
229
|
+
'Review the field selection and try again.')
|
|
230
|
+
|
|
231
|
+
def _save_m2m(self):
|
|
232
|
+
using = router.db_for_write(type(self.instance), instance=self.instance)
|
|
233
|
+
self._guard_save(using)
|
|
234
|
+
super()._save_m2m()
|
|
235
|
+
if self.ordered_field_ids is not None:
|
|
236
|
+
self.instance.profile_fields.all().delete()
|
|
237
|
+
ProfileField.objects.bulk_create([
|
|
238
|
+
ProfileField(profile=self.instance, field_id=pk, position=position)
|
|
239
|
+
for position, pk in enumerate(self.ordered_field_ids)
|
|
240
|
+
])
|
|
241
|
+
self.instance.__dict__.pop('_profile_form_save_guard', None)
|
|
242
|
+
|
|
243
|
+
def save(self, commit=True):
|
|
244
|
+
# Guard before the model UPDATE too: waiting until save_m2m() would
|
|
245
|
+
# acquire the profile lock before native field locks and invert cleanup.
|
|
246
|
+
self.instance._profile_form_save_guard = self._guard_save
|
|
247
|
+
if not commit:
|
|
248
|
+
return super().save(commit=False)
|
|
249
|
+
using = router.db_for_write(type(self.instance), instance=self.instance)
|
|
250
|
+
with transaction.atomic(using=using):
|
|
251
|
+
return super().save(commit=True)
|
|
File without changes
|
netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/management/commands/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Read-only native field resolution and reusable Device profile matching."""
|
|
2
|
+
from dcim.models import Device
|
|
3
|
+
from extras.choices import CustomFieldStatusChoices
|
|
4
|
+
from extras.models import CustomField
|
|
5
|
+
|
|
6
|
+
from .models import CustomFieldProfile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_matching_profiles(device):
|
|
10
|
+
"""AND between configured criteria, OR within each, in profile order."""
|
|
11
|
+
if not isinstance(device, Device):
|
|
12
|
+
return []
|
|
13
|
+
candidates = CustomFieldProfile.objects.filter(
|
|
14
|
+
enabled=True, content_type__app_label='dcim', content_type__model='device',
|
|
15
|
+
).prefetch_related('device_types', 'device_roles', 'applicability_tags')
|
|
16
|
+
device_tags = set(device.tags.values_list('pk', flat=True))
|
|
17
|
+
result = []
|
|
18
|
+
for profile in candidates:
|
|
19
|
+
types = {item.pk for item in profile.device_types.all()}
|
|
20
|
+
roles = {item.pk for item in profile.device_roles.all()}
|
|
21
|
+
tags = {item.pk for item in profile.applicability_tags.all()}
|
|
22
|
+
if ((not types or device.device_type_id in types)
|
|
23
|
+
and (not roles or device.role_id in roles)
|
|
24
|
+
and (not tags or tags.intersection(device_tags))):
|
|
25
|
+
result.append(profile)
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_profile_filter_summary(profile):
|
|
30
|
+
"""Return plain text for native escaping by tables/templates."""
|
|
31
|
+
criteria = []
|
|
32
|
+
for label, name in (('Device Type', 'device_types'), ('Role', 'device_roles'),
|
|
33
|
+
('Tag', 'applicability_tags')):
|
|
34
|
+
values = [str(item) for item in getattr(profile, name).all()]
|
|
35
|
+
if values:
|
|
36
|
+
criteria.append(f'{label}: (' + ' OR '.join(values) + ')')
|
|
37
|
+
return ' AND '.join(criteria) or 'Applies to all Devices'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_profile_fields(profile):
|
|
41
|
+
"""Resolve current active native fields without writing native definitions."""
|
|
42
|
+
if (profile.content_type.app_label, profile.content_type.model) != ('dcim', 'device'):
|
|
43
|
+
return []
|
|
44
|
+
# Native resolution owns ordering, while a fresh applicability/status guard
|
|
45
|
+
# excludes stale cached entries even after direct through-table/status writes.
|
|
46
|
+
database = profile._state.db or 'default'
|
|
47
|
+
live_ids = set(CustomField.objects.using(database).filter(
|
|
48
|
+
object_types__app_label='dcim', object_types__model='device',
|
|
49
|
+
status=CustomFieldStatusChoices.STATUS_ACTIVE,
|
|
50
|
+
).values_list('pk', flat=True))
|
|
51
|
+
native = [field for field in CustomField.objects.db_manager(database).get_for_model(Device)
|
|
52
|
+
if field.pk in live_ids]
|
|
53
|
+
if profile.field_mode == CustomFieldProfile.FIELD_MODE_ALL:
|
|
54
|
+
return native
|
|
55
|
+
active = {field.pk: field for field in native}
|
|
56
|
+
return [active[entry.field_id] for entry in profile.profile_fields.all() if entry.field_id in active]
|