review request view

This commit is contained in:
rmanyari 2023-03-15 10:50:30 -06:00
parent c72dabd450
commit 69b09d5154
8 changed files with 255 additions and 41 deletions

View File

@ -49,6 +49,7 @@ __all__ = (
'TagSerializer', 'TagSerializer',
'WebhookSerializer', 'WebhookSerializer',
'NotificationSerializer', 'NotificationSerializer',
'ReviewRequestSerializer',
) )

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.models.staging import ReviewRequest
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -25,6 +26,7 @@ __all__ = (
'SavedFilterForm', 'SavedFilterForm',
'TagForm', 'TagForm',
'WebhookForm', 'WebhookForm',
'ReviewRequestForm',
) )
@ -279,3 +281,12 @@ class JournalEntryForm(NetBoxModelForm):
'assigned_object_type': forms.HiddenInput, 'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput,
} }
class ReviewRequestForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ReviewRequest
fields = [
'status', 'state'
]

View File

@ -3,7 +3,8 @@ from django.conf import settings
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.models import * 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 * from .template_code import *
__all__ = ( __all__ = (
@ -18,6 +19,8 @@ __all__ = (
'TaggedItemTable', 'TaggedItemTable',
'TagTable', 'TagTable',
'WebhookTable', 'WebhookTable',
'ReviewRequestTable',
'StagedChangeTable',
) )
@ -205,6 +208,47 @@ class ConfigContextTable(NetBoxTable):
default_columns = ('pk', 'name', 'weight', 'is_active', 'description') 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): class ObjectChangeTable(NetBoxTable):
time = tables.DateTimeColumn( time = tables.DateTimeColumn(
linkify=True, linkify=True,

View File

@ -93,5 +93,9 @@ urlpatterns = [
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'), re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
# Markdown # 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/<int:pk>/', include(get_model_urls('extras', 'reviewrequest'))),
] ]

View File

@ -11,9 +11,13 @@ from django_rq.queues import get_connection
from rq import Worker from rq import Worker
from netbox.views import generic from netbox.views import generic
from extras.choices import ChangeActionChoices
from extras.models.staging import ReviewRequest
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.templatetags.builtins.filters import render_markdown 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 utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import JobResultStatusChoices from .choices import JobResultStatusChoices
@ -905,6 +909,59 @@ class RenderMarkdownView(View):
return HttpResponse(rendered) 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): def suggest_form_factory(obj_cls, form_cls):
return type( return type(
f'dyn_suggest_{form_cls.__name__}', f'dyn_suggest_{form_cls.__name__}',

View File

@ -309,6 +309,7 @@ OTHER_MENU = Menu(
items=( items=(
get_model_item('extras', 'tag', 'Tags'), get_model_item('extras', 'tag', 'Tags'),
get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']), get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']),
get_model_item('extras', 'reviewrequest', _('Review Requests'), actions=[]),
), ),
), ),
), ),

View File

@ -178,6 +178,56 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
def is_suggest(self): def is_suggest(self):
return getattr(self, 'is_suggest_view', False) 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 <a href="{rr.get_absolute_url()}">Review Request</a> 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} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
else:
msg = f'{msg} {obj}'
return (obj, msg)
def get_required_permission(self): def get_required_permission(self):
# self._permission_action is set by dispatch() to either "add" or "change" depending on whether # 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. # we are modifying an existing object or creating a new one.
@ -264,46 +314,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
try: try:
if self.is_suggest: if self.is_suggest:
utc_timestamp = int(datetime.utcnow().timestamp()) obj, msg = self._create_review_request(request, obj, form)
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.',
)
else: else:
with transaction.atomic(): obj, msg = self._create_or_update_instance(obj, form)
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
)
logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
else:
msg = f'{msg} {obj}'
messages.success(request, msg) messages.success(request, msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:

View File

@ -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 %}
<div class="row mb-3">
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">
Review Request
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Created at</th>
<td>
{{ object.created|annotated_date }}
</td>
</tr>
<tr>
<th scope="row">Last Updated</th>
<td>
{{ object.last_updated|annotated_date }}
</td>
</tr>
<tr>
<th scope="row">Requester</th>
<td>
{% if object.owner.get_full_name %}
{{ object.owner.get_full_name }} ({{ object.owner }})
{% else %}
{{ object.owner }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Reviewer</th>
<td>
{% if object.reviewer.get_full_name %}
{{ object.reviewer.get_full_name }} ({{ object.reviewer }})
{% else %}
{{ object.reviewer }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Status</th>
<td>
{{ object.status }}
</td>
</tr>
<tr>
<th scope="row">State</th>
<td>
{{ object.state }}
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Staged Changes</h5>
<div class="card-body table-responsive">
{% render_table staged_change_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=staged_change_table.paginator page=staged_change_table.page %}
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}