nautobot 2.4.17__py3-none-any.whl → 2.4.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/apps/views.py +2 -0
- nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
- nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
- nautobot/circuits/tests/integration/test_circuit.py +2 -2
- nautobot/circuits/views.py +32 -15
- nautobot/core/filters.py +2 -2
- nautobot/core/settings.py +1 -0
- nautobot/core/settings.yaml +9 -0
- nautobot/core/tables.py +21 -23
- nautobot/core/templates/components/breadcrumbs.html +19 -0
- nautobot/core/templates/generic/object_changelog.html +0 -2
- nautobot/core/templates/generic/object_list.html +15 -12
- nautobot/core/templates/generic/object_notes.html +0 -2
- nautobot/core/templates/generic/object_retrieve.html +16 -9
- nautobot/core/templatetags/helpers.py +24 -0
- nautobot/core/templatetags/ui_framework.py +40 -5
- nautobot/core/testing/filters.py +37 -21
- nautobot/core/testing/views.py +25 -0
- nautobot/core/tests/test_tables.py +43 -6
- nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
- nautobot/core/tests/test_titles.py +2 -2
- nautobot/core/tests/test_ui.py +14 -1
- nautobot/core/tests/test_views.py +45 -0
- nautobot/core/ui/breadcrumbs.py +13 -8
- nautobot/core/ui/object_detail.py +43 -5
- nautobot/core/ui/titles.py +9 -5
- nautobot/core/views/__init__.py +24 -3
- nautobot/core/views/generic.py +42 -17
- nautobot/core/views/mixins.py +146 -12
- nautobot/core/views/utils.py +117 -0
- nautobot/dcim/models/devices.py +4 -0
- nautobot/dcim/tables/__init__.py +2 -0
- nautobot/dcim/tables/devices.py +24 -0
- nautobot/dcim/tables/power.py +2 -2
- nautobot/dcim/templates/dcim/device/base.html +1 -11
- nautobot/dcim/templates/dcim/device_component.html +0 -19
- nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
- nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
- nautobot/dcim/tests/test_views.py +41 -0
- nautobot/dcim/views.py +160 -39
- nautobot/extras/filters/mixins.py +1 -1
- nautobot/extras/forms/forms.py +15 -0
- nautobot/extras/models/groups.py +10 -1
- nautobot/extras/models/jobs.py +2 -2
- nautobot/extras/plugins/views.py +18 -5
- nautobot/extras/tables.py +4 -2
- nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
- nautobot/extras/templates/extras/dynamicgroup.html +2 -99
- nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
- nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
- nautobot/extras/templates/extras/gitrepository.html +2 -82
- nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
- nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
- nautobot/extras/templates/extras/gitrepository_update.html +13 -0
- nautobot/extras/templates/extras/note_retrieve.html +0 -52
- nautobot/extras/templates/extras/plugin_detail.html +3 -7
- nautobot/extras/templates/extras/plugins_list.html +0 -2
- nautobot/extras/tests/test_dynamicgroups.py +73 -18
- nautobot/extras/tests/test_views.py +5 -0
- nautobot/extras/urls.py +2 -94
- nautobot/extras/views.py +424 -430
- nautobot/ipam/querysets.py +3 -3
- nautobot/ipam/signals.py +6 -1
- nautobot/ipam/templates/ipam/prefix.html +0 -8
- nautobot/ipam/tests/test_api.py +5 -0
- nautobot/ipam/tests/test_models.py +387 -0
- nautobot/ipam/tests/test_querysets.py +46 -0
- nautobot/ipam/utils/migrations.py +1 -1
- nautobot/ipam/views.py +17 -8
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
- nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
- nautobot/project-static/docs/development/core/getting-started.html +0 -15
- nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +300 -300
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
- nautobot/project-static/img/nautobot_icon.svg +32 -34
- nautobot/project-static/js/table_sorting_indicator.js +0 -2
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
- nautobot/core/templates/inc/breadcrumbs.html +0 -14
- nautobot/project-static/docs/requirements.txt +0 -14
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/entry_points.txt +0 -0
nautobot/extras/views.py
CHANGED
|
@@ -7,7 +7,7 @@ from django.contrib import messages
|
|
|
7
7
|
from django.contrib.contenttypes.models import ContentType
|
|
8
8
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
9
9
|
from django.db import IntegrityError, transaction
|
|
10
|
-
from django.db.models import
|
|
10
|
+
from django.db.models import Q
|
|
11
11
|
from django.forms.utils import pretty_name
|
|
12
12
|
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
|
13
13
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
@@ -25,7 +25,6 @@ from jsonschema.validators import Draft7Validator
|
|
|
25
25
|
from rest_framework.decorators import action
|
|
26
26
|
from rest_framework.permissions import IsAuthenticated
|
|
27
27
|
|
|
28
|
-
from nautobot.apps.ui import BaseTextPanel
|
|
29
28
|
from nautobot.core.choices import ButtonActionColorChoices
|
|
30
29
|
from nautobot.core.constants import PAGINATE_COUNT_DEFAULT
|
|
31
30
|
from nautobot.core.events import publish_event
|
|
@@ -36,8 +35,9 @@ from nautobot.core.models.utils import pretty_print_query, serialize_object_v2
|
|
|
36
35
|
from nautobot.core.tables import ButtonsColumn
|
|
37
36
|
from nautobot.core.templatetags import helpers
|
|
38
37
|
from nautobot.core.ui import object_detail
|
|
38
|
+
from nautobot.core.ui.breadcrumbs import Breadcrumbs, ModelBreadcrumbItem
|
|
39
39
|
from nautobot.core.ui.choices import SectionChoices
|
|
40
|
-
from nautobot.core.ui.
|
|
40
|
+
from nautobot.core.ui.titles import Titles
|
|
41
41
|
from nautobot.core.utils.config import get_settings_or_config
|
|
42
42
|
from nautobot.core.utils.lookup import (
|
|
43
43
|
get_filterset_for_model,
|
|
@@ -46,11 +46,9 @@ from nautobot.core.utils.lookup import (
|
|
|
46
46
|
get_table_class_string_from_view_name,
|
|
47
47
|
get_table_for_model,
|
|
48
48
|
)
|
|
49
|
-
from nautobot.core.utils.permissions import get_permission_for_model
|
|
50
49
|
from nautobot.core.utils.requests import is_single_choice_field, normalize_querydict
|
|
51
50
|
from nautobot.core.views import generic, viewsets
|
|
52
51
|
from nautobot.core.views.mixins import (
|
|
53
|
-
GetReturnURLMixin,
|
|
54
52
|
ObjectBulkCreateViewMixin,
|
|
55
53
|
ObjectBulkDestroyViewMixin,
|
|
56
54
|
ObjectBulkUpdateViewMixin,
|
|
@@ -63,7 +61,7 @@ from nautobot.core.views.mixins import (
|
|
|
63
61
|
ObjectPermissionRequiredMixin,
|
|
64
62
|
)
|
|
65
63
|
from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
|
|
66
|
-
from nautobot.core.views.utils import prepare_cloned_fields
|
|
64
|
+
from nautobot.core.views.utils import get_obj_from_context, prepare_cloned_fields
|
|
67
65
|
from nautobot.core.views.viewsets import NautobotUIViewSet
|
|
68
66
|
from nautobot.dcim.models import Controller, Device, Interface, Module, Rack, VirtualDeviceContext
|
|
69
67
|
from nautobot.dcim.tables import (
|
|
@@ -160,12 +158,12 @@ class ComputedFieldUIViewSet(NautobotUIViewSet):
|
|
|
160
158
|
fields="__all__",
|
|
161
159
|
exclude_fields=["template"],
|
|
162
160
|
),
|
|
163
|
-
ObjectTextPanel(
|
|
161
|
+
object_detail.ObjectTextPanel(
|
|
164
162
|
label="Template",
|
|
165
163
|
section=SectionChoices.FULL_WIDTH,
|
|
166
164
|
weight=100,
|
|
167
165
|
object_field="template",
|
|
168
|
-
render_as=ObjectTextPanel.RenderOptions.CODE,
|
|
166
|
+
render_as=object_detail.ObjectTextPanel.RenderOptions.CODE,
|
|
169
167
|
),
|
|
170
168
|
),
|
|
171
169
|
)
|
|
@@ -576,6 +574,59 @@ class CustomFieldUIViewSet(NautobotUIViewSet):
|
|
|
576
574
|
template_name = "extras/customfield_update.html"
|
|
577
575
|
action_buttons = ("add",)
|
|
578
576
|
|
|
577
|
+
class CustomFieldObjectFieldsPanel(object_detail.ObjectFieldsPanel):
|
|
578
|
+
def render_value(self, key, value, context):
|
|
579
|
+
obj = get_obj_from_context(context, self.context_object_key)
|
|
580
|
+
_type = getattr(obj, "type", None)
|
|
581
|
+
|
|
582
|
+
if key == "default":
|
|
583
|
+
if not value:
|
|
584
|
+
return helpers.HTML_NONE
|
|
585
|
+
if _type == "markdown":
|
|
586
|
+
return helpers.render_markdown(value)
|
|
587
|
+
elif _type == "json":
|
|
588
|
+
return helpers.render_json(value)
|
|
589
|
+
else:
|
|
590
|
+
return helpers.placeholder(value)
|
|
591
|
+
return super().render_value(key, value, context)
|
|
592
|
+
|
|
593
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
594
|
+
panels=[
|
|
595
|
+
CustomFieldObjectFieldsPanel(
|
|
596
|
+
weight=100,
|
|
597
|
+
section=SectionChoices.LEFT_HALF,
|
|
598
|
+
fields="__all__",
|
|
599
|
+
exclude_fields=["content_types", "validation_minimum", "validation_maximum", "validation_regex"],
|
|
600
|
+
),
|
|
601
|
+
object_detail.DataTablePanel(
|
|
602
|
+
weight=200,
|
|
603
|
+
section=SectionChoices.LEFT_HALF,
|
|
604
|
+
label="Custom Field Choices",
|
|
605
|
+
context_data_key="choices_data",
|
|
606
|
+
context_columns_key="columns",
|
|
607
|
+
context_column_headers_key="header",
|
|
608
|
+
),
|
|
609
|
+
object_detail.ObjectFieldsPanel(
|
|
610
|
+
section=SectionChoices.RIGHT_HALF,
|
|
611
|
+
weight=100,
|
|
612
|
+
label="Assignment",
|
|
613
|
+
fields=[
|
|
614
|
+
"content_types",
|
|
615
|
+
],
|
|
616
|
+
key_transforms={"content_types": "Content Types"},
|
|
617
|
+
),
|
|
618
|
+
object_detail.ObjectFieldsPanel(
|
|
619
|
+
section=SectionChoices.RIGHT_HALF,
|
|
620
|
+
weight=200,
|
|
621
|
+
label="Validation Rules",
|
|
622
|
+
fields=["validation_minimum", "validation_maximum", "validation_regex"],
|
|
623
|
+
value_transforms={
|
|
624
|
+
"validation_regex": [lambda val: None if val == "" else val, helpers.pre_tag],
|
|
625
|
+
},
|
|
626
|
+
),
|
|
627
|
+
]
|
|
628
|
+
)
|
|
629
|
+
|
|
579
630
|
def get_extra_context(self, request, instance):
|
|
580
631
|
context = super().get_extra_context(request, instance)
|
|
581
632
|
|
|
@@ -585,6 +636,16 @@ class CustomFieldUIViewSet(NautobotUIViewSet):
|
|
|
585
636
|
else:
|
|
586
637
|
context["choices"] = forms.CustomFieldChoiceFormSet(instance=instance)
|
|
587
638
|
|
|
639
|
+
if self.action == "retrieve":
|
|
640
|
+
choices_data = []
|
|
641
|
+
|
|
642
|
+
for choice in instance.custom_field_choices.all():
|
|
643
|
+
choices_data.append({"value": choice.value, "weight": choice.weight})
|
|
644
|
+
|
|
645
|
+
context["columns"] = ["value", "weight"]
|
|
646
|
+
context["header"] = ["Value", "Weight"]
|
|
647
|
+
context["choices_data"] = choices_data
|
|
648
|
+
|
|
588
649
|
return context
|
|
589
650
|
|
|
590
651
|
def form_save(self, form, **kwargs):
|
|
@@ -613,9 +674,9 @@ class CustomLinkUIViewSet(NautobotUIViewSet):
|
|
|
613
674
|
serializer_class = serializers.CustomLinkSerializer
|
|
614
675
|
table_class = tables.CustomLinkTable
|
|
615
676
|
|
|
616
|
-
object_detail_content = ObjectDetailContent(
|
|
677
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
617
678
|
panels=[
|
|
618
|
-
ObjectFieldsPanel(
|
|
679
|
+
object_detail.ObjectFieldsPanel(
|
|
619
680
|
label="Custom Link",
|
|
620
681
|
section=SectionChoices.LEFT_HALF,
|
|
621
682
|
weight=100,
|
|
@@ -649,212 +710,263 @@ class CustomLinkUIViewSet(NautobotUIViewSet):
|
|
|
649
710
|
#
|
|
650
711
|
|
|
651
712
|
|
|
652
|
-
class
|
|
713
|
+
class DynamicGroupUIViewSet(NautobotUIViewSet):
|
|
714
|
+
bulk_update_form_class = forms.DynamicGroupBulkEditForm
|
|
715
|
+
filterset_class = filters.DynamicGroupFilterSet
|
|
716
|
+
filterset_form_class = forms.DynamicGroupFilterForm
|
|
717
|
+
form_class = forms.DynamicGroupForm
|
|
653
718
|
queryset = DynamicGroup.objects.all()
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
filterset_form = forms.DynamicGroupFilterForm
|
|
719
|
+
serializer_class = serializers.DynamicGroupSerializer
|
|
720
|
+
table_class = tables.DynamicGroupTable
|
|
657
721
|
action_buttons = ("add",)
|
|
658
722
|
|
|
659
|
-
|
|
660
|
-
class DynamicGroupView(generic.ObjectView):
|
|
661
|
-
queryset = DynamicGroup.objects.all()
|
|
662
|
-
|
|
663
723
|
def get_extra_context(self, request, instance):
|
|
664
724
|
context = super().get_extra_context(request, instance)
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
if table_class is not None:
|
|
676
|
-
# Members table (for display on Members nav tab)
|
|
677
|
-
if hasattr(members, "without_tree_fields"):
|
|
678
|
-
members = members.without_tree_fields()
|
|
679
|
-
members_table = table_class(
|
|
680
|
-
members.restrict(request.user, "view"),
|
|
681
|
-
orderable=False,
|
|
682
|
-
exclude=["dynamic_group_count"],
|
|
683
|
-
hide_hierarchy_ui=True,
|
|
684
|
-
)
|
|
685
|
-
paginate = {
|
|
686
|
-
"paginator_class": EnhancedPaginator,
|
|
687
|
-
"per_page": get_paginate_count(request),
|
|
688
|
-
}
|
|
689
|
-
RequestConfig(request, paginate).configure(members_table)
|
|
690
|
-
|
|
691
|
-
# Descendants table
|
|
692
|
-
descendants_memberships = instance.membership_tree()
|
|
693
|
-
descendants_table = tables.NestedDynamicGroupDescendantsTable(
|
|
694
|
-
descendants_memberships,
|
|
695
|
-
orderable=False,
|
|
696
|
-
)
|
|
697
|
-
descendants_tree = {m.pk: m.depth for m in descendants_memberships}
|
|
725
|
+
if self.action in ("create", "update"):
|
|
726
|
+
filterform_class = instance.generate_filter_form()
|
|
727
|
+
if filterform_class is None:
|
|
728
|
+
context["filter_form"] = None
|
|
729
|
+
elif request.POST:
|
|
730
|
+
context["filter_form"] = filterform_class(data=request.POST)
|
|
731
|
+
else:
|
|
732
|
+
initial = instance.get_initial()
|
|
733
|
+
context["filter_form"] = filterform_class(initial=initial)
|
|
698
734
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
735
|
+
formset_kwargs = {"instance": instance}
|
|
736
|
+
if request.POST:
|
|
737
|
+
formset_kwargs["data"] = request.POST
|
|
738
|
+
context["children"] = forms.DynamicGroupMembershipFormSet(**formset_kwargs)
|
|
703
739
|
|
|
740
|
+
elif self.action == "retrieve":
|
|
741
|
+
model = instance.model
|
|
742
|
+
table_class = get_table_for_model(model)
|
|
704
743
|
if instance.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
|
|
705
|
-
|
|
706
|
-
|
|
744
|
+
# Ensure that members cache is up-to-date for this specific group
|
|
745
|
+
members = instance.update_cached_members()
|
|
746
|
+
messages.success(request, f"Refreshed cached members list for {instance}")
|
|
707
747
|
else:
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
748
|
+
members = instance.members
|
|
749
|
+
if table_class is not None:
|
|
750
|
+
if hasattr(members, "without_tree_fields"):
|
|
751
|
+
members = members.without_tree_fields()
|
|
752
|
+
|
|
753
|
+
members_table = table_class(
|
|
754
|
+
members.restrict(request.user, "view"),
|
|
755
|
+
orderable=False,
|
|
756
|
+
exclude=["dynamic_group_count"],
|
|
757
|
+
hide_hierarchy_ui=True,
|
|
758
|
+
)
|
|
759
|
+
paginate = {
|
|
760
|
+
"paginator_class": EnhancedPaginator,
|
|
761
|
+
"per_page": get_paginate_count(request),
|
|
762
|
+
}
|
|
763
|
+
RequestConfig(request, paginate).configure(members_table)
|
|
719
764
|
|
|
720
|
-
|
|
765
|
+
# Descendants table
|
|
766
|
+
descendants_memberships = instance.membership_tree()
|
|
767
|
+
descendants_table = tables.NestedDynamicGroupDescendantsTable(
|
|
768
|
+
descendants_memberships,
|
|
769
|
+
orderable=False,
|
|
770
|
+
)
|
|
771
|
+
descendants_tree = {m.pk: m.depth for m in descendants_memberships}
|
|
721
772
|
|
|
773
|
+
# Ancestors table
|
|
774
|
+
ancestors = instance.get_ancestors()
|
|
775
|
+
ancestors_table = tables.NestedDynamicGroupAncestorsTable(
|
|
776
|
+
ancestors,
|
|
777
|
+
orderable=False,
|
|
778
|
+
)
|
|
779
|
+
ancestors_tree = instance.flatten_ancestors_tree(instance.ancestors_tree())
|
|
780
|
+
if instance.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
|
|
781
|
+
context["raw_query"] = pretty_print_query(instance.generate_query())
|
|
782
|
+
context["members_list_url"] = None
|
|
783
|
+
else:
|
|
784
|
+
context["raw_query"] = None
|
|
785
|
+
try:
|
|
786
|
+
context["members_list_url"] = reverse(get_route_for_model(instance.model, "list"))
|
|
787
|
+
except NoReverseMatch:
|
|
788
|
+
context["members_list_url"] = None
|
|
789
|
+
|
|
790
|
+
context.update(
|
|
791
|
+
{
|
|
792
|
+
"members_verbose_name_plural": instance.model._meta.verbose_name_plural,
|
|
793
|
+
"members_table": members_table,
|
|
794
|
+
"ancestors_table": ancestors_table,
|
|
795
|
+
"ancestors_tree": ancestors_tree,
|
|
796
|
+
"descendants_table": descendants_table,
|
|
797
|
+
"descendants_tree": descendants_tree,
|
|
798
|
+
}
|
|
799
|
+
)
|
|
722
800
|
|
|
723
|
-
|
|
724
|
-
queryset = DynamicGroup.objects.all()
|
|
725
|
-
model_form = forms.DynamicGroupForm
|
|
726
|
-
template_name = "extras/dynamicgroup_edit.html"
|
|
801
|
+
return context
|
|
727
802
|
|
|
728
|
-
def
|
|
729
|
-
|
|
803
|
+
def form_save(self, form, **kwargs):
|
|
804
|
+
obj = form.save(commit=False)
|
|
805
|
+
context = self.get_extra_context(self.request, obj)
|
|
806
|
+
|
|
807
|
+
# Save filters
|
|
808
|
+
if obj.group_type == DynamicGroupTypeChoices.TYPE_DYNAMIC_FILTER:
|
|
809
|
+
filter_form = context.get("filter_form")
|
|
810
|
+
if not filter_form or not filter_form.is_valid():
|
|
811
|
+
form.add_error(None, "Errors encountered when saving Dynamic Group associations. See below.")
|
|
812
|
+
raise ValidationError("invalid dynamic group filter_form")
|
|
813
|
+
try:
|
|
814
|
+
obj.set_filter(filter_form.cleaned_data)
|
|
815
|
+
except ValidationError as err:
|
|
816
|
+
form.add_error(None, "Invalid filter detected in existing DynamicGroup filter data.")
|
|
817
|
+
for msg in getattr(err, "messages", [str(err)]):
|
|
818
|
+
if msg:
|
|
819
|
+
form.add_error(None, msg)
|
|
820
|
+
raise
|
|
821
|
+
|
|
822
|
+
# After filters have been set, now we save the object to the database.
|
|
823
|
+
obj.save()
|
|
824
|
+
# Save m2m fields, such as Tags https://docs.djangoproject.com/en/3.2/topics/forms/modelforms/#the-save-method
|
|
825
|
+
form.save_m2m()
|
|
826
|
+
|
|
827
|
+
# Process the formsets for children
|
|
828
|
+
children = context.get("children")
|
|
829
|
+
if children and not children.is_valid():
|
|
830
|
+
form.add_error(None, "Errors encountered when saving Dynamic Group associations. See below.")
|
|
831
|
+
# dedupe only non-field errors to avoid duplicates in the banner
|
|
832
|
+
added_errors = set()
|
|
833
|
+
for f in children.forms:
|
|
834
|
+
for msg in f.non_field_errors():
|
|
835
|
+
if msg not in added_errors:
|
|
836
|
+
form.add_error(None, msg)
|
|
837
|
+
added_errors.add(msg)
|
|
838
|
+
raise ValidationError("invalid DynamicGroupMembershipFormSet")
|
|
839
|
+
|
|
840
|
+
if children:
|
|
841
|
+
children.save()
|
|
730
842
|
|
|
731
|
-
|
|
843
|
+
return obj
|
|
732
844
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
filter_form = filterform_class(data=request.POST)
|
|
737
|
-
else:
|
|
738
|
-
initial = instance.get_initial()
|
|
739
|
-
filter_form = filterform_class(initial=initial)
|
|
845
|
+
# Suppress the global top banner when ValidationError happens
|
|
846
|
+
def _handle_validation_error(self, e):
|
|
847
|
+
self.has_error = True
|
|
740
848
|
|
|
741
|
-
|
|
849
|
+
@action(
|
|
850
|
+
detail=False,
|
|
851
|
+
methods=["GET", "POST"],
|
|
852
|
+
url_path="assign-members",
|
|
853
|
+
url_name="bulk_assign",
|
|
854
|
+
custom_view_base_action="add",
|
|
855
|
+
custom_view_additional_permissions=[
|
|
856
|
+
"extras.add_staticgroupassociation",
|
|
857
|
+
],
|
|
858
|
+
)
|
|
859
|
+
def bulk_assign(self, request):
|
|
860
|
+
"""
|
|
861
|
+
Update the static group assignments of the provided `pk_list` (or `_all`) of the given `content_type`.
|
|
742
862
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
863
|
+
Unlike BulkEditView, this takes a single POST rather than two to perform its operation as
|
|
864
|
+
there's no separate confirmation step involved.
|
|
865
|
+
"""
|
|
866
|
+
if request.method == "GET":
|
|
867
|
+
return redirect(reverse("extras:staticgroupassociation_list"))
|
|
746
868
|
|
|
747
|
-
|
|
869
|
+
# TODO more error handling - content-type doesn't exist, model_class not found, filterset missing, etc.
|
|
870
|
+
content_type = ContentType.objects.get(pk=request.POST.get("content_type"))
|
|
871
|
+
model = content_type.model_class()
|
|
872
|
+
self.default_return_url = get_route_for_model(model, "list")
|
|
873
|
+
filterset_class = get_filterset_for_model(model)
|
|
748
874
|
|
|
749
|
-
|
|
875
|
+
if request.POST.get("_all"):
|
|
876
|
+
if filterset_class:
|
|
877
|
+
pk_list = list(filterset_class(request.GET, model.objects.only("pk")).qs.values_list("pk", flat=True))
|
|
878
|
+
else:
|
|
879
|
+
pk_list = list(model.objects.values_list("pk", flat=True))
|
|
880
|
+
else:
|
|
881
|
+
pk_list = request.POST.getlist("pk")
|
|
750
882
|
|
|
751
|
-
|
|
752
|
-
obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
|
|
753
|
-
form = self.model_form(data=request.POST, files=request.FILES, instance=obj)
|
|
883
|
+
form = forms.DynamicGroupBulkAssignForm(model, request.POST)
|
|
754
884
|
restrict_form_fields(form, request.user)
|
|
755
885
|
|
|
756
886
|
if form.is_valid():
|
|
757
887
|
logger.debug("Form validation was successful")
|
|
758
|
-
|
|
759
888
|
try:
|
|
760
889
|
with transaction.atomic():
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if obj.group_type == DynamicGroupTypeChoices.TYPE_DYNAMIC_FILTER:
|
|
767
|
-
# Process the filter form and save the query filters to `obj.filter`.
|
|
768
|
-
filter_form = ctx["filter_form"]
|
|
769
|
-
if filter_form.is_valid():
|
|
770
|
-
obj.set_filter(filter_form.cleaned_data)
|
|
890
|
+
add_to_groups = list(form.cleaned_data["add_to_groups"])
|
|
891
|
+
new_group_name = form.cleaned_data["create_and_assign_to_new_group_name"]
|
|
892
|
+
if new_group_name:
|
|
893
|
+
if not request.user.has_perm("extras.add_dynamicgroup"):
|
|
894
|
+
raise DynamicGroup.DoesNotExist
|
|
771
895
|
else:
|
|
772
|
-
|
|
896
|
+
new_group = DynamicGroup(
|
|
897
|
+
name=new_group_name,
|
|
898
|
+
content_type=content_type,
|
|
899
|
+
group_type=DynamicGroupTypeChoices.TYPE_STATIC,
|
|
900
|
+
)
|
|
901
|
+
new_group.validated_save()
|
|
902
|
+
# Check permissions
|
|
903
|
+
DynamicGroup.objects.restrict(request.user, "add").get(pk=new_group.pk)
|
|
773
904
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
self.queryset.get(pk=obj.pk)
|
|
905
|
+
add_to_groups.append(new_group)
|
|
906
|
+
msg = "Created dynamic group"
|
|
907
|
+
logger.info(f"{msg} {new_group} (PK: {new_group.pk})")
|
|
908
|
+
msg = format_html('{} <a href="{}">{}</a>', msg, new_group.get_absolute_url(), new_group)
|
|
909
|
+
messages.success(request, msg)
|
|
780
910
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
messages.success(request, msg)
|
|
911
|
+
with deferred_change_logging_for_bulk_operation():
|
|
912
|
+
associations = []
|
|
913
|
+
for pk in pk_list:
|
|
914
|
+
for dynamic_group in add_to_groups:
|
|
915
|
+
association, created = StaticGroupAssociation.objects.get_or_create(
|
|
916
|
+
dynamic_group=dynamic_group,
|
|
917
|
+
associated_object_type_id=content_type.id,
|
|
918
|
+
associated_object_id=pk,
|
|
919
|
+
)
|
|
920
|
+
association.validated_save()
|
|
921
|
+
associations.append(association)
|
|
922
|
+
if created:
|
|
923
|
+
logger.debug("Created %s", association)
|
|
795
924
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
925
|
+
# Enforce object-level permissions
|
|
926
|
+
permitted_associations = StaticGroupAssociation.objects.restrict(request.user, "add")
|
|
927
|
+
if permitted_associations.filter(pk__in=[assoc.pk for assoc in associations]).count() != len(
|
|
928
|
+
associations
|
|
929
|
+
):
|
|
930
|
+
raise StaticGroupAssociation.DoesNotExist
|
|
801
931
|
|
|
802
|
-
|
|
932
|
+
if associations:
|
|
933
|
+
msg = (
|
|
934
|
+
f"Added {len(pk_list)} {model._meta.verbose_name_plural} "
|
|
935
|
+
f"to {len(add_to_groups)} dynamic group(s)."
|
|
936
|
+
)
|
|
937
|
+
logger.info(msg)
|
|
938
|
+
messages.success(request, msg)
|
|
803
939
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
940
|
+
if form.cleaned_data["remove_from_groups"]:
|
|
941
|
+
for dynamic_group in form.cleaned_data["remove_from_groups"]:
|
|
942
|
+
(
|
|
943
|
+
StaticGroupAssociation.objects.restrict(request.user, "delete")
|
|
944
|
+
.filter(
|
|
945
|
+
dynamic_group=dynamic_group,
|
|
946
|
+
associated_object_type=content_type,
|
|
947
|
+
associated_object_id__in=pk_list,
|
|
948
|
+
)
|
|
949
|
+
.delete()
|
|
950
|
+
)
|
|
809
951
|
|
|
952
|
+
msg = (
|
|
953
|
+
f"Removed {len(pk_list)} {model._meta.verbose_name_plural} from "
|
|
954
|
+
f"{len(form.cleaned_data['remove_from_groups'])} dynamic group(s)."
|
|
955
|
+
)
|
|
956
|
+
logger.info(msg)
|
|
957
|
+
messages.success(request, msg)
|
|
958
|
+
except ValidationError as e:
|
|
959
|
+
messages.error(request, e)
|
|
810
960
|
except ObjectDoesNotExist:
|
|
811
|
-
msg = "
|
|
812
|
-
logger.
|
|
813
|
-
|
|
814
|
-
except RuntimeError:
|
|
815
|
-
msg = "Errors encountered when saving Dynamic Group associations. See below."
|
|
816
|
-
logger.debug(msg)
|
|
817
|
-
form.add_error(None, msg)
|
|
818
|
-
except ProtectedError as err:
|
|
819
|
-
# e.g. Trying to delete a something that is in use.
|
|
820
|
-
err_msg = err.args[0]
|
|
821
|
-
protected_obj = err.protected_objects[0]
|
|
822
|
-
msg = f"{protected_obj.value}: {err_msg} Please cancel this edit and start again."
|
|
823
|
-
logger.debug(msg)
|
|
824
|
-
form.add_error(None, msg)
|
|
825
|
-
except ValidationError as err:
|
|
826
|
-
msg = "Invalid filter detected in existing DynamicGroup filter data."
|
|
827
|
-
logger.debug(msg)
|
|
828
|
-
err_messages = err.args[0].split("\n")
|
|
829
|
-
for message in err_messages:
|
|
830
|
-
if message:
|
|
831
|
-
form.add_error(None, message)
|
|
961
|
+
msg = "Static group association failed due to object-level permissions violation"
|
|
962
|
+
logger.warning(msg)
|
|
963
|
+
messages.error(request, msg)
|
|
832
964
|
|
|
833
965
|
else:
|
|
834
966
|
logger.debug("Form validation failed")
|
|
967
|
+
messages.error(request, form.errors)
|
|
835
968
|
|
|
836
|
-
return
|
|
837
|
-
request,
|
|
838
|
-
self.template_name,
|
|
839
|
-
{
|
|
840
|
-
"obj": obj,
|
|
841
|
-
"obj_type": self.queryset.model._meta.verbose_name,
|
|
842
|
-
"form": form,
|
|
843
|
-
"return_url": self.get_return_url(request, obj),
|
|
844
|
-
"editing": obj.present_in_database,
|
|
845
|
-
**self.get_extra_context(request, obj),
|
|
846
|
-
},
|
|
847
|
-
)
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
class DynamicGroupDeleteView(generic.ObjectDeleteView):
|
|
851
|
-
queryset = DynamicGroup.objects.all()
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
class DynamicGroupBulkDeleteView(generic.BulkDeleteView):
|
|
855
|
-
queryset = DynamicGroup.objects.all()
|
|
856
|
-
table = tables.DynamicGroupTable
|
|
857
|
-
filterset = filters.DynamicGroupFilterSet
|
|
969
|
+
return redirect(self.get_return_url(request))
|
|
858
970
|
|
|
859
971
|
|
|
860
972
|
class ObjectDynamicGroupsView(generic.GenericView):
|
|
@@ -870,6 +982,8 @@ class ObjectDynamicGroupsView(generic.GenericView):
|
|
|
870
982
|
"""
|
|
871
983
|
|
|
872
984
|
base_template: Optional[str] = None
|
|
985
|
+
breadcrumbs = Breadcrumbs()
|
|
986
|
+
view_titles = Titles()
|
|
873
987
|
|
|
874
988
|
def get(self, request, model, **kwargs):
|
|
875
989
|
# Handle QuerySet restriction of parent object if needed
|
|
@@ -903,6 +1017,9 @@ class ObjectDynamicGroupsView(generic.GenericView):
|
|
|
903
1017
|
"table": dynamicgroups_table,
|
|
904
1018
|
"base_template": base_template,
|
|
905
1019
|
"active_tab": "dynamic-groups",
|
|
1020
|
+
"breadcrumbs": self.breadcrumbs,
|
|
1021
|
+
"view_titles": self.view_titles,
|
|
1022
|
+
"detail": True,
|
|
906
1023
|
},
|
|
907
1024
|
)
|
|
908
1025
|
|
|
@@ -921,26 +1038,26 @@ class ExportTemplateUIViewSet(NautobotUIViewSet):
|
|
|
921
1038
|
serializer_class = serializers.ExportTemplateSerializer
|
|
922
1039
|
table_class = tables.ExportTemplateTable
|
|
923
1040
|
|
|
924
|
-
object_detail_content = ObjectDetailContent(
|
|
1041
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
925
1042
|
panels=[
|
|
926
|
-
ObjectFieldsPanel(
|
|
1043
|
+
object_detail.ObjectFieldsPanel(
|
|
927
1044
|
label="Details",
|
|
928
1045
|
section=SectionChoices.LEFT_HALF,
|
|
929
1046
|
weight=100,
|
|
930
1047
|
fields=["name", "owner", "description"],
|
|
931
1048
|
),
|
|
932
|
-
ObjectFieldsPanel(
|
|
1049
|
+
object_detail.ObjectFieldsPanel(
|
|
933
1050
|
label="Template",
|
|
934
1051
|
section=SectionChoices.LEFT_HALF,
|
|
935
1052
|
weight=200,
|
|
936
1053
|
fields=["content_type", "mime_type", "file_extension"],
|
|
937
1054
|
),
|
|
938
|
-
ObjectTextPanel(
|
|
1055
|
+
object_detail.ObjectTextPanel(
|
|
939
1056
|
label="Code Template",
|
|
940
1057
|
section=SectionChoices.RIGHT_HALF,
|
|
941
1058
|
weight=100,
|
|
942
1059
|
object_field="template_code",
|
|
943
|
-
render_as=ObjectTextPanel.RenderOptions.CODE,
|
|
1060
|
+
render_as=object_detail.ObjectTextPanel.RenderOptions.CODE,
|
|
944
1061
|
),
|
|
945
1062
|
]
|
|
946
1063
|
)
|
|
@@ -987,97 +1104,6 @@ class ExternalIntegrationUIViewSet(NautobotUIViewSet):
|
|
|
987
1104
|
#
|
|
988
1105
|
|
|
989
1106
|
|
|
990
|
-
class GitRepositoryListView(generic.ObjectListView):
|
|
991
|
-
queryset = GitRepository.objects.all()
|
|
992
|
-
filterset = filters.GitRepositoryFilterSet
|
|
993
|
-
filterset_form = forms.GitRepositoryFilterForm
|
|
994
|
-
table = tables.GitRepositoryTable
|
|
995
|
-
template_name = "extras/gitrepository_list.html"
|
|
996
|
-
|
|
997
|
-
def extra_context(self):
|
|
998
|
-
# Get the newest results for each repository name
|
|
999
|
-
results = {
|
|
1000
|
-
r.task_kwargs["repository"]: r
|
|
1001
|
-
for r in JobResult.objects.filter(
|
|
1002
|
-
task_name__startswith="nautobot.core.jobs.GitRepository",
|
|
1003
|
-
task_kwargs__repository__isnull=False,
|
|
1004
|
-
status__in=JobResultStatusChoices.READY_STATES,
|
|
1005
|
-
)
|
|
1006
|
-
.order_by("date_done")
|
|
1007
|
-
.defer("result")
|
|
1008
|
-
}
|
|
1009
|
-
return {
|
|
1010
|
-
"job_results": results,
|
|
1011
|
-
"datasource_contents": get_datasource_contents("extras.gitrepository"),
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
class GitRepositoryView(generic.ObjectView):
|
|
1016
|
-
queryset = GitRepository.objects.all()
|
|
1017
|
-
|
|
1018
|
-
def get_extra_context(self, request, instance):
|
|
1019
|
-
return {
|
|
1020
|
-
"datasource_contents": get_datasource_contents("extras.gitrepository"),
|
|
1021
|
-
**super().get_extra_context(request, instance),
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
class GitRepositoryEditView(generic.ObjectEditView):
|
|
1026
|
-
queryset = GitRepository.objects.all()
|
|
1027
|
-
model_form = forms.GitRepositoryForm
|
|
1028
|
-
template_name = "extras/gitrepository_object_edit.html"
|
|
1029
|
-
|
|
1030
|
-
# TODO(jathan): Align with changes for v2 where we're not stashing the user on the instance for
|
|
1031
|
-
# magical calls and instead discretely calling `repo.sync(user=user, dry_run=dry_run)`, but
|
|
1032
|
-
# again, this will be moved to the API calls, so just something to keep in mind.
|
|
1033
|
-
def alter_obj(self, obj, request, url_args, url_kwargs):
|
|
1034
|
-
# A GitRepository needs to know the originating request when it's saved so that it can enqueue using it
|
|
1035
|
-
obj.user = request.user
|
|
1036
|
-
return super().alter_obj(obj, request, url_args, url_kwargs)
|
|
1037
|
-
|
|
1038
|
-
def get_return_url(self, request, obj=None, default_return_url=None):
|
|
1039
|
-
if request.method == "POST":
|
|
1040
|
-
return reverse("extras:gitrepository_result", kwargs={"pk": obj.pk})
|
|
1041
|
-
return super().get_return_url(request, obj=obj, default_return_url=default_return_url)
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
class GitRepositoryDeleteView(generic.ObjectDeleteView):
|
|
1045
|
-
queryset = GitRepository.objects.all()
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
class GitRepositoryBulkImportView(generic.BulkImportView): # 3.0 TODO: remove, unused
|
|
1049
|
-
queryset = GitRepository.objects.all()
|
|
1050
|
-
table = tables.GitRepositoryBulkTable
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
class GitRepositoryBulkEditView(generic.BulkEditView):
|
|
1054
|
-
queryset = GitRepository.objects.select_related("secrets_group")
|
|
1055
|
-
filterset = filters.GitRepositoryFilterSet
|
|
1056
|
-
table = tables.GitRepositoryBulkTable
|
|
1057
|
-
form = forms.GitRepositoryBulkEditForm
|
|
1058
|
-
|
|
1059
|
-
def alter_obj(self, obj, request, url_args, url_kwargs):
|
|
1060
|
-
# A GitRepository needs to know the originating request when it's saved so that it can enqueue using it
|
|
1061
|
-
obj.request = request
|
|
1062
|
-
return super().alter_obj(obj, request, url_args, url_kwargs)
|
|
1063
|
-
|
|
1064
|
-
def extra_context(self):
|
|
1065
|
-
return {
|
|
1066
|
-
"datasource_contents": get_datasource_contents("extras.gitrepository"),
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
class GitRepositoryBulkDeleteView(generic.BulkDeleteView):
|
|
1071
|
-
queryset = GitRepository.objects.all()
|
|
1072
|
-
table = tables.GitRepositoryBulkTable
|
|
1073
|
-
filterset = filters.GitRepositoryFilterSet
|
|
1074
|
-
|
|
1075
|
-
def extra_context(self):
|
|
1076
|
-
return {
|
|
1077
|
-
"datasource_contents": get_datasource_contents("extras.gitrepository"),
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
1107
|
def check_and_call_git_repository_function(request, pk, func):
|
|
1082
1108
|
"""Helper for checking Git permissions and worker availability, then calling provided function if all is well
|
|
1083
1109
|
Args:
|
|
@@ -1102,40 +1128,88 @@ def check_and_call_git_repository_function(request, pk, func):
|
|
|
1102
1128
|
return redirect(job_result.get_absolute_url())
|
|
1103
1129
|
|
|
1104
1130
|
|
|
1105
|
-
class
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1131
|
+
class GitRepositoryUIViewSet(NautobotUIViewSet):
|
|
1132
|
+
bulk_update_form_class = forms.GitRepositoryBulkEditForm
|
|
1133
|
+
filterset_form_class = forms.GitRepositoryFilterForm
|
|
1134
|
+
queryset = GitRepository.objects.all()
|
|
1135
|
+
form_class = forms.GitRepositoryForm
|
|
1136
|
+
filterset_class = filters.GitRepositoryFilterSet
|
|
1137
|
+
serializer_class = serializers.GitRepositorySerializer
|
|
1138
|
+
table_class = tables.GitRepositoryTable
|
|
1113
1139
|
|
|
1140
|
+
def get_extra_context(self, request, instance=None):
|
|
1141
|
+
context = super().get_extra_context(request, instance)
|
|
1142
|
+
context["datasource_contents"] = get_datasource_contents("extras.gitrepository")
|
|
1143
|
+
|
|
1144
|
+
if self.action in ("list", "bulk_update", "bulk_destroy"):
|
|
1145
|
+
results = {
|
|
1146
|
+
r.task_kwargs["repository"]: r
|
|
1147
|
+
for r in JobResult.objects.filter(
|
|
1148
|
+
task_name__startswith="nautobot.core.jobs.GitRepository",
|
|
1149
|
+
task_kwargs__repository__isnull=False,
|
|
1150
|
+
status__in=JobResultStatusChoices.READY_STATES,
|
|
1151
|
+
)
|
|
1152
|
+
.order_by("date_done")
|
|
1153
|
+
.defer("result")
|
|
1154
|
+
}
|
|
1155
|
+
context["job_results"] = results
|
|
1114
1156
|
|
|
1115
|
-
|
|
1116
|
-
"""
|
|
1117
|
-
Display a JobResult and its Job data.
|
|
1118
|
-
"""
|
|
1157
|
+
return context
|
|
1119
1158
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1159
|
+
def form_valid(self, form):
|
|
1160
|
+
if hasattr(form, "instance") and form.instance is not None:
|
|
1161
|
+
form.instance.user = self.request.user
|
|
1162
|
+
form.instance.request = self.request
|
|
1163
|
+
return super().form_valid(form)
|
|
1122
1164
|
|
|
1123
|
-
def
|
|
1124
|
-
|
|
1165
|
+
def get_return_url(self, request, obj=None, default_return_url=None):
|
|
1166
|
+
# Only redirect to result if object exists and action is not deletion
|
|
1167
|
+
if request.method == "POST" and obj is not None and self.action != "destroy":
|
|
1168
|
+
return reverse("extras:gitrepository_result", kwargs={"pk": obj.pk})
|
|
1169
|
+
return super().get_return_url(request, obj=obj, default_return_url=default_return_url)
|
|
1125
1170
|
|
|
1126
|
-
|
|
1171
|
+
@action(
|
|
1172
|
+
detail=True,
|
|
1173
|
+
url_path="result",
|
|
1174
|
+
url_name="result",
|
|
1175
|
+
custom_view_base_action="view",
|
|
1176
|
+
)
|
|
1177
|
+
def result(self, request, pk=None):
|
|
1178
|
+
instance = self.get_object()
|
|
1127
1179
|
job_result = instance.get_latest_sync()
|
|
1128
1180
|
|
|
1129
|
-
|
|
1130
|
-
job_result
|
|
1131
|
-
|
|
1132
|
-
return {
|
|
1133
|
-
"result": job_result,
|
|
1181
|
+
context = {
|
|
1182
|
+
"result": job_result or {},
|
|
1134
1183
|
"base_template": "extras/gitrepository.html",
|
|
1135
1184
|
"object": instance,
|
|
1136
1185
|
"active_tab": "result",
|
|
1186
|
+
"verbose_name": instance._meta.verbose_name,
|
|
1137
1187
|
}
|
|
1138
1188
|
|
|
1189
|
+
return render(request, "extras/gitrepository_result.html", context)
|
|
1190
|
+
|
|
1191
|
+
@action(
|
|
1192
|
+
detail=True,
|
|
1193
|
+
methods=["post"],
|
|
1194
|
+
url_path="sync",
|
|
1195
|
+
url_name="sync",
|
|
1196
|
+
custom_view_base_action="change",
|
|
1197
|
+
custom_view_additional_permissions=["extras.change_gitrepository"],
|
|
1198
|
+
)
|
|
1199
|
+
def sync(self, request, pk=None):
|
|
1200
|
+
return check_and_call_git_repository_function(request, pk, enqueue_pull_git_repository_and_refresh_data)
|
|
1201
|
+
|
|
1202
|
+
@action(
|
|
1203
|
+
detail=True,
|
|
1204
|
+
methods=["post"],
|
|
1205
|
+
url_path="dry-run",
|
|
1206
|
+
url_name="dryrun",
|
|
1207
|
+
custom_view_base_action="change",
|
|
1208
|
+
custom_view_additional_permissions=["extras.change_gitrepository"],
|
|
1209
|
+
)
|
|
1210
|
+
def dry_run(self, request, pk=None):
|
|
1211
|
+
return check_and_call_git_repository_function(request, pk, enqueue_git_repository_diff_origin_and_local)
|
|
1212
|
+
|
|
1139
1213
|
|
|
1140
1214
|
#
|
|
1141
1215
|
# Saved GraphQL queries
|
|
@@ -2036,9 +2110,9 @@ class JobHookUIViewSet(NautobotUIViewSet):
|
|
|
2036
2110
|
table_class = tables.JobHookTable
|
|
2037
2111
|
queryset = JobHook.objects.all()
|
|
2038
2112
|
|
|
2039
|
-
object_detail_content = ObjectDetailContent(
|
|
2113
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
2040
2114
|
panels=(
|
|
2041
|
-
ObjectFieldsPanel(
|
|
2115
|
+
object_detail.ObjectFieldsPanel(
|
|
2042
2116
|
weight=100,
|
|
2043
2117
|
section=SectionChoices.LEFT_HALF,
|
|
2044
2118
|
fields="__all__",
|
|
@@ -2134,9 +2208,9 @@ class JobButtonUIViewSet(NautobotUIViewSet):
|
|
|
2134
2208
|
queryset = JobButton.objects.all()
|
|
2135
2209
|
serializer_class = serializers.JobButtonSerializer
|
|
2136
2210
|
table_class = tables.JobButtonTable
|
|
2137
|
-
object_detail_content = ObjectDetailContent(
|
|
2211
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
2138
2212
|
panels=(
|
|
2139
|
-
ObjectFieldsPanel(
|
|
2213
|
+
object_detail.ObjectFieldsPanel(
|
|
2140
2214
|
weight=100,
|
|
2141
2215
|
section=SectionChoices.LEFT_HALF,
|
|
2142
2216
|
fields="__all__",
|
|
@@ -2251,6 +2325,10 @@ class ObjectChangeLogView(generic.GenericView):
|
|
|
2251
2325
|
"table": objectchanges_table,
|
|
2252
2326
|
"base_template": base_template,
|
|
2253
2327
|
"active_tab": "changelog",
|
|
2328
|
+
"breadcrumbs": self.get_breadcrumbs(obj, view_type=""),
|
|
2329
|
+
"view_titles": self.get_view_titles(obj, view_type=""),
|
|
2330
|
+
"detail": True,
|
|
2331
|
+
"view_action": "changelog",
|
|
2254
2332
|
},
|
|
2255
2333
|
)
|
|
2256
2334
|
|
|
@@ -2344,6 +2422,37 @@ class NoteUIViewSet(
|
|
|
2344
2422
|
serializer_class = serializers.NoteSerializer
|
|
2345
2423
|
table_class = tables.NoteTable
|
|
2346
2424
|
action_buttons = ()
|
|
2425
|
+
breadcrumbs = Breadcrumbs(
|
|
2426
|
+
items={
|
|
2427
|
+
"detail": [
|
|
2428
|
+
ModelBreadcrumbItem(model=Note),
|
|
2429
|
+
ModelBreadcrumbItem(
|
|
2430
|
+
model=lambda c: c["object"].assigned_object,
|
|
2431
|
+
action="notes",
|
|
2432
|
+
reverse_kwargs=lambda c: {"pk": c["object"].assigned_object.pk},
|
|
2433
|
+
label=lambda c: c["object"].assigned_object,
|
|
2434
|
+
should_render=lambda c: c["object"].assigned_object,
|
|
2435
|
+
),
|
|
2436
|
+
]
|
|
2437
|
+
}
|
|
2438
|
+
)
|
|
2439
|
+
|
|
2440
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
2441
|
+
panels=(
|
|
2442
|
+
object_detail.ObjectFieldsPanel(
|
|
2443
|
+
weight=100,
|
|
2444
|
+
section=SectionChoices.LEFT_HALF,
|
|
2445
|
+
fields=["user", "assigned_object_type", "assigned_object"],
|
|
2446
|
+
),
|
|
2447
|
+
object_detail.ObjectTextPanel(
|
|
2448
|
+
label="Text",
|
|
2449
|
+
section=SectionChoices.LEFT_HALF,
|
|
2450
|
+
weight=200,
|
|
2451
|
+
object_field="note",
|
|
2452
|
+
render_as=object_detail.ObjectTextPanel.RenderOptions.MARKDOWN,
|
|
2453
|
+
),
|
|
2454
|
+
),
|
|
2455
|
+
)
|
|
2347
2456
|
|
|
2348
2457
|
def alter_obj(self, obj, request, url_args, url_kwargs):
|
|
2349
2458
|
obj.user = request.user
|
|
@@ -2396,6 +2505,10 @@ class ObjectNotesView(generic.GenericView):
|
|
|
2396
2505
|
"base_template": base_template,
|
|
2397
2506
|
"active_tab": "notes",
|
|
2398
2507
|
"form": notes_form,
|
|
2508
|
+
"breadcrumbs": self.get_breadcrumbs(obj, view_type=""),
|
|
2509
|
+
"view_titles": self.get_view_titles(obj, view_type=""),
|
|
2510
|
+
"detail": True,
|
|
2511
|
+
"view_action": "notes",
|
|
2399
2512
|
},
|
|
2400
2513
|
)
|
|
2401
2514
|
|
|
@@ -2414,9 +2527,9 @@ class RelationshipUIViewSet(NautobotUIViewSet):
|
|
|
2414
2527
|
table_class = tables.RelationshipTable
|
|
2415
2528
|
queryset = Relationship.objects.all()
|
|
2416
2529
|
|
|
2417
|
-
object_detail_content = ObjectDetailContent(
|
|
2530
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
2418
2531
|
panels=(
|
|
2419
|
-
ObjectFieldsPanel(
|
|
2532
|
+
object_detail.ObjectFieldsPanel(
|
|
2420
2533
|
label="Relationship",
|
|
2421
2534
|
section=SectionChoices.LEFT_HALF,
|
|
2422
2535
|
weight=100,
|
|
@@ -2432,13 +2545,13 @@ class RelationshipUIViewSet(NautobotUIViewSet):
|
|
|
2432
2545
|
"destination_filter",
|
|
2433
2546
|
],
|
|
2434
2547
|
),
|
|
2435
|
-
ObjectFieldsPanel(
|
|
2548
|
+
object_detail.ObjectFieldsPanel(
|
|
2436
2549
|
label="Source Attributes",
|
|
2437
2550
|
section=SectionChoices.RIGHT_HALF,
|
|
2438
2551
|
weight=100,
|
|
2439
2552
|
fields=["source_type", "source_label", "source_hidden", "source_filter"],
|
|
2440
2553
|
),
|
|
2441
|
-
ObjectFieldsPanel(
|
|
2554
|
+
object_detail.ObjectFieldsPanel(
|
|
2442
2555
|
label="Destination Attributes",
|
|
2443
2556
|
section=SectionChoices.RIGHT_HALF,
|
|
2444
2557
|
weight=200,
|
|
@@ -2644,7 +2757,7 @@ class SecretsGroupUIViewSet(NautobotUIViewSet):
|
|
|
2644
2757
|
table_class = tables.SecretsGroupTable
|
|
2645
2758
|
queryset = SecretsGroup.objects.all()
|
|
2646
2759
|
|
|
2647
|
-
object_detail_content = ObjectDetailContent(
|
|
2760
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
2648
2761
|
panels=(
|
|
2649
2762
|
object_detail.ObjectFieldsPanel(
|
|
2650
2763
|
label="Secrets Group Details",
|
|
@@ -2712,125 +2825,6 @@ class StaticGroupAssociationUIViewSet(
|
|
|
2712
2825
|
return queryset
|
|
2713
2826
|
|
|
2714
2827
|
|
|
2715
|
-
class DynamicGroupBulkAssignView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
2716
|
-
queryset = StaticGroupAssociation.objects.all()
|
|
2717
|
-
form_class = forms.DynamicGroupBulkAssignForm
|
|
2718
|
-
|
|
2719
|
-
def get_required_permission(self):
|
|
2720
|
-
return get_permission_for_model(self.queryset.model, "add")
|
|
2721
|
-
|
|
2722
|
-
def get(self, request):
|
|
2723
|
-
return redirect(self.get_return_url(request))
|
|
2724
|
-
|
|
2725
|
-
def post(self, request, **kwargs):
|
|
2726
|
-
"""
|
|
2727
|
-
Update the static group assignments of the provided `pk_list` (or `_all`) of the given `content_type`.
|
|
2728
|
-
|
|
2729
|
-
Unlike BulkEditView, this takes a single POST rather than two to perform its operation as
|
|
2730
|
-
there's no separate confirmation step involved.
|
|
2731
|
-
"""
|
|
2732
|
-
# TODO more error handling - content-type doesn't exist, model_class not found, filterset missing, etc.
|
|
2733
|
-
content_type = ContentType.objects.get(pk=request.POST.get("content_type"))
|
|
2734
|
-
model = content_type.model_class()
|
|
2735
|
-
self.default_return_url = get_route_for_model(model, "list")
|
|
2736
|
-
filterset_class = get_filterset_for_model(model)
|
|
2737
|
-
|
|
2738
|
-
if request.POST.get("_all"):
|
|
2739
|
-
if filterset_class is not None:
|
|
2740
|
-
pk_list = list(filterset_class(request.GET, model.objects.only("pk")).qs.values_list("pk", flat=True))
|
|
2741
|
-
else:
|
|
2742
|
-
pk_list = list(model.objects.all().values_list("pk", flat=True))
|
|
2743
|
-
else:
|
|
2744
|
-
pk_list = request.POST.getlist("pk")
|
|
2745
|
-
|
|
2746
|
-
form = self.form_class(model, request.POST)
|
|
2747
|
-
restrict_form_fields(form, request.user)
|
|
2748
|
-
|
|
2749
|
-
if form.is_valid():
|
|
2750
|
-
logger.debug("Form validation was successful")
|
|
2751
|
-
try:
|
|
2752
|
-
with transaction.atomic():
|
|
2753
|
-
add_to_groups = list(form.cleaned_data["add_to_groups"])
|
|
2754
|
-
new_group_name = form.cleaned_data["create_and_assign_to_new_group_name"]
|
|
2755
|
-
if new_group_name:
|
|
2756
|
-
if not request.user.has_perm("extras.add_dynamicgroup"):
|
|
2757
|
-
raise DynamicGroup.DoesNotExist
|
|
2758
|
-
else:
|
|
2759
|
-
new_group = DynamicGroup(
|
|
2760
|
-
name=new_group_name,
|
|
2761
|
-
content_type=content_type,
|
|
2762
|
-
group_type=DynamicGroupTypeChoices.TYPE_STATIC,
|
|
2763
|
-
)
|
|
2764
|
-
new_group.validated_save()
|
|
2765
|
-
# Check permissions
|
|
2766
|
-
DynamicGroup.objects.restrict(request.user, "add").get(pk=new_group.pk)
|
|
2767
|
-
|
|
2768
|
-
add_to_groups.append(new_group)
|
|
2769
|
-
msg = "Created dynamic group"
|
|
2770
|
-
logger.info(f"{msg} {new_group} (PK: {new_group.pk})")
|
|
2771
|
-
msg = format_html('{} <a href="{}">{}</a>', msg, new_group.get_absolute_url(), new_group)
|
|
2772
|
-
messages.success(self.request, msg)
|
|
2773
|
-
|
|
2774
|
-
with deferred_change_logging_for_bulk_operation():
|
|
2775
|
-
associations = []
|
|
2776
|
-
for pk in pk_list:
|
|
2777
|
-
for dynamic_group in add_to_groups:
|
|
2778
|
-
association, created = StaticGroupAssociation.objects.get_or_create(
|
|
2779
|
-
dynamic_group=dynamic_group,
|
|
2780
|
-
associated_object_type_id=content_type.id,
|
|
2781
|
-
associated_object_id=pk,
|
|
2782
|
-
)
|
|
2783
|
-
association.validated_save()
|
|
2784
|
-
associations.append(association)
|
|
2785
|
-
if created:
|
|
2786
|
-
logger.debug("Created %s", association)
|
|
2787
|
-
|
|
2788
|
-
# Enforce object-level permissions
|
|
2789
|
-
if self.queryset.filter(pk__in=[assoc.pk for assoc in associations]).count() != len(
|
|
2790
|
-
associations
|
|
2791
|
-
):
|
|
2792
|
-
raise StaticGroupAssociation.DoesNotExist
|
|
2793
|
-
|
|
2794
|
-
if associations:
|
|
2795
|
-
msg = (
|
|
2796
|
-
f"Added {len(pk_list)} {model._meta.verbose_name_plural} "
|
|
2797
|
-
f"to {len(add_to_groups)} dynamic group(s)."
|
|
2798
|
-
)
|
|
2799
|
-
logger.info(msg)
|
|
2800
|
-
messages.success(self.request, msg)
|
|
2801
|
-
|
|
2802
|
-
if form.cleaned_data["remove_from_groups"]:
|
|
2803
|
-
for dynamic_group in form.cleaned_data["remove_from_groups"]:
|
|
2804
|
-
(
|
|
2805
|
-
StaticGroupAssociation.objects.restrict(request.user, "delete")
|
|
2806
|
-
.filter(
|
|
2807
|
-
dynamic_group=dynamic_group,
|
|
2808
|
-
associated_object_type=content_type,
|
|
2809
|
-
associated_object_id__in=pk_list,
|
|
2810
|
-
)
|
|
2811
|
-
.delete()
|
|
2812
|
-
)
|
|
2813
|
-
|
|
2814
|
-
msg = (
|
|
2815
|
-
f"Removed {len(pk_list)} {model._meta.verbose_name_plural} from "
|
|
2816
|
-
f"{len(form.cleaned_data['remove_from_groups'])} dynamic group(s)."
|
|
2817
|
-
)
|
|
2818
|
-
logger.info(msg)
|
|
2819
|
-
messages.success(self.request, msg)
|
|
2820
|
-
except ValidationError as e:
|
|
2821
|
-
messages.error(self.request, e)
|
|
2822
|
-
except ObjectDoesNotExist:
|
|
2823
|
-
msg = "Static group association failed due to object-level permissions violation"
|
|
2824
|
-
logger.warning(msg)
|
|
2825
|
-
messages.error(self.request, msg)
|
|
2826
|
-
|
|
2827
|
-
else:
|
|
2828
|
-
logger.debug("Form validation failed")
|
|
2829
|
-
messages.error(self.request, form.errors)
|
|
2830
|
-
|
|
2831
|
-
return redirect(self.get_return_url(request))
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
2828
|
#
|
|
2835
2829
|
# Custom statuses
|
|
2836
2830
|
#
|
|
@@ -2955,33 +2949,33 @@ class WebhookUIViewSet(NautobotUIViewSet):
|
|
|
2955
2949
|
serializer_class = serializers.WebhookSerializer
|
|
2956
2950
|
table_class = tables.WebhookTable
|
|
2957
2951
|
|
|
2958
|
-
object_detail_content = ObjectDetailContent(
|
|
2952
|
+
object_detail_content = object_detail.ObjectDetailContent(
|
|
2959
2953
|
panels=[
|
|
2960
|
-
ObjectFieldsPanel(
|
|
2954
|
+
object_detail.ObjectFieldsPanel(
|
|
2961
2955
|
label="Webhook",
|
|
2962
2956
|
section=SectionChoices.LEFT_HALF,
|
|
2963
2957
|
weight=100,
|
|
2964
2958
|
fields=("name", "content_types", "type_create", "type_update", "type_delete", "enabled"),
|
|
2965
2959
|
),
|
|
2966
|
-
ObjectFieldsPanel(
|
|
2960
|
+
object_detail.ObjectFieldsPanel(
|
|
2967
2961
|
label="HTTP",
|
|
2968
2962
|
section=SectionChoices.LEFT_HALF,
|
|
2969
2963
|
weight=100,
|
|
2970
2964
|
fields=("http_method", "http_content_type", "payload_url", "additional_headers"),
|
|
2971
2965
|
value_transforms={"additional_headers": [helpers.pre_tag]},
|
|
2972
2966
|
),
|
|
2973
|
-
ObjectFieldsPanel(
|
|
2967
|
+
object_detail.ObjectFieldsPanel(
|
|
2974
2968
|
label="Security",
|
|
2975
2969
|
section=SectionChoices.LEFT_HALF,
|
|
2976
2970
|
weight=100,
|
|
2977
2971
|
fields=("secret", "ssl_verification", "ca_file_path"),
|
|
2978
2972
|
),
|
|
2979
|
-
ObjectTextPanel(
|
|
2973
|
+
object_detail.ObjectTextPanel(
|
|
2980
2974
|
label="Body Template",
|
|
2981
2975
|
section=SectionChoices.RIGHT_HALF,
|
|
2982
2976
|
weight=100,
|
|
2983
2977
|
object_field="body_template",
|
|
2984
|
-
render_as=BaseTextPanel.RenderOptions.CODE,
|
|
2978
|
+
render_as=object_detail.BaseTextPanel.RenderOptions.CODE,
|
|
2985
2979
|
),
|
|
2986
2980
|
]
|
|
2987
2981
|
)
|