netbox-change-control 0.3.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 (110) hide show
  1. netbox_change_control-0.3.0/LICENSE +21 -0
  2. netbox_change_control-0.3.0/PKG-INFO +159 -0
  3. netbox_change_control-0.3.0/README.md +135 -0
  4. netbox_change_control-0.3.0/netbox_change_control/__init__.py +87 -0
  5. netbox_change_control-0.3.0/netbox_change_control/api/__init__.py +0 -0
  6. netbox_change_control-0.3.0/netbox_change_control/api/serializers.py +251 -0
  7. netbox_change_control-0.3.0/netbox_change_control/api/urls.py +14 -0
  8. netbox_change_control-0.3.0/netbox_change_control/api/views.py +173 -0
  9. netbox_change_control-0.3.0/netbox_change_control/automerge.py +166 -0
  10. netbox_change_control-0.3.0/netbox_change_control/batching.py +102 -0
  11. netbox_change_control-0.3.0/netbox_change_control/checks.py +368 -0
  12. netbox_change_control-0.3.0/netbox_change_control/choices.py +110 -0
  13. netbox_change_control-0.3.0/netbox_change_control/conflicts.py +61 -0
  14. netbox_change_control-0.3.0/netbox_change_control/dashboard.py +65 -0
  15. netbox_change_control-0.3.0/netbox_change_control/diffs.py +133 -0
  16. netbox_change_control-0.3.0/netbox_change_control/events.py +83 -0
  17. netbox_change_control-0.3.0/netbox_change_control/filtersets.py +377 -0
  18. netbox_change_control-0.3.0/netbox_change_control/forms.py +434 -0
  19. netbox_change_control-0.3.0/netbox_change_control/jobs.py +51 -0
  20. netbox_change_control-0.3.0/netbox_change_control/migrations/0001_initial.py +203 -0
  21. netbox_change_control-0.3.0/netbox_change_control/migrations/0002_policy_condition_state.py +18 -0
  22. netbox_change_control-0.3.0/netbox_change_control/migrations/0003_changerequest_ref.py +18 -0
  23. netbox_change_control-0.3.0/netbox_change_control/migrations/0004_alter_changerequest_options.py +17 -0
  24. netbox_change_control-0.3.0/netbox_change_control/migrations/0005_changerequest_cached_conflicted_and_more.py +68 -0
  25. netbox_change_control-0.3.0/netbox_change_control/migrations/0006_remove_changerequestpolicy_created_and_more.py +21 -0
  26. netbox_change_control-0.3.0/netbox_change_control/migrations/0007_alter_changerequestpolicy_options.py +41 -0
  27. netbox_change_control-0.3.0/netbox_change_control/migrations/__init__.py +0 -0
  28. netbox_change_control-0.3.0/netbox_change_control/models/__init__.py +4 -0
  29. netbox_change_control-0.3.0/netbox_change_control/models/checks.py +94 -0
  30. netbox_change_control-0.3.0/netbox_change_control/models/comments.py +137 -0
  31. netbox_change_control-0.3.0/netbox_change_control/models/policies.py +213 -0
  32. netbox_change_control-0.3.0/netbox_change_control/models/requests.py +608 -0
  33. netbox_change_control-0.3.0/netbox_change_control/navigation.py +67 -0
  34. netbox_change_control-0.3.0/netbox_change_control/notifications.py +92 -0
  35. netbox_change_control-0.3.0/netbox_change_control/permissions.py +57 -0
  36. netbox_change_control-0.3.0/netbox_change_control/policy.py +447 -0
  37. netbox_change_control-0.3.0/netbox_change_control/search.py +114 -0
  38. netbox_change_control-0.3.0/netbox_change_control/signal_receivers.py +532 -0
  39. netbox_change_control-0.3.0/netbox_change_control/tables.py +178 -0
  40. netbox_change_control-0.3.0/netbox_change_control/template_content.py +121 -0
  41. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/changerequest.html +308 -0
  42. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/changerequest_changes.html +151 -0
  43. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/changerequest_reviews.html +14 -0
  44. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/branch_change_request.html +20 -0
  45. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/branch_change_request_body.html +23 -0
  46. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/branch_change_request_card.html +21 -0
  47. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/branch_no_request.html +20 -0
  48. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/branch_no_request_body.html +8 -0
  49. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/branch_no_request_card.html +19 -0
  50. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/comment_actions.html +21 -0
  51. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/review_form.html +39 -0
  52. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/inc/rule_reviewers.html +24 -0
  53. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/mergecheck.html +35 -0
  54. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/policy.html +73 -0
  55. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/policyrule.html +53 -0
  56. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/review.html +45 -0
  57. netbox_change_control-0.3.0/netbox_change_control/templates/netbox_change_control/widgets/my_reviews.html +21 -0
  58. netbox_change_control-0.3.0/netbox_change_control/tests/__init__.py +0 -0
  59. netbox_change_control-0.3.0/netbox_change_control/tests/base.py +151 -0
  60. netbox_change_control-0.3.0/netbox_change_control/tests/test_api.py +326 -0
  61. netbox_change_control-0.3.0/netbox_change_control/tests/test_automatic_behaviors.py +271 -0
  62. netbox_change_control-0.3.0/netbox_change_control/tests/test_automerge_once.py +116 -0
  63. netbox_change_control-0.3.0/netbox_change_control/tests/test_batching.py +178 -0
  64. netbox_change_control-0.3.0/netbox_change_control/tests/test_branch_deletion.py +258 -0
  65. netbox_change_control-0.3.0/netbox_change_control/tests/test_branch_page.py +237 -0
  66. netbox_change_control-0.3.0/netbox_change_control/tests/test_cached_state.py +305 -0
  67. netbox_change_control-0.3.0/netbox_change_control/tests/test_check_changelog.py +105 -0
  68. netbox_change_control-0.3.0/netbox_change_control/tests/test_checks.py +837 -0
  69. netbox_change_control-0.3.0/netbox_change_control/tests/test_comment_attribution.py +234 -0
  70. netbox_change_control-0.3.0/netbox_change_control/tests/test_comments.py +376 -0
  71. netbox_change_control-0.3.0/netbox_change_control/tests/test_conditions.py +205 -0
  72. netbox_change_control-0.3.0/netbox_change_control/tests/test_conflicts.py +253 -0
  73. netbox_change_control-0.3.0/netbox_change_control/tests/test_documented_constraints.py +143 -0
  74. netbox_change_control-0.3.0/netbox_change_control/tests/test_documented_permissions.py +103 -0
  75. netbox_change_control-0.3.0/netbox_change_control/tests/test_documented_settings.py +46 -0
  76. netbox_change_control-0.3.0/netbox_change_control/tests/test_events.py +109 -0
  77. netbox_change_control-0.3.0/netbox_change_control/tests/test_extensibility.py +222 -0
  78. netbox_change_control-0.3.0/netbox_change_control/tests/test_filtersets.py +317 -0
  79. netbox_change_control-0.3.0/netbox_change_control/tests/test_lifecycle_actions.py +462 -0
  80. netbox_change_control-0.3.0/netbox_change_control/tests/test_list_actions.py +100 -0
  81. netbox_change_control-0.3.0/netbox_change_control/tests/test_markdown.py +115 -0
  82. netbox_change_control-0.3.0/netbox_change_control/tests/test_notifications.py +145 -0
  83. netbox_change_control-0.3.0/netbox_change_control/tests/test_permissions.py +57 -0
  84. netbox_change_control-0.3.0/netbox_change_control/tests/test_policy.py +285 -0
  85. netbox_change_control-0.3.0/netbox_change_control/tests/test_protect_main.py +279 -0
  86. netbox_change_control-0.3.0/netbox_change_control/tests/test_readiness.py +56 -0
  87. netbox_change_control-0.3.0/netbox_change_control/tests/test_reviewer_display.py +180 -0
  88. netbox_change_control-0.3.0/netbox_change_control/tests/test_scope.py +244 -0
  89. netbox_change_control-0.3.0/netbox_change_control/tests/test_search.py +147 -0
  90. netbox_change_control-0.3.0/netbox_change_control/tests/test_status.py +64 -0
  91. netbox_change_control-0.3.0/netbox_change_control/tests/test_templates.py +190 -0
  92. netbox_change_control-0.3.0/netbox_change_control/tests/test_validators.py +83 -0
  93. netbox_change_control-0.3.0/netbox_change_control/tests/test_views.py +150 -0
  94. netbox_change_control-0.3.0/netbox_change_control/tests/test_windows.py +441 -0
  95. netbox_change_control-0.3.0/netbox_change_control/urls.py +74 -0
  96. netbox_change_control-0.3.0/netbox_change_control/validators.py +131 -0
  97. netbox_change_control-0.3.0/netbox_change_control/views/__init__.py +14 -0
  98. netbox_change_control-0.3.0/netbox_change_control/views/bulk.py +80 -0
  99. netbox_change_control-0.3.0/netbox_change_control/views/checks.py +73 -0
  100. netbox_change_control-0.3.0/netbox_change_control/views/comments.py +191 -0
  101. netbox_change_control-0.3.0/netbox_change_control/views/policies.py +86 -0
  102. netbox_change_control-0.3.0/netbox_change_control/views/requests.py +279 -0
  103. netbox_change_control-0.3.0/netbox_change_control/views/reviews.py +68 -0
  104. netbox_change_control-0.3.0/netbox_change_control.egg-info/PKG-INFO +159 -0
  105. netbox_change_control-0.3.0/netbox_change_control.egg-info/SOURCES.txt +108 -0
  106. netbox_change_control-0.3.0/netbox_change_control.egg-info/dependency_links.txt +1 -0
  107. netbox_change_control-0.3.0/netbox_change_control.egg-info/requires.txt +1 -0
  108. netbox_change_control-0.3.0/netbox_change_control.egg-info/top_level.txt +1 -0
  109. netbox_change_control-0.3.0/pyproject.toml +58 -0
  110. netbox_change_control-0.3.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antoine Keranflech
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,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-change-control
3
+ Version: 0.3.0
4
+ Summary: Policy-driven change control and mandatory review for NetBox branches
5
+ Author-email: Antoine Keranflech <antoine.keranflech@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/antoinekh/netbox-change-control
8
+ Project-URL: Documentation, https://github.com/antoinekh/netbox-change-control/blob/master/docs/index.md
9
+ Project-URL: Source, https://github.com/antoinekh/netbox-change-control
10
+ Project-URL: Changelog, https://github.com/antoinekh/netbox-change-control/blob/master/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/antoinekh/netbox-change-control/issues
12
+ Keywords: netbox,netbox-plugin,change-management,change-control,branching,approval-workflow,network-automation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Django
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Intended Audience :: System Administrators
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: netboxlabs-netbox-branching<1.2,>=1.1.3
23
+ Dynamic: license-file
24
+
25
+ <div align="center">
26
+ <img src="docs/img/logo.svg" width="460" alt="netbox change control" />
27
+ <p><strong>Policy-driven change control and mandatory review for NetBox branches</strong></p>
28
+ <p>change requests &bull; policies &bull; checks &bull; comments</p>
29
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" />
30
+ <img src="https://img.shields.io/badge/NetBox-%3E%3D%204.6.9%2C%20%3C%204.7-00857d" alt="NetBox compatibility" />
31
+ <img src="https://img.shields.io/badge/netbox--branching-%3E%3D%201.1.3-00857d" alt="netbox-branching compatibility" />
32
+ <img src="https://img.shields.io/badge/python-%3E%3D%203.12-blue" alt="Python version" />
33
+ <p>
34
+ <strong><a href="docs/index.md">Documentation</a></strong> |
35
+ <strong><a href="docs/installation.md">Install</a></strong> |
36
+ <strong><a href="docs/policies.md">Policies</a></strong> |
37
+ <strong><a href="docs/checks.md">Checks</a></strong> |
38
+ <strong><a href="CHANGELOG.md">Changelog</a></strong>
39
+ </p>
40
+ </div>
41
+
42
+ This plugin builds on [netbox-branching](https://github.com/netboxlabs/netbox-branching). A branch stages your changes; this plugin decides who must approve them and refuses the merge until they have.
43
+
44
+ The goal is change control that is **policy driven**: who must approve a change is decided by the objects it touches, not by whoever opened it. Around that sit two extension points, so the same gate can be driven by more than people. Pre-merge **checks** are pluggable, and an event fires on every status change, so a change request can call an external system, wait for a CI result, or ask a model to review the diff before anyone merges it. See [writing your own checks](docs/custom-checks.md), which includes an AI reviewer and a CI reporter.
45
+
46
+ It takes ideas from **[NetBox Labs change management](https://netboxlabs.com/docs/changes/)**, for policies and rules governing who must review a branch; from **GitHub**, for status checks that gate a merge independently of human approval and for review comments anchored to a specific change; and from **[Infrahub](https://docs.infrahub.app/topics/proposed-change)** by OpsMill, for treating a proposed change as a first-class object that carries its own validation.
47
+
48
+ > [!NOTE]
49
+ > **Independent community plugin.** Free, MIT licensed, not official, not certified and not endorsed by NetBox Labs. It bundles no netbox-branching code. You install that package yourself, and its own licence governs how you may use it.
50
+ >
51
+ > No commercial support. If you need a supported product, use the [NetBox Labs](https://netboxlabs.com/docs/changes/) change management plugin.
52
+
53
+ > [!WARNING]
54
+ > **Not stable yet.** The version is below 1.0. Models, settings and the REST API can still change between releases. Read the [changelog](CHANGELOG.md) before you upgrade.
55
+
56
+ <p align="center">
57
+ <a href="#how-it-works">How it works</a> |
58
+ <a href="#documentation">Documentation</a> |
59
+ <a href="#features">Features</a> |
60
+ <a href="#requirements">Requirements</a> |
61
+ <a href="#quick-install">Quick install</a>
62
+ </p>
63
+
64
+ <p align="center">
65
+ <img src="docs/img/change-request-detail.png" width="800" alt="A change request, showing the approval status, the pre-merge checks and the conflict banner" />
66
+ </p>
67
+
68
+ ## How it works
69
+
70
+ 1. Someone creates a branch and makes their changes inside it, as normal for netbox-branching.
71
+ 2. They open a **change request** against that branch.
72
+ 3. The plugin reads which object types the branch touches and attaches every **policy** whose scope matches. A policy can narrow further on the values of the objects themselves. The author cannot remove them.
73
+ 4. Each policy contains **rules**. A rule says how many approvals it needs and who may give them.
74
+ 5. Reviewers **approve**, request changes, or comment. They can also comment on one specific changed object.
75
+ 6. Independently, the **pre-merge checks** named by those policies run. A required check that is not passing blocks the merge on its own.
76
+ 7. Once every rule is satisfied and every required check passes, the **merge** button appears.
77
+ 8. After the merge, the request is marked completed.
78
+
79
+ Two gates guard the merge and they are independent: the people gate (policies and reviews) and the machine gate (checks). A change can be approved by every reviewer and still be refused by a check.
80
+
81
+ ## Documentation
82
+
83
+ Full documentation is in [`docs/`](docs/index.md).
84
+
85
+ | Page | Covers |
86
+ |---|---|
87
+ | [Installation and configuration](docs/installation.md) | Requirements, installing, and every setting. |
88
+ | [Policies and rules](docs/policies.md) | Scoping a policy and writing rules. |
89
+ | [Policy conditions](docs/policy-conditions.md) | Narrowing a policy on object values. |
90
+ | [Conflicts with main](docs/conflicts.md) | What counts as a real conflict, and how to resolve one. |
91
+ | [Change requests](docs/change-requests.md) | The lifecycle, and what survives a branch deletion. |
92
+ | [Reviews](docs/reviews.md) | Submitting reviews and commenting on individual changes. |
93
+ | [Pre-merge checks](docs/checks.md) | What checks are and which ship built in. |
94
+ | [Writing your own checks](docs/custom-checks.md) | The registry, an AI reviewer, and reporting from CI. |
95
+ | [Event rules](docs/event-rules.md) | Firing a webhook or a script on a change request. |
96
+ | [Merging, windows and auto-merge](docs/merging.md) | Change windows and automatic merging. |
97
+ | [Protecting main](docs/protect-main.md) | Requiring a branch, optionally for part of NetBox only. |
98
+ | [Automatic behaviours](docs/automation.md) | Stale reviews, reevaluation, notifications. |
99
+ | [Administration guide](docs/admin-guide.md) | Roles, the permission matrix, building policies, troubleshooting. |
100
+ | [Permissions](docs/permissions.md) | The short reference for every permission name. |
101
+ | [REST API](docs/api.md) | Every endpoint. |
102
+ | [Extending this plugin](docs/extending.md) | How another plugin adds content and checks. |
103
+ | [Design](docs/design.md) | Why it is built this way. |
104
+
105
+ ## Features
106
+
107
+ | Feature | Status |
108
+ |---|---|
109
+ | Policies containing rules with a minimum approval count | Done |
110
+ | Rules naming reviewer groups and individual reviewers | Done |
111
+ | Policies attached automatically, scope-matched from the branch contents, following it as it changes, and not selectable by the author | Done |
112
+ | Policy conditions, narrowing a policy on the values of the changed objects | Done |
113
+ | Change requests with status and priority | Done |
114
+ | Reviews with approve, request changes, and comment | Done |
115
+ | Per-change comments on the branch diff, with threaded replies, in Markdown | Done |
116
+ | Merge button appears once approved | Done, on the change request and on the branch |
117
+ | Status set to **completed** after a successful merge | Done |
118
+ | Pre-merge gate, enforced regardless of `protect_main` | Done |
119
+ | `protect_main` blocks direct edits outside a branch, optionally scoped | Done, with a bypass permission |
120
+ | Stale review detection when the branch changes | Done |
121
+ | Approval invalidation when the branch changes after approval | Done |
122
+ | Policy reevaluation on rule, reviewer or group membership change | Done |
123
+ | Real conflicts with main, distinguished from a stale branching baseline | Done |
124
+ | Notifications to reviewers | Done, through NetBox's notification inbox |
125
+ | Pluggable pre-merge checks, in-process or reported over the REST API | Done |
126
+ | Change windows, with an override permission | Done |
127
+ | Automatic merge once every gate is satisfied | Done |
128
+ | Change request survives deletion of its branch | Done |
129
+ | REST API for every model | Done |
130
+
131
+ ## Requirements
132
+
133
+ | Component | Version |
134
+ |---|---|
135
+ | NetBox | `>= 4.6.9, < 4.7` |
136
+ | netbox-branching | `>= 1.1.3, < 1.2` |
137
+ | Python | `>= 3.12` |
138
+
139
+ ## Quick install
140
+
141
+ ```python
142
+ # configuration/plugins.py
143
+ PLUGINS = [
144
+ 'netbox_change_control',
145
+ 'netbox_branching', # must stay last
146
+ ]
147
+
148
+ PLUGINS_CONFIG = {
149
+ 'netbox_branching': {
150
+ 'exempt_models': ['netbox_change_control.*'], # required
151
+ },
152
+ }
153
+ ```
154
+
155
+ ```bash
156
+ ./manage.py migrate netbox_change_control
157
+ ```
158
+
159
+ See [Installation and configuration](docs/installation.md) for the detail, including why `exempt_models` is not optional.
@@ -0,0 +1,135 @@
1
+ <div align="center">
2
+ <img src="docs/img/logo.svg" width="460" alt="netbox change control" />
3
+ <p><strong>Policy-driven change control and mandatory review for NetBox branches</strong></p>
4
+ <p>change requests &bull; policies &bull; checks &bull; comments</p>
5
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" />
6
+ <img src="https://img.shields.io/badge/NetBox-%3E%3D%204.6.9%2C%20%3C%204.7-00857d" alt="NetBox compatibility" />
7
+ <img src="https://img.shields.io/badge/netbox--branching-%3E%3D%201.1.3-00857d" alt="netbox-branching compatibility" />
8
+ <img src="https://img.shields.io/badge/python-%3E%3D%203.12-blue" alt="Python version" />
9
+ <p>
10
+ <strong><a href="docs/index.md">Documentation</a></strong> |
11
+ <strong><a href="docs/installation.md">Install</a></strong> |
12
+ <strong><a href="docs/policies.md">Policies</a></strong> |
13
+ <strong><a href="docs/checks.md">Checks</a></strong> |
14
+ <strong><a href="CHANGELOG.md">Changelog</a></strong>
15
+ </p>
16
+ </div>
17
+
18
+ This plugin builds on [netbox-branching](https://github.com/netboxlabs/netbox-branching). A branch stages your changes; this plugin decides who must approve them and refuses the merge until they have.
19
+
20
+ The goal is change control that is **policy driven**: who must approve a change is decided by the objects it touches, not by whoever opened it. Around that sit two extension points, so the same gate can be driven by more than people. Pre-merge **checks** are pluggable, and an event fires on every status change, so a change request can call an external system, wait for a CI result, or ask a model to review the diff before anyone merges it. See [writing your own checks](docs/custom-checks.md), which includes an AI reviewer and a CI reporter.
21
+
22
+ It takes ideas from **[NetBox Labs change management](https://netboxlabs.com/docs/changes/)**, for policies and rules governing who must review a branch; from **GitHub**, for status checks that gate a merge independently of human approval and for review comments anchored to a specific change; and from **[Infrahub](https://docs.infrahub.app/topics/proposed-change)** by OpsMill, for treating a proposed change as a first-class object that carries its own validation.
23
+
24
+ > [!NOTE]
25
+ > **Independent community plugin.** Free, MIT licensed, not official, not certified and not endorsed by NetBox Labs. It bundles no netbox-branching code. You install that package yourself, and its own licence governs how you may use it.
26
+ >
27
+ > No commercial support. If you need a supported product, use the [NetBox Labs](https://netboxlabs.com/docs/changes/) change management plugin.
28
+
29
+ > [!WARNING]
30
+ > **Not stable yet.** The version is below 1.0. Models, settings and the REST API can still change between releases. Read the [changelog](CHANGELOG.md) before you upgrade.
31
+
32
+ <p align="center">
33
+ <a href="#how-it-works">How it works</a> |
34
+ <a href="#documentation">Documentation</a> |
35
+ <a href="#features">Features</a> |
36
+ <a href="#requirements">Requirements</a> |
37
+ <a href="#quick-install">Quick install</a>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <img src="docs/img/change-request-detail.png" width="800" alt="A change request, showing the approval status, the pre-merge checks and the conflict banner" />
42
+ </p>
43
+
44
+ ## How it works
45
+
46
+ 1. Someone creates a branch and makes their changes inside it, as normal for netbox-branching.
47
+ 2. They open a **change request** against that branch.
48
+ 3. The plugin reads which object types the branch touches and attaches every **policy** whose scope matches. A policy can narrow further on the values of the objects themselves. The author cannot remove them.
49
+ 4. Each policy contains **rules**. A rule says how many approvals it needs and who may give them.
50
+ 5. Reviewers **approve**, request changes, or comment. They can also comment on one specific changed object.
51
+ 6. Independently, the **pre-merge checks** named by those policies run. A required check that is not passing blocks the merge on its own.
52
+ 7. Once every rule is satisfied and every required check passes, the **merge** button appears.
53
+ 8. After the merge, the request is marked completed.
54
+
55
+ Two gates guard the merge and they are independent: the people gate (policies and reviews) and the machine gate (checks). A change can be approved by every reviewer and still be refused by a check.
56
+
57
+ ## Documentation
58
+
59
+ Full documentation is in [`docs/`](docs/index.md).
60
+
61
+ | Page | Covers |
62
+ |---|---|
63
+ | [Installation and configuration](docs/installation.md) | Requirements, installing, and every setting. |
64
+ | [Policies and rules](docs/policies.md) | Scoping a policy and writing rules. |
65
+ | [Policy conditions](docs/policy-conditions.md) | Narrowing a policy on object values. |
66
+ | [Conflicts with main](docs/conflicts.md) | What counts as a real conflict, and how to resolve one. |
67
+ | [Change requests](docs/change-requests.md) | The lifecycle, and what survives a branch deletion. |
68
+ | [Reviews](docs/reviews.md) | Submitting reviews and commenting on individual changes. |
69
+ | [Pre-merge checks](docs/checks.md) | What checks are and which ship built in. |
70
+ | [Writing your own checks](docs/custom-checks.md) | The registry, an AI reviewer, and reporting from CI. |
71
+ | [Event rules](docs/event-rules.md) | Firing a webhook or a script on a change request. |
72
+ | [Merging, windows and auto-merge](docs/merging.md) | Change windows and automatic merging. |
73
+ | [Protecting main](docs/protect-main.md) | Requiring a branch, optionally for part of NetBox only. |
74
+ | [Automatic behaviours](docs/automation.md) | Stale reviews, reevaluation, notifications. |
75
+ | [Administration guide](docs/admin-guide.md) | Roles, the permission matrix, building policies, troubleshooting. |
76
+ | [Permissions](docs/permissions.md) | The short reference for every permission name. |
77
+ | [REST API](docs/api.md) | Every endpoint. |
78
+ | [Extending this plugin](docs/extending.md) | How another plugin adds content and checks. |
79
+ | [Design](docs/design.md) | Why it is built this way. |
80
+
81
+ ## Features
82
+
83
+ | Feature | Status |
84
+ |---|---|
85
+ | Policies containing rules with a minimum approval count | Done |
86
+ | Rules naming reviewer groups and individual reviewers | Done |
87
+ | Policies attached automatically, scope-matched from the branch contents, following it as it changes, and not selectable by the author | Done |
88
+ | Policy conditions, narrowing a policy on the values of the changed objects | Done |
89
+ | Change requests with status and priority | Done |
90
+ | Reviews with approve, request changes, and comment | Done |
91
+ | Per-change comments on the branch diff, with threaded replies, in Markdown | Done |
92
+ | Merge button appears once approved | Done, on the change request and on the branch |
93
+ | Status set to **completed** after a successful merge | Done |
94
+ | Pre-merge gate, enforced regardless of `protect_main` | Done |
95
+ | `protect_main` blocks direct edits outside a branch, optionally scoped | Done, with a bypass permission |
96
+ | Stale review detection when the branch changes | Done |
97
+ | Approval invalidation when the branch changes after approval | Done |
98
+ | Policy reevaluation on rule, reviewer or group membership change | Done |
99
+ | Real conflicts with main, distinguished from a stale branching baseline | Done |
100
+ | Notifications to reviewers | Done, through NetBox's notification inbox |
101
+ | Pluggable pre-merge checks, in-process or reported over the REST API | Done |
102
+ | Change windows, with an override permission | Done |
103
+ | Automatic merge once every gate is satisfied | Done |
104
+ | Change request survives deletion of its branch | Done |
105
+ | REST API for every model | Done |
106
+
107
+ ## Requirements
108
+
109
+ | Component | Version |
110
+ |---|---|
111
+ | NetBox | `>= 4.6.9, < 4.7` |
112
+ | netbox-branching | `>= 1.1.3, < 1.2` |
113
+ | Python | `>= 3.12` |
114
+
115
+ ## Quick install
116
+
117
+ ```python
118
+ # configuration/plugins.py
119
+ PLUGINS = [
120
+ 'netbox_change_control',
121
+ 'netbox_branching', # must stay last
122
+ ]
123
+
124
+ PLUGINS_CONFIG = {
125
+ 'netbox_branching': {
126
+ 'exempt_models': ['netbox_change_control.*'], # required
127
+ },
128
+ }
129
+ ```
130
+
131
+ ```bash
132
+ ./manage.py migrate netbox_change_control
133
+ ```
134
+
135
+ See [Installation and configuration](docs/installation.md) for the detail, including why `exempt_models` is not optional.
@@ -0,0 +1,87 @@
1
+ from netbox.plugins import PluginConfig, get_plugin_config
2
+
3
+ __version__ = '0.3.0'
4
+
5
+
6
+ class ChangeControlConfig(PluginConfig):
7
+ name = 'netbox_change_control'
8
+ verbose_name = 'NetBox Change Control'
9
+ description = 'Policy-driven change control and mandatory review for NetBox branches'
10
+ version = __version__
11
+ base_url = 'change-control'
12
+ # NetBox refuses to load the plugin outside this range, which is the only place the
13
+ # requirement is enforced rather than documented. The floor is 4.6.9 because that is what
14
+ # is tested; below 4.6.5 the initial migration cannot even apply, since it depends on
15
+ # extras.0140, and below 4.6.0 on core.0024 and users.0016 as well.
16
+ min_version = '4.6.9'
17
+ max_version = '4.6.99'
18
+ default_settings = { # noqa: RUF012
19
+ # Block writes to branching-supported models outside of a branch. Users holding the
20
+ # netbox_change_control.bypass_policy permission are exempt.
21
+ 'protect_main': False,
22
+ # Limit protect_main to specific models. Empty means every branching-supported model.
23
+ # Entries are 'app_label.modelname' or 'app_label.*', for example ['circuits.*'].
24
+ 'protect_main_scope': [],
25
+ # Refuse to merge a branch which has no approved change request. This is the core
26
+ # guarantee of the plugin; disable it only to troubleshoot.
27
+ 'enforce_merge_gate': True,
28
+ # Send a NetBox notification to the outstanding reviewers when a change request
29
+ # needs review, and to the requester when it is approved or rejected.
30
+ 'notify_reviewers': True,
31
+ # Where a branch page shows its change request. 'right_page' puts it in a card in
32
+ # the right-hand column, beside branching's own cards; 'alerts' puts it in a band
33
+ # across the top of the page, above them. Naming both shows both, and an empty list
34
+ # shows neither.
35
+ 'branch_page_placement': ['right_page'],
36
+ # Which built-in pre-merge checks to make available. True offers all of them, False
37
+ # none, and a list a subset. Registering only makes a check selectable: a policy
38
+ # decides where it applies, by naming it in its Checks field. Valid names:
39
+ # 'has-changes' the branch contains something to merge
40
+ # 'no-conflicts' no conflicts with main
41
+ # 'not-stale' the branch is recent enough to sync
42
+ # 'threads-resolved' every comment thread on the Changes tab is resolved
43
+ 'enable_builtin_checks': True,
44
+ # Checks reported by an external system through the REST API. Each is created as
45
+ # pending and blocks the merge until a result is reported. Entries are a name, or a
46
+ # (name, label) pair.
47
+ 'required_external_checks': [],
48
+ # Allow change requests to merge themselves once every gate is satisfied. Each
49
+ # request must also opt in individually. Turning this off disables the feature
50
+ # globally without editing any request, and stops the periodic job being registered.
51
+ 'enable_auto_merge': True,
52
+ # Minutes between automatic merge sweeps. This bounds how late a change window can
53
+ # fire: at 10, a window opening at 21:00 merges by 21:09. Lower it for tighter
54
+ # windows, at the cost of one Job record per run. A change window shorter than this
55
+ # interval may never merge automatically, and says so on the change request. Takes
56
+ # effect when the worker restarts.
57
+ 'auto_merge_interval': 10,
58
+ }
59
+
60
+ def ready(self):
61
+ super().ready()
62
+
63
+ from netbox_branching.models import Branch
64
+
65
+ # NetBox auto-imports a plugin's navigation, search and template_content modules, but
66
+ # not its dashboard module, so a widget is registered only if something imports it.
67
+ # Without this the My Reviews widget is missing from "Add widget", and a dashboard
68
+ # already holding one fails to load at all.
69
+ from . import dashboard, events, signal_receivers # noqa: F401
70
+
71
+ # Registering the job is what makes rqworker schedule it, so skipping the import
72
+ # when auto-merge is off means no periodic job and no Job records at all.
73
+ if get_plugin_config('netbox_change_control', 'enable_auto_merge'):
74
+ from . import jobs # noqa: F401
75
+ from .checks import register_builtin_checks
76
+ from .validators import require_approved_change_request
77
+
78
+ Branch.register_preaction_check(require_approved_change_request, 'merge')
79
+
80
+ # Registering makes a check available; a policy decides where it applies. True makes
81
+ # every built-in available, False none, and a list a subset.
82
+ selection = get_plugin_config('netbox_change_control', 'enable_builtin_checks')
83
+ if selection:
84
+ register_builtin_checks(list(selection) if isinstance(selection, (list, tuple, set)) else None)
85
+
86
+
87
+ config = ChangeControlConfig
@@ -0,0 +1,251 @@
1
+ from netbox.api.serializers import NetBoxModelSerializer
2
+ from rest_framework import serializers
3
+
4
+ from netbox_change_control.models import (
5
+ ChangeComment,
6
+ ChangeRequest,
7
+ MergeCheck,
8
+ Policy,
9
+ PolicyRule,
10
+ Review,
11
+ )
12
+
13
+ __all__ = (
14
+ 'ChangeCommentSerializer',
15
+ 'ChangeRequestSerializer',
16
+ 'MergeCheckSerializer',
17
+ 'PolicyRuleSerializer',
18
+ 'PolicySerializer',
19
+ 'ReviewSerializer',
20
+ )
21
+
22
+
23
+ class PolicySerializer(NetBoxModelSerializer):
24
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_change_control-api:policy-detail')
25
+
26
+ class Meta:
27
+ model = Policy
28
+ fields = (
29
+ 'id',
30
+ 'url',
31
+ 'display',
32
+ 'name',
33
+ 'description',
34
+ 'enabled',
35
+ 'weight',
36
+ 'object_types',
37
+ 'conditions',
38
+ 'condition_state',
39
+ 'checks',
40
+ 'comments',
41
+ 'tags',
42
+ 'custom_fields',
43
+ 'created',
44
+ 'last_updated',
45
+ )
46
+ brief_fields = ('id', 'url', 'display', 'name')
47
+
48
+
49
+ class PolicyRuleSerializer(NetBoxModelSerializer):
50
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_change_control-api:policyrule-detail')
51
+ policy = PolicySerializer(nested=True)
52
+
53
+ class Meta:
54
+ model = PolicyRule
55
+ fields = (
56
+ 'id',
57
+ 'url',
58
+ 'display',
59
+ 'policy',
60
+ 'name',
61
+ 'min_reviews',
62
+ 'groups',
63
+ 'users',
64
+ 'tags',
65
+ 'custom_fields',
66
+ 'created',
67
+ 'last_updated',
68
+ )
69
+ brief_fields = ('id', 'url', 'display', 'name', 'min_reviews')
70
+
71
+
72
+ class ChangeRequestSerializer(NetBoxModelSerializer):
73
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_change_control-api:changerequest-detail')
74
+ approved = serializers.BooleanField(source='is_approved', read_only=True)
75
+ branch_name = serializers.CharField(read_only=True)
76
+ branch_deleted = serializers.BooleanField(read_only=True)
77
+ ready_to_merge = serializers.BooleanField(source='is_ready_to_merge', read_only=True)
78
+ # Derived from the policy evaluation, so it is reported and never set. A writable status
79
+ # let a caller mark a request Completed, which is terminal and blocks its branch from ever
80
+ # merging. Use the abandon and reopen actions for the two transitions a person makes.
81
+ status = serializers.CharField(read_only=True)
82
+ merge_blocked_reason = serializers.CharField(read_only=True)
83
+ has_conflicts = serializers.BooleanField(read_only=True)
84
+
85
+ class Meta:
86
+ model = ChangeRequest
87
+ fields = (
88
+ 'id',
89
+ 'url',
90
+ 'display',
91
+ 'ref',
92
+ 'branch',
93
+ 'branch_name',
94
+ 'branch_deleted',
95
+ 'title',
96
+ 'description',
97
+ 'status',
98
+ 'priority',
99
+ 'requester',
100
+ 'approved',
101
+ 'ready_to_merge',
102
+ 'merge_blocked_reason',
103
+ 'has_conflicts',
104
+ 'scheduled_start',
105
+ 'scheduled_end',
106
+ 'auto_merge',
107
+ 'comments',
108
+ 'tags',
109
+ 'custom_fields',
110
+ 'created',
111
+ 'last_updated',
112
+ )
113
+ brief_fields = ('id', 'url', 'display', 'ref', 'title', 'status')
114
+
115
+
116
+ class ReviewSerializer(NetBoxModelSerializer):
117
+ """
118
+ A review is a personal statement, so the API records it as the caller and never as
119
+ somebody else.
120
+
121
+ `reviewer` is read-only and defaults to the requesting user. Leaving it writable let any
122
+ token holding `add_review` post an approval attributed to a colleague, which defeats the
123
+ merge gate with one request. The object form does not offer the field for the same reason.
124
+ """
125
+
126
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_change_control-api:review-detail')
127
+ reviewer = serializers.PrimaryKeyRelatedField(read_only=True)
128
+
129
+ class Meta:
130
+ model = Review
131
+ fields = (
132
+ 'id',
133
+ 'url',
134
+ 'display',
135
+ 'change_request',
136
+ 'reviewer',
137
+ 'decision',
138
+ 'comment',
139
+ 'tags',
140
+ 'custom_fields',
141
+ 'created',
142
+ 'last_updated',
143
+ )
144
+ brief_fields = ('id', 'url', 'display', 'decision')
145
+
146
+ def validate(self, attrs):
147
+ """
148
+ Attribute a new review to the caller.
149
+
150
+ A read-only field carrying a default is not enough, because DRF leaves a read-only
151
+ field out of validated_data entirely. The assignment also has to happen before
152
+ super(), since NetBox's ValidatedModelSerializer builds a model instance from these
153
+ attributes and calls full_clean() on it, which would reject the missing reviewer.
154
+ """
155
+ # `not self.nested` matters: a nested serializer's to_internal_value returns a model
156
+ # instance rather than a dict, and assigning into it raises TypeError.
157
+ if self.instance is None and not self.nested:
158
+ request = self.context.get('request')
159
+ if request is not None:
160
+ attrs['reviewer'] = request.user
161
+ return super().validate(attrs)
162
+
163
+
164
+ class MergeCheckSerializer(NetBoxModelSerializer):
165
+ """
166
+ External systems report a result by PATCHing status, summary and details_url onto an
167
+ existing check.
168
+ """
169
+
170
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_change_control-api:mergecheck-detail')
171
+ blocks_merge = serializers.BooleanField(read_only=True)
172
+ # A reporter must not decide whether its own check counts. Requiredness comes from the
173
+ # configuration or the policy, and the gate reads it from there rather than from this row,
174
+ # so a writable field here would only mislead.
175
+ required = serializers.BooleanField(read_only=True)
176
+ # The check belongs to the change request which created it. Moving it elsewhere would
177
+ # detach a result from what it was measuring.
178
+ change_request = serializers.PrimaryKeyRelatedField(read_only=True)
179
+
180
+ class Meta:
181
+ model = MergeCheck
182
+ fields = (
183
+ 'id',
184
+ 'url',
185
+ 'display',
186
+ 'change_request',
187
+ 'name',
188
+ 'label',
189
+ 'status',
190
+ 'required',
191
+ 'summary',
192
+ 'details_url',
193
+ 'completed',
194
+ 'blocks_merge',
195
+ 'tags',
196
+ 'custom_fields',
197
+ 'created',
198
+ 'last_updated',
199
+ )
200
+ brief_fields = ('id', 'url', 'display', 'name', 'status')
201
+
202
+
203
+ class ChangeCommentSerializer(NetBoxModelSerializer):
204
+ """
205
+ A comment is a personal statement, so the API records it as the caller and never as
206
+ somebody else.
207
+
208
+ `author` is read-only and defaults to the requesting user, for the same reason `reviewer`
209
+ is on ReviewSerializer. Leaving it writable let any token holding `add_changecomment` post
210
+ a comment attributed to a colleague, which is enough to fake a sign-off in the discussion
211
+ a reviewer reads before approving.
212
+ """
213
+
214
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_change_control-api:changecomment-detail')
215
+ author = serializers.PrimaryKeyRelatedField(read_only=True)
216
+
217
+ def validate(self, attrs):
218
+ """
219
+ Attribute a new comment to the caller.
220
+
221
+ A read-only field carrying a default is not enough, because DRF leaves a read-only
222
+ field out of validated_data entirely. The assignment also has to happen before
223
+ super(), since NetBox's ValidatedModelSerializer builds a model instance from these
224
+ attributes and calls full_clean() on it, which would reject the missing author.
225
+ """
226
+ # `not self.nested` matters: a nested serializer's to_internal_value returns a model
227
+ # instance rather than a dict, and assigning into it raises TypeError.
228
+ if self.instance is None and not self.nested:
229
+ request = self.context.get('request')
230
+ if request is not None:
231
+ attrs['author'] = request.user
232
+ return super().validate(attrs)
233
+
234
+ class Meta:
235
+ model = ChangeComment
236
+ fields = (
237
+ 'id',
238
+ 'url',
239
+ 'display',
240
+ 'change_request',
241
+ 'change_diff',
242
+ 'parent',
243
+ 'author',
244
+ 'text',
245
+ 'resolved',
246
+ 'tags',
247
+ 'custom_fields',
248
+ 'created',
249
+ 'last_updated',
250
+ )
251
+ brief_fields = ('id', 'url', 'display', 'text')