netbox-change-control 0.3.0__py3-none-any.whl
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_change_control/__init__.py +87 -0
- netbox_change_control/api/__init__.py +0 -0
- netbox_change_control/api/serializers.py +251 -0
- netbox_change_control/api/urls.py +14 -0
- netbox_change_control/api/views.py +173 -0
- netbox_change_control/automerge.py +166 -0
- netbox_change_control/batching.py +102 -0
- netbox_change_control/checks.py +368 -0
- netbox_change_control/choices.py +110 -0
- netbox_change_control/conflicts.py +61 -0
- netbox_change_control/dashboard.py +65 -0
- netbox_change_control/diffs.py +133 -0
- netbox_change_control/events.py +83 -0
- netbox_change_control/filtersets.py +377 -0
- netbox_change_control/forms.py +434 -0
- netbox_change_control/jobs.py +51 -0
- netbox_change_control/migrations/0001_initial.py +203 -0
- netbox_change_control/migrations/0002_policy_condition_state.py +18 -0
- netbox_change_control/migrations/0003_changerequest_ref.py +18 -0
- netbox_change_control/migrations/0004_alter_changerequest_options.py +17 -0
- netbox_change_control/migrations/0005_changerequest_cached_conflicted_and_more.py +68 -0
- netbox_change_control/migrations/0006_remove_changerequestpolicy_created_and_more.py +21 -0
- netbox_change_control/migrations/0007_alter_changerequestpolicy_options.py +41 -0
- netbox_change_control/migrations/__init__.py +0 -0
- netbox_change_control/models/__init__.py +4 -0
- netbox_change_control/models/checks.py +94 -0
- netbox_change_control/models/comments.py +137 -0
- netbox_change_control/models/policies.py +213 -0
- netbox_change_control/models/requests.py +608 -0
- netbox_change_control/navigation.py +67 -0
- netbox_change_control/notifications.py +92 -0
- netbox_change_control/permissions.py +57 -0
- netbox_change_control/policy.py +447 -0
- netbox_change_control/search.py +114 -0
- netbox_change_control/signal_receivers.py +532 -0
- netbox_change_control/tables.py +178 -0
- netbox_change_control/template_content.py +121 -0
- netbox_change_control/templates/netbox_change_control/changerequest.html +308 -0
- netbox_change_control/templates/netbox_change_control/changerequest_changes.html +151 -0
- netbox_change_control/templates/netbox_change_control/changerequest_reviews.html +14 -0
- netbox_change_control/templates/netbox_change_control/inc/branch_change_request.html +20 -0
- netbox_change_control/templates/netbox_change_control/inc/branch_change_request_body.html +23 -0
- netbox_change_control/templates/netbox_change_control/inc/branch_change_request_card.html +21 -0
- netbox_change_control/templates/netbox_change_control/inc/branch_no_request.html +20 -0
- netbox_change_control/templates/netbox_change_control/inc/branch_no_request_body.html +8 -0
- netbox_change_control/templates/netbox_change_control/inc/branch_no_request_card.html +19 -0
- netbox_change_control/templates/netbox_change_control/inc/comment_actions.html +21 -0
- netbox_change_control/templates/netbox_change_control/inc/review_form.html +39 -0
- netbox_change_control/templates/netbox_change_control/inc/rule_reviewers.html +24 -0
- netbox_change_control/templates/netbox_change_control/mergecheck.html +35 -0
- netbox_change_control/templates/netbox_change_control/policy.html +73 -0
- netbox_change_control/templates/netbox_change_control/policyrule.html +53 -0
- netbox_change_control/templates/netbox_change_control/review.html +45 -0
- netbox_change_control/templates/netbox_change_control/widgets/my_reviews.html +21 -0
- netbox_change_control/tests/__init__.py +0 -0
- netbox_change_control/tests/base.py +151 -0
- netbox_change_control/tests/test_api.py +326 -0
- netbox_change_control/tests/test_automatic_behaviors.py +271 -0
- netbox_change_control/tests/test_automerge_once.py +116 -0
- netbox_change_control/tests/test_batching.py +178 -0
- netbox_change_control/tests/test_branch_deletion.py +258 -0
- netbox_change_control/tests/test_branch_page.py +237 -0
- netbox_change_control/tests/test_cached_state.py +305 -0
- netbox_change_control/tests/test_check_changelog.py +105 -0
- netbox_change_control/tests/test_checks.py +837 -0
- netbox_change_control/tests/test_comment_attribution.py +234 -0
- netbox_change_control/tests/test_comments.py +376 -0
- netbox_change_control/tests/test_conditions.py +205 -0
- netbox_change_control/tests/test_conflicts.py +253 -0
- netbox_change_control/tests/test_documented_constraints.py +143 -0
- netbox_change_control/tests/test_documented_permissions.py +103 -0
- netbox_change_control/tests/test_documented_settings.py +46 -0
- netbox_change_control/tests/test_events.py +109 -0
- netbox_change_control/tests/test_extensibility.py +222 -0
- netbox_change_control/tests/test_filtersets.py +317 -0
- netbox_change_control/tests/test_lifecycle_actions.py +462 -0
- netbox_change_control/tests/test_list_actions.py +100 -0
- netbox_change_control/tests/test_markdown.py +115 -0
- netbox_change_control/tests/test_notifications.py +145 -0
- netbox_change_control/tests/test_permissions.py +57 -0
- netbox_change_control/tests/test_policy.py +285 -0
- netbox_change_control/tests/test_protect_main.py +279 -0
- netbox_change_control/tests/test_readiness.py +56 -0
- netbox_change_control/tests/test_reviewer_display.py +180 -0
- netbox_change_control/tests/test_scope.py +244 -0
- netbox_change_control/tests/test_search.py +147 -0
- netbox_change_control/tests/test_status.py +64 -0
- netbox_change_control/tests/test_templates.py +190 -0
- netbox_change_control/tests/test_validators.py +83 -0
- netbox_change_control/tests/test_views.py +150 -0
- netbox_change_control/tests/test_windows.py +441 -0
- netbox_change_control/urls.py +74 -0
- netbox_change_control/validators.py +131 -0
- netbox_change_control/views/__init__.py +14 -0
- netbox_change_control/views/bulk.py +80 -0
- netbox_change_control/views/checks.py +73 -0
- netbox_change_control/views/comments.py +191 -0
- netbox_change_control/views/policies.py +86 -0
- netbox_change_control/views/requests.py +279 -0
- netbox_change_control/views/reviews.py +68 -0
- netbox_change_control-0.3.0.dist-info/METADATA +159 -0
- netbox_change_control-0.3.0.dist-info/RECORD +105 -0
- netbox_change_control-0.3.0.dist-info/WHEEL +5 -0
- netbox_change_control-0.3.0.dist-info/licenses/LICENSE +21 -0
- netbox_change_control-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from netbox.api.routers import NetBoxRouter
|
|
2
|
+
|
|
3
|
+
from . import views
|
|
4
|
+
|
|
5
|
+
router = NetBoxRouter()
|
|
6
|
+
router.register('policies', views.PolicyViewSet)
|
|
7
|
+
router.register('policy-rules', views.PolicyRuleViewSet)
|
|
8
|
+
router.register('change-requests', views.ChangeRequestViewSet)
|
|
9
|
+
router.register('reviews', views.ReviewViewSet)
|
|
10
|
+
router.register('checks', views.MergeCheckViewSet)
|
|
11
|
+
router.register('change-comments', views.ChangeCommentViewSet)
|
|
12
|
+
|
|
13
|
+
app_name = 'netbox_change_control-api'
|
|
14
|
+
urlpatterns = router.urls
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
|
|
3
|
+
from django.shortcuts import get_object_or_404
|
|
4
|
+
from netbox.api.authentication import TokenPermissions
|
|
5
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
|
6
|
+
from rest_framework import status
|
|
7
|
+
from rest_framework.decorators import action
|
|
8
|
+
from rest_framework.exceptions import PermissionDenied
|
|
9
|
+
from rest_framework.response import Response
|
|
10
|
+
|
|
11
|
+
from netbox_change_control import filtersets
|
|
12
|
+
from netbox_change_control.models import (
|
|
13
|
+
ChangeComment,
|
|
14
|
+
ChangeRequest,
|
|
15
|
+
MergeCheck,
|
|
16
|
+
Policy,
|
|
17
|
+
PolicyRule,
|
|
18
|
+
Review,
|
|
19
|
+
)
|
|
20
|
+
from netbox_change_control.permissions import ABANDON_PERMISSION, CHANGE_PERMISSION, REOPEN_PERMISSION
|
|
21
|
+
|
|
22
|
+
from .serializers import (
|
|
23
|
+
ChangeCommentSerializer,
|
|
24
|
+
ChangeRequestSerializer,
|
|
25
|
+
MergeCheckSerializer,
|
|
26
|
+
PolicyRuleSerializer,
|
|
27
|
+
PolicySerializer,
|
|
28
|
+
ReviewSerializer,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = (
|
|
32
|
+
'ChangeCommentViewSet',
|
|
33
|
+
'ChangeRequestViewSet',
|
|
34
|
+
'MergeCheckViewSet',
|
|
35
|
+
'PolicyRuleViewSet',
|
|
36
|
+
'PolicyViewSet',
|
|
37
|
+
'ReviewViewSet',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PolicyViewSet(NetBoxModelViewSet):
|
|
42
|
+
queryset = Policy.objects.prefetch_related('object_types', 'tags')
|
|
43
|
+
serializer_class = PolicySerializer
|
|
44
|
+
filterset_class = filtersets.PolicyFilterSet
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PolicyRuleViewSet(NetBoxModelViewSet):
|
|
48
|
+
queryset = PolicyRule.objects.prefetch_related('groups', 'users', 'tags')
|
|
49
|
+
serializer_class = PolicyRuleSerializer
|
|
50
|
+
filterset_class = filtersets.PolicyRuleFilterSet
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ObjectActionPermissions(TokenPermissions):
|
|
54
|
+
"""
|
|
55
|
+
Permissions for an action performed *on* an object rather than one that creates one.
|
|
56
|
+
|
|
57
|
+
NetBox maps every POST to `add_<model>`, which is right for creating an object and wrong
|
|
58
|
+
here: abandoning a change request is not adding one, and requiring `add_changerequest` to
|
|
59
|
+
give up on somebody else's change is both surprising and too broad.
|
|
60
|
+
|
|
61
|
+
The blanket requirement is dropped, and each action states the permission it wants and
|
|
62
|
+
restricts its own queryset to the objects the caller holds that permission on. Everything
|
|
63
|
+
else is inherited, including the check that a write token is not read-only.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
perms_map: ClassVar = {**TokenPermissions.perms_map, 'POST': []}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ChangeRequestViewSet(NetBoxModelViewSet):
|
|
70
|
+
queryset = ChangeRequest.objects.prefetch_related('policies', 'tags')
|
|
71
|
+
serializer_class = ChangeRequestSerializer
|
|
72
|
+
filterset_class = filtersets.ChangeRequestFilterSet
|
|
73
|
+
|
|
74
|
+
def _restricted(self, request, action_name, pk):
|
|
75
|
+
"""
|
|
76
|
+
Fetch the object, honouring any constraint on the caller's permission for this action.
|
|
77
|
+
|
|
78
|
+
`self.get_object()` cannot be used: BaseViewSet.initial() has already narrowed
|
|
79
|
+
`self.queryset` to what the caller may *add*, which is the wrong question for these.
|
|
80
|
+
|
|
81
|
+
A caller holding the permission but constrained away from this object gets a 404,
|
|
82
|
+
which is the NetBox convention for an object-level miss. Not holding it at all is a
|
|
83
|
+
403, raised by the action before it gets here.
|
|
84
|
+
"""
|
|
85
|
+
return get_object_or_404(ChangeRequest.objects.restrict(request.user, action_name), pk=pk)
|
|
86
|
+
|
|
87
|
+
# `status` is read-only on the serializer because it is derived from the policy
|
|
88
|
+
# evaluation. These are the two transitions a person makes by hand, each behind its own
|
|
89
|
+
# permission, so an integration can still give up on a change or take one back up without
|
|
90
|
+
# the field being writable and Completed being one typo away.
|
|
91
|
+
|
|
92
|
+
@action(detail=True, methods=['post'], permission_classes=[ObjectActionPermissions])
|
|
93
|
+
def submit(self, request, pk=None):
|
|
94
|
+
if not request.user.has_perm(CHANGE_PERMISSION):
|
|
95
|
+
raise PermissionDenied('You do not have permission to change change requests.')
|
|
96
|
+
|
|
97
|
+
change_request = self._restricted(request, 'change', pk)
|
|
98
|
+
if not change_request.submit():
|
|
99
|
+
return Response(
|
|
100
|
+
{'detail': 'Only a draft can be submitted for review.'},
|
|
101
|
+
status=status.HTTP_409_CONFLICT,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
from netbox_change_control import events
|
|
105
|
+
|
|
106
|
+
events.emit(change_request, events.CHANGE_REQUEST_SUBMITTED)
|
|
107
|
+
change_request.refresh_from_db()
|
|
108
|
+
return Response(self.get_serializer(change_request).data)
|
|
109
|
+
|
|
110
|
+
@action(detail=True, methods=['post'], url_path='return-to-draft')
|
|
111
|
+
def return_to_draft(self, request, pk=None):
|
|
112
|
+
if not request.user.has_perm(CHANGE_PERMISSION):
|
|
113
|
+
raise PermissionDenied('You do not have permission to change change requests.')
|
|
114
|
+
|
|
115
|
+
change_request = self._restricted(request, 'change', pk)
|
|
116
|
+
if not change_request.return_to_draft():
|
|
117
|
+
return Response(
|
|
118
|
+
{
|
|
119
|
+
'detail': (
|
|
120
|
+
f'A {change_request.get_status_display().lower()} change request cannot be returned to draft.'
|
|
121
|
+
)
|
|
122
|
+
},
|
|
123
|
+
status=status.HTTP_409_CONFLICT,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return Response(self.get_serializer(change_request).data)
|
|
127
|
+
|
|
128
|
+
@action(detail=True, methods=['post'], permission_classes=[ObjectActionPermissions])
|
|
129
|
+
def abandon(self, request, pk=None):
|
|
130
|
+
if not request.user.has_perm(ABANDON_PERMISSION):
|
|
131
|
+
raise PermissionDenied('You do not have permission to abandon change requests.')
|
|
132
|
+
|
|
133
|
+
change_request = self._restricted(request, 'abandon', pk)
|
|
134
|
+
if not change_request.abandon():
|
|
135
|
+
return Response(
|
|
136
|
+
{'detail': f'A {change_request.get_status_display().lower()} change request cannot be abandoned.'},
|
|
137
|
+
status=status.HTTP_409_CONFLICT,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return Response(self.get_serializer(change_request).data)
|
|
141
|
+
|
|
142
|
+
@action(detail=True, methods=['post'], permission_classes=[ObjectActionPermissions])
|
|
143
|
+
def reopen(self, request, pk=None):
|
|
144
|
+
if not request.user.has_perm(REOPEN_PERMISSION):
|
|
145
|
+
raise PermissionDenied('You do not have permission to reopen change requests.')
|
|
146
|
+
|
|
147
|
+
change_request = self._restricted(request, 'reopen', pk)
|
|
148
|
+
if not change_request.reopen():
|
|
149
|
+
return Response(
|
|
150
|
+
{'detail': 'Only an abandoned change request can be reopened.'},
|
|
151
|
+
status=status.HTTP_409_CONFLICT,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
change_request.refresh_from_db()
|
|
155
|
+
return Response(self.get_serializer(change_request).data)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ReviewViewSet(NetBoxModelViewSet):
|
|
159
|
+
queryset = Review.objects.select_related('reviewer', 'change_request')
|
|
160
|
+
serializer_class = ReviewSerializer
|
|
161
|
+
filterset_class = filtersets.ReviewFilterSet
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MergeCheckViewSet(NetBoxModelViewSet):
|
|
165
|
+
queryset = MergeCheck.objects.select_related('change_request')
|
|
166
|
+
serializer_class = MergeCheckSerializer
|
|
167
|
+
filterset_class = filtersets.MergeCheckFilterSet
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ChangeCommentViewSet(NetBoxModelViewSet):
|
|
171
|
+
queryset = ChangeComment.objects.select_related('author', 'change_diff', 'change_request')
|
|
172
|
+
serializer_class = ChangeCommentSerializer
|
|
173
|
+
filterset_class = filtersets.ChangeCommentFilterSet
|