mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-27 01:36:11 -06:00
review request view
This commit is contained in:
parent
c72dabd450
commit
69b09d5154
@ -49,6 +49,7 @@ __all__ = (
|
|||||||
'TagSerializer',
|
'TagSerializer',
|
||||||
'WebhookSerializer',
|
'WebhookSerializer',
|
||||||
'NotificationSerializer',
|
'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 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'
|
||||||
|
]
|
||||||
|
@ -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,
|
||||||
|
@ -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'))),
|
||||||
]
|
]
|
||||||
|
@ -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__}',
|
||||||
|
@ -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=[]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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:
|
||||||
|
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