mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 17:26:10 -06:00
review request view
This commit is contained in:
parent
c72dabd450
commit
69b09d5154
@ -49,6 +49,7 @@ __all__ = (
|
||||
'TagSerializer',
|
||||
'WebhookSerializer',
|
||||
'NotificationSerializer',
|
||||
'ReviewRequestSerializer',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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'
|
||||
]
|
||||
|
@ -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,
|
||||
|
@ -93,5 +93,9 @@ urlpatterns = [
|
||||
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', 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/<int:pk>/', include(get_model_urls('extras', 'reviewrequest'))),
|
||||
]
|
||||
|
@ -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__}',
|
||||
|
@ -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=[]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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 <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):
|
||||
# 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} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
|
||||
else:
|
||||
msg = f'{msg} {obj}'
|
||||
messages.success(request, msg)
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
|
82
netbox/templates/extras/reviewrequest.html
Normal file
82
netbox/templates/extras/reviewrequest.html
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user