diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 686382a8c..8893eb154 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -204,6 +204,11 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:device_list' ) + # TODO: Remove this and just piggy back on the defaults + # once adjusted. + actions = columns.ActionsColumn( + actions=('edit', 'suggest', 'delete', 'changelog') + ) class Meta(NetBoxTable.Meta): model = models.Device diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b49e799c..e8db10fee 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext as _ from django.views.generic import View from circuits.models import Circuit, CircuitTermination -from extras.views import ObjectConfigContextView +from extras.views import ObjectConfigContextView, suggest_form_factory from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic @@ -1916,6 +1916,14 @@ class DeviceEditView(generic.ObjectEditView): template_name = 'dcim/device_edit.html' +@register_model_view(Device, 'suggest') +class DeviceSuggestView(generic.ObjectEditView): + queryset = Device.objects.all() + form = suggest_form_factory(Device, forms.DeviceForm) + template_name = 'dcim/device_edit.html' + is_suggest_view = True + + @register_model_view(Device, 'delete') class DeviceDeleteView(generic.ObjectDeleteView): queryset = Device.objects.all() diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 91d3b5c58..f519d7bf0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,5 +1,7 @@ +from django import forms as DjangoForms from django.contrib import messages from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User from django.db.models import Count, Q from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -901,3 +903,16 @@ class RenderMarkdownView(View): rendered = render_markdown(form.cleaned_data['text']) return HttpResponse(rendered) + + +def suggest_form_factory(obj_cls, form_cls): + return type( + f'dyn_suggest_{form_cls.__name__}', + (form_cls, ), + { + 'reviewer': DjangoForms.ModelChoiceField( + # TODO: Use obj_cls to get the right qs here. + queryset=User.objects.filter(is_active=True, is_superuser=True), + required=True,) + } + ) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 66ee787a8..413e39293 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -210,6 +210,7 @@ class ActionsColumn(tables.Column): empty_values = () actions = { 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), + 'suggest': ActionsItem('Suggest edit', 'pencil', 'suggest', 'warning'), 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'), 'changelog': ActionsItem('Changelog', 'history'), } diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 475cca9d3..3f7cfc187 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,5 +1,6 @@ import logging from copy import deepcopy +from datetime import datetime from django.contrib import messages from django.db import transaction @@ -9,6 +10,9 @@ from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe +from netbox.staging import checkout + +from extras.models.staging import ReviewRequest, Branch, Notification from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation @@ -161,11 +165,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): form = None def dispatch(self, request, *args, **kwargs): - # Determine required permission based on whether we are editing an existing object - self._permission_action = 'change' if kwargs else 'add' - + # Determine required permission based on whether we + # are suggesting an edit, editing an existing object, + # or creating a new instance. + if self.is_suggest: + self._permission_action = 'suggest' + else: + self._permission_action = 'change' if kwargs else 'add' return super().dispatch(request, *args, **kwargs) + @property + def is_suggest(self): + return getattr(self, 'is_suggest_view', False) + def get_required_permission(self): # self._permission_action is set by dispatch() to either "add" or "change" depending on whether # we are modifying an existing object or creating a new one. @@ -222,6 +234,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): 'model': model, 'object': obj, 'form': form, + 'is_suggest': self.is_suggest, 'return_url': self.get_return_url(request, obj), 'prerequisite_model': get_prerequisite_model(self.queryset), **self.get_extra_context(request, obj), @@ -250,15 +263,39 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): logger.debug("Form validation was successful") try: - with transaction.atomic(): - object_created = form.instance.pk is None - obj = form.save() + if self.is_suggest: + utc_timestamp = int(datetime.utcnow().timestamp()) + branch_name = f'{request.user}_{obj.__class__.__name__}_{utc_timestamp}' + branch = Branch.objects.create(name=branch_name, user=request.user) + owner = request.user + reviewer = form.cleaned_data.get('reviewer') + with checkout(branch): + obj = form.save() - # Check that the new object conforms with any assigned object-level permissions - if not self.queryset.filter(pk=obj.pk).exists(): - raise PermissionsViolation() + # TODO: Remove branch if this atomic transaction fails. + with transaction.atomic(): + ReviewRequest.objects.create( + owner=owner, + reviewer=reviewer, + branch=branch + ) + Notification.objects.create( + user=reviewer, + title=f'{owner.get_full_name()} is requesting a review on a modifition on {obj}', + content=f'{owner.get_full_name()} has made modifications to {obj} and is requesting \ + that you review and approve them.', + ) + else: + with transaction.atomic(): + object_created = form.instance.pk is None + obj = form.save() + + # Check that the new object conforms with any assigned object-level permissions + if not self.queryset.filter(pk=obj.pk).exists(): + raise PermissionsViolation() msg = '{} {}'.format( + 'Created Review Request' if self.is_suggest else 'Created' if object_created else 'Modified', self.queryset.model._meta.verbose_name ) diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index c61fb723f..03e58dbd0 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -14,7 +14,13 @@ Context: {% endcomment %} {% block title %} - {% if object.pk %}Editing {{ object|meta:"verbose_name" }} {{ object }}{% else %}Add a new {{ object|meta:"verbose_name" }}{% endif %} + {% if is_suggest %} + Suggesting an edit for {{ object|meta:"verbose_name" }} {{ object }} + {% elif object.pk %} + Editing {{ object|meta:"verbose_name" }} {{ object }} + {% else %} + Add a new {{ object|meta:"verbose_name" }} + {% endif %} {% endblock title %} {% block tabs %} @@ -99,6 +105,13 @@ Context: {% endblock form %} + {% if is_suggest and form.reviewer %} +
+
Reviewer
+ {% render_field form.reviewer %} +
+ {% endif %} +
{% block buttons %} {% if object.pk %}