diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f996876b9..0d12728b5 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -49,6 +49,7 @@ __all__ = ( 'TagSerializer', 'WebhookSerializer', 'NotificationSerializer', + 'ReviewRequestSerializer', ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a21cf21e2..cdaa1eafe 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * +from extras.models.staging import ReviewRequest from extras.utils import FeatureQuery from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup @@ -25,6 +26,7 @@ __all__ = ( 'SavedFilterForm', 'TagForm', 'WebhookForm', + 'ReviewRequestForm', ) @@ -279,3 +281,12 @@ class JournalEntryForm(NetBoxModelForm): 'assigned_object_type': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput, } + + +class ReviewRequestForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = ReviewRequest + fields = [ + 'status', 'state' + ] diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index c2b8c9424..9fbd793a5 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -3,7 +3,8 @@ from django.conf import settings from django.utils.translation import gettext as _ from extras.models import * -from netbox.tables import NetBoxTable, columns +from extras.models.staging import * +from netbox.tables import NetBoxTable, BaseTable, columns from .template_code import * __all__ = ( @@ -18,6 +19,8 @@ __all__ = ( 'TaggedItemTable', 'TagTable', 'WebhookTable', + 'ReviewRequestTable', + 'StagedChangeTable', ) @@ -205,6 +208,47 @@ class ConfigContextTable(NetBoxTable): default_columns = ('pk', 'name', 'weight', 'is_active', 'description') +class ReviewRequestTable(NetBoxTable): + id = tables.Column( + linkify=True + ) + actions = columns.ActionsColumn( + actions=() + ) + + class Meta(NetBoxTable.Meta): + model = ReviewRequest + fields = ( + 'pk', 'id', 'created', 'last_updated', 'status', 'state', 'owner', 'reviewer' + ) + default_columns = ('pk', 'id', 'created', 'last_updated', 'status', 'state', 'owner', 'reviewer') + + +class StagedChangeTable(BaseTable): + + model_name = tables.Column( + verbose_name='Model' + ) + + object = tables.Column( + verbose_name='Object', + linkify=lambda record: record.object.get_absolute_url(), + accessor='object__name' + ) + + diff_added = tables.Column( + verbose_name='Suggested Change' + ) + + diff_removed = tables.Column( + verbose_name='Current Value' + ) + + class Meta(BaseTable.Meta): + model = StagedChange + fields = ('id', 'action', 'model_name', 'object', 'diff_added', 'diff_removed') + + class ObjectChangeTable(NetBoxTable): time = tables.DateTimeColumn( linkify=True, diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 304e5b9ea..674125606 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -93,5 +93,9 @@ urlpatterns = [ re_path(r'^scripts/(?P.([^.]+)).(?P.(.+))/', views.ScriptView.as_view(), name='script'), # Markdown - path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown") + path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"), + + # Review Request + path('review-requests/', views.ReviewRequestListView.as_view(), name='reviewrequest_list'), + path('review-requests//', include(get_model_urls('extras', 'reviewrequest'))), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index f519d7bf0..f9654c462 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,9 +11,13 @@ from django_rq.queues import get_connection from rq import Worker from netbox.views import generic +from extras.choices import ChangeActionChoices +from extras.models.staging import ReviewRequest from utilities.htmx import is_htmx from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict +from utilities.utils import copy_safe_request, count_related, \ + get_viewname, normalize_querydict, shallow_compare_dict, \ + serialize_object, deserialize_object from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables from .choices import JobResultStatusChoices @@ -905,6 +909,59 @@ class RenderMarkdownView(View): return HttpResponse(rendered) +# +# Review Request +# + +class ReviewRequestListView(generic.ObjectListView): + table = tables.ReviewRequestTable + actions = ('bulk_delete') + + def get_queryset(self, request): + return ReviewRequest.objects.filter( + Q(owner__id=self.request.user.id) | + Q(reviewer__id=self.request.user.id) + ).order_by('last_updated') + + +@register_model_view(ReviewRequest) +class ReviewRequestEditView(generic.ObjectView): + queryset = ReviewRequest.objects.all() + + def get_extra_context(self, request, instance): + data = [] + for sc in StagedChange.objects.filter(branch__id=instance.branch.id).exclude(object_type__model='objectchange'): + if sc.action == ChangeActionChoices.ACTION_UPDATE: + current = serialize_object(sc.object, resolve_tags=True) + diff_added = shallow_compare_dict( + current or dict(), + sc.data, + exclude=['last_updated'], + ) + diff_removed = { + x: current.get(x) for x in diff_added + } if current else {} + sc._diff_added = diff_added + sc._diff_removed = diff_removed + if sc.action == ChangeActionChoices.ACTION_CREATE: + do = deserialize_object(sc.model(), sc.data) + sc._diff_added = do.object + + print(sc.object_type.model) + + data.append(sc) + + staged_change_table = tables.StagedChangeTable( + data=data, + orderable=False + ) + staged_change_table.configure(request) + + return { + 'staged_change_table': staged_change_table, + } + + def suggest_form_factory(obj_cls, form_cls): return type( f'dyn_suggest_{form_cls.__name__}', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 09a35489d..761299a18 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -309,6 +309,7 @@ OTHER_MENU = Menu( items=( get_model_item('extras', 'tag', 'Tags'), get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']), + get_model_item('extras', 'reviewrequest', _('Review Requests'), actions=[]), ), ), ), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 3f7cfc187..215eebb8e 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -178,6 +178,56 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): def is_suggest(self): return getattr(self, 'is_suggest_view', False) + def _create_review_request(self, request, obj, form): + obj_cls_name = self.queryset.model._meta.verbose_name + owner = request.user + now_utc = int(datetime.utcnow().timestamp()) + branch_name = f'{owner}_{now_utc}' + branch = Branch.objects.create(name=branch_name, user=owner) + reviewer = form.cleaned_data.get('reviewer') + + with checkout(branch): + obj = form.save() + + # TODO: Remove branch if this atomic transaction fails. + with transaction.atomic(): + rr = ReviewRequest.objects.create( + owner=owner, + reviewer=reviewer, + branch=branch + ) + Notification.objects.create( + user=reviewer, + # TODO: get_full_name doesn't always return a value, figure out + # what's the right approach here. + 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.', + ) + + return (obj, mark_safe(f'Created Review Request for {obj_cls_name}')) + + def _create_or_update_instance(self, obj, form): + logger = logging.getLogger('netbox.views.ObjectEditView') + 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' if object_created else 'Modified', + self.queryset.model._meta.verbose_name + ) + logger.info(f"{msg} {obj} (PK: {obj.pk})") + if hasattr(obj, 'get_absolute_url'): + msg = mark_safe(f'{msg} {escape(obj)}') + else: + msg = f'{msg} {obj}' + + return (obj, msg) + 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. @@ -264,46 +314,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): try: 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() - - # 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.', - ) + obj, msg = self._create_review_request(request, obj, form) else: - with transaction.atomic(): - object_created = form.instance.pk is None - obj = form.save() + obj, msg = self._create_or_update_instance(obj, form) - # 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 - ) - logger.info(f"{msg} {obj} (PK: {obj.pk})") - if hasattr(obj, 'get_absolute_url'): - msg = mark_safe(f'{msg} {escape(obj)}') - else: - msg = f'{msg} {obj}' messages.success(request, msg) if '_addanother' in request.POST: diff --git a/netbox/templates/extras/reviewrequest.html b/netbox/templates/extras/reviewrequest.html new file mode 100644 index 000000000..193a72cf1 --- /dev/null +++ b/netbox/templates/extras/reviewrequest.html @@ -0,0 +1,82 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block title %}{{ object }}{% endblock %} + +{# ObjectChange does not support the default add/edit/delete controls #} +{% block controls %}{% endblock %} +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
+ Review Request +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Created at + {{ object.created|annotated_date }} +
Last Updated + {{ object.last_updated|annotated_date }} +
Requester + {% if object.owner.get_full_name %} + {{ object.owner.get_full_name }} ({{ object.owner }}) + {% else %} + {{ object.owner }} + {% endif %} +
Reviewer + {% if object.reviewer.get_full_name %} + {{ object.reviewer.get_full_name }} ({{ object.reviewer }}) + {% else %} + {{ object.reviewer }} + {% endif %} +
Status + {{ object.status }} +
State + {{ object.state }} +
+
+
+
+
+
+
+
+
Staged Changes
+
+ {% render_table staged_change_table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=staged_change_table.paginator page=staged_change_table.page %} +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} \ No newline at end of file