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.
Files changed (105) hide show
  1. netbox_change_control/__init__.py +87 -0
  2. netbox_change_control/api/__init__.py +0 -0
  3. netbox_change_control/api/serializers.py +251 -0
  4. netbox_change_control/api/urls.py +14 -0
  5. netbox_change_control/api/views.py +173 -0
  6. netbox_change_control/automerge.py +166 -0
  7. netbox_change_control/batching.py +102 -0
  8. netbox_change_control/checks.py +368 -0
  9. netbox_change_control/choices.py +110 -0
  10. netbox_change_control/conflicts.py +61 -0
  11. netbox_change_control/dashboard.py +65 -0
  12. netbox_change_control/diffs.py +133 -0
  13. netbox_change_control/events.py +83 -0
  14. netbox_change_control/filtersets.py +377 -0
  15. netbox_change_control/forms.py +434 -0
  16. netbox_change_control/jobs.py +51 -0
  17. netbox_change_control/migrations/0001_initial.py +203 -0
  18. netbox_change_control/migrations/0002_policy_condition_state.py +18 -0
  19. netbox_change_control/migrations/0003_changerequest_ref.py +18 -0
  20. netbox_change_control/migrations/0004_alter_changerequest_options.py +17 -0
  21. netbox_change_control/migrations/0005_changerequest_cached_conflicted_and_more.py +68 -0
  22. netbox_change_control/migrations/0006_remove_changerequestpolicy_created_and_more.py +21 -0
  23. netbox_change_control/migrations/0007_alter_changerequestpolicy_options.py +41 -0
  24. netbox_change_control/migrations/__init__.py +0 -0
  25. netbox_change_control/models/__init__.py +4 -0
  26. netbox_change_control/models/checks.py +94 -0
  27. netbox_change_control/models/comments.py +137 -0
  28. netbox_change_control/models/policies.py +213 -0
  29. netbox_change_control/models/requests.py +608 -0
  30. netbox_change_control/navigation.py +67 -0
  31. netbox_change_control/notifications.py +92 -0
  32. netbox_change_control/permissions.py +57 -0
  33. netbox_change_control/policy.py +447 -0
  34. netbox_change_control/search.py +114 -0
  35. netbox_change_control/signal_receivers.py +532 -0
  36. netbox_change_control/tables.py +178 -0
  37. netbox_change_control/template_content.py +121 -0
  38. netbox_change_control/templates/netbox_change_control/changerequest.html +308 -0
  39. netbox_change_control/templates/netbox_change_control/changerequest_changes.html +151 -0
  40. netbox_change_control/templates/netbox_change_control/changerequest_reviews.html +14 -0
  41. netbox_change_control/templates/netbox_change_control/inc/branch_change_request.html +20 -0
  42. netbox_change_control/templates/netbox_change_control/inc/branch_change_request_body.html +23 -0
  43. netbox_change_control/templates/netbox_change_control/inc/branch_change_request_card.html +21 -0
  44. netbox_change_control/templates/netbox_change_control/inc/branch_no_request.html +20 -0
  45. netbox_change_control/templates/netbox_change_control/inc/branch_no_request_body.html +8 -0
  46. netbox_change_control/templates/netbox_change_control/inc/branch_no_request_card.html +19 -0
  47. netbox_change_control/templates/netbox_change_control/inc/comment_actions.html +21 -0
  48. netbox_change_control/templates/netbox_change_control/inc/review_form.html +39 -0
  49. netbox_change_control/templates/netbox_change_control/inc/rule_reviewers.html +24 -0
  50. netbox_change_control/templates/netbox_change_control/mergecheck.html +35 -0
  51. netbox_change_control/templates/netbox_change_control/policy.html +73 -0
  52. netbox_change_control/templates/netbox_change_control/policyrule.html +53 -0
  53. netbox_change_control/templates/netbox_change_control/review.html +45 -0
  54. netbox_change_control/templates/netbox_change_control/widgets/my_reviews.html +21 -0
  55. netbox_change_control/tests/__init__.py +0 -0
  56. netbox_change_control/tests/base.py +151 -0
  57. netbox_change_control/tests/test_api.py +326 -0
  58. netbox_change_control/tests/test_automatic_behaviors.py +271 -0
  59. netbox_change_control/tests/test_automerge_once.py +116 -0
  60. netbox_change_control/tests/test_batching.py +178 -0
  61. netbox_change_control/tests/test_branch_deletion.py +258 -0
  62. netbox_change_control/tests/test_branch_page.py +237 -0
  63. netbox_change_control/tests/test_cached_state.py +305 -0
  64. netbox_change_control/tests/test_check_changelog.py +105 -0
  65. netbox_change_control/tests/test_checks.py +837 -0
  66. netbox_change_control/tests/test_comment_attribution.py +234 -0
  67. netbox_change_control/tests/test_comments.py +376 -0
  68. netbox_change_control/tests/test_conditions.py +205 -0
  69. netbox_change_control/tests/test_conflicts.py +253 -0
  70. netbox_change_control/tests/test_documented_constraints.py +143 -0
  71. netbox_change_control/tests/test_documented_permissions.py +103 -0
  72. netbox_change_control/tests/test_documented_settings.py +46 -0
  73. netbox_change_control/tests/test_events.py +109 -0
  74. netbox_change_control/tests/test_extensibility.py +222 -0
  75. netbox_change_control/tests/test_filtersets.py +317 -0
  76. netbox_change_control/tests/test_lifecycle_actions.py +462 -0
  77. netbox_change_control/tests/test_list_actions.py +100 -0
  78. netbox_change_control/tests/test_markdown.py +115 -0
  79. netbox_change_control/tests/test_notifications.py +145 -0
  80. netbox_change_control/tests/test_permissions.py +57 -0
  81. netbox_change_control/tests/test_policy.py +285 -0
  82. netbox_change_control/tests/test_protect_main.py +279 -0
  83. netbox_change_control/tests/test_readiness.py +56 -0
  84. netbox_change_control/tests/test_reviewer_display.py +180 -0
  85. netbox_change_control/tests/test_scope.py +244 -0
  86. netbox_change_control/tests/test_search.py +147 -0
  87. netbox_change_control/tests/test_status.py +64 -0
  88. netbox_change_control/tests/test_templates.py +190 -0
  89. netbox_change_control/tests/test_validators.py +83 -0
  90. netbox_change_control/tests/test_views.py +150 -0
  91. netbox_change_control/tests/test_windows.py +441 -0
  92. netbox_change_control/urls.py +74 -0
  93. netbox_change_control/validators.py +131 -0
  94. netbox_change_control/views/__init__.py +14 -0
  95. netbox_change_control/views/bulk.py +80 -0
  96. netbox_change_control/views/checks.py +73 -0
  97. netbox_change_control/views/comments.py +191 -0
  98. netbox_change_control/views/policies.py +86 -0
  99. netbox_change_control/views/requests.py +279 -0
  100. netbox_change_control/views/reviews.py +68 -0
  101. netbox_change_control-0.3.0.dist-info/METADATA +159 -0
  102. netbox_change_control-0.3.0.dist-info/RECORD +105 -0
  103. netbox_change_control-0.3.0.dist-info/WHEEL +5 -0
  104. netbox_change_control-0.3.0.dist-info/licenses/LICENSE +21 -0
  105. 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