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.
Files changed (56) hide show
  1. netbox_custom_field_profiles-0.1.0/LICENSE +21 -0
  2. netbox_custom_field_profiles-0.1.0/PKG-INFO +156 -0
  3. netbox_custom_field_profiles-0.1.0/README.md +116 -0
  4. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/__init__.py +24 -0
  5. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/__init__.py +0 -0
  6. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/serializers.py +12 -0
  7. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/urls.py +3 -0
  8. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/api/views.py +1 -0
  9. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/forms.py +251 -0
  10. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/management/__init__.py +0 -0
  11. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/management/commands/__init__.py +0 -0
  12. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/matching.py +56 -0
  13. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/metadata.py +18 -0
  14. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/middleware.py +48 -0
  15. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/0001_initial.py +70 -0
  16. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/0002_profiles.py +63 -0
  17. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/0003_retire_legacy_configuration.py +44 -0
  18. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/migrations/__init__.py +0 -0
  19. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/models.py +59 -0
  20. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/navigation.py +19 -0
  21. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/presentation.py +65 -0
  22. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/signals.py +103 -0
  23. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/static/netbox_custom_field_profiles/profile_fields.js +74 -0
  24. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tables.py +19 -0
  25. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/template_content.py +4 -0
  26. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/customfieldprofile.html +20 -0
  27. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/customfieldprofile_edit.html +41 -0
  28. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/device_parameters.html +75 -0
  29. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/device_parameters_edit.html +23 -0
  30. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templates/netbox_custom_field_profiles/parameter_value.html +19 -0
  31. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templatetags/__init__.py +0 -0
  32. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/templatetags/parameter_tags.py +16 -0
  33. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/__init__.py +1 -0
  34. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/runner.py +26 -0
  35. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_lifecycle.py +389 -0
  36. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_matching.py +165 -0
  37. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameter_editing.py +269 -0
  38. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameter_permissions.py +349 -0
  39. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameter_rendering.py +260 -0
  40. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_parameters.py +192 -0
  41. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_permissions.py +165 -0
  42. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_plugin_documentation.py +88 -0
  43. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_preservation.py +156 -0
  44. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_schema.py +95 -0
  45. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_selection.py +349 -0
  46. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/tests/test_tracer.py +123 -0
  47. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/urls.py +10 -0
  48. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles/views.py +95 -0
  49. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/PKG-INFO +156 -0
  50. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/SOURCES.txt +54 -0
  51. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/dependency_links.txt +1 -0
  52. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/entry_points.txt +2 -0
  53. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/requires.txt +4 -0
  54. netbox_custom_field_profiles-0.1.0/netbox_custom_field_profiles.egg-info/top_level.txt +1 -0
  55. netbox_custom_field_profiles-0.1.0/pyproject.toml +35 -0
  56. 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
+ ![Device Custom Fields tab showing Sandbox properties and Test properties sections with the Edit Custom Fields action](https://raw.githubusercontent.com/mwallraf/netbox-custom-params-tab/v0.1.0/docs/screenshots/parameters-tab.png)
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
+ ![Custom Field Profile editor showing selected fields, a Device tag rule and ordering controls](https://raw.githubusercontent.com/mwallraf/netbox-custom-params-tab/v0.1.0/docs/screenshots/profile-configuration.png)
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
+ ![Device Custom Fields tab showing Sandbox properties and Test properties sections with the Edit Custom Fields action](https://raw.githubusercontent.com/mwallraf/netbox-custom-params-tab/v0.1.0/docs/screenshots/parameters-tab.png)
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
+ ![Custom Field Profile editor showing selected fields, a Device tag rule and ordering controls](https://raw.githubusercontent.com/mwallraf/netbox-custom-params-tab/v0.1.0/docs/screenshots/profile-configuration.png)
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
@@ -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,3 @@
1
+ """Keep NetBox's API discovery importable without legacy configuration routes."""
2
+
3
+ urlpatterns = []
@@ -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)
@@ -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]