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.

Files changed (92) hide show
  1. nautobot/apps/views.py +2 -0
  2. nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
  3. nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
  4. nautobot/circuits/tests/integration/test_circuit.py +2 -2
  5. nautobot/circuits/views.py +32 -15
  6. nautobot/core/filters.py +2 -2
  7. nautobot/core/settings.py +1 -0
  8. nautobot/core/settings.yaml +9 -0
  9. nautobot/core/tables.py +21 -23
  10. nautobot/core/templates/components/breadcrumbs.html +19 -0
  11. nautobot/core/templates/generic/object_changelog.html +0 -2
  12. nautobot/core/templates/generic/object_list.html +15 -12
  13. nautobot/core/templates/generic/object_notes.html +0 -2
  14. nautobot/core/templates/generic/object_retrieve.html +16 -9
  15. nautobot/core/templatetags/helpers.py +24 -0
  16. nautobot/core/templatetags/ui_framework.py +40 -5
  17. nautobot/core/testing/filters.py +37 -21
  18. nautobot/core/testing/views.py +25 -0
  19. nautobot/core/tests/test_tables.py +43 -6
  20. nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
  21. nautobot/core/tests/test_titles.py +2 -2
  22. nautobot/core/tests/test_ui.py +14 -1
  23. nautobot/core/tests/test_views.py +45 -0
  24. nautobot/core/ui/breadcrumbs.py +13 -8
  25. nautobot/core/ui/object_detail.py +43 -5
  26. nautobot/core/ui/titles.py +9 -5
  27. nautobot/core/views/__init__.py +24 -3
  28. nautobot/core/views/generic.py +42 -17
  29. nautobot/core/views/mixins.py +146 -12
  30. nautobot/core/views/utils.py +117 -0
  31. nautobot/dcim/models/devices.py +4 -0
  32. nautobot/dcim/tables/__init__.py +2 -0
  33. nautobot/dcim/tables/devices.py +24 -0
  34. nautobot/dcim/tables/power.py +2 -2
  35. nautobot/dcim/templates/dcim/device/base.html +1 -11
  36. nautobot/dcim/templates/dcim/device_component.html +0 -19
  37. nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
  38. nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
  39. nautobot/dcim/tests/test_views.py +41 -0
  40. nautobot/dcim/views.py +160 -39
  41. nautobot/extras/filters/mixins.py +1 -1
  42. nautobot/extras/forms/forms.py +15 -0
  43. nautobot/extras/models/groups.py +10 -1
  44. nautobot/extras/models/jobs.py +2 -2
  45. nautobot/extras/plugins/views.py +18 -5
  46. nautobot/extras/tables.py +4 -2
  47. nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
  48. nautobot/extras/templates/extras/dynamicgroup.html +2 -99
  49. nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
  50. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
  51. nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
  52. nautobot/extras/templates/extras/gitrepository.html +2 -82
  53. nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
  54. nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
  55. nautobot/extras/templates/extras/gitrepository_update.html +13 -0
  56. nautobot/extras/templates/extras/note_retrieve.html +0 -52
  57. nautobot/extras/templates/extras/plugin_detail.html +3 -7
  58. nautobot/extras/templates/extras/plugins_list.html +0 -2
  59. nautobot/extras/tests/test_dynamicgroups.py +73 -18
  60. nautobot/extras/tests/test_views.py +5 -0
  61. nautobot/extras/urls.py +2 -94
  62. nautobot/extras/views.py +424 -430
  63. nautobot/ipam/querysets.py +3 -3
  64. nautobot/ipam/signals.py +6 -1
  65. nautobot/ipam/templates/ipam/prefix.html +0 -8
  66. nautobot/ipam/tests/test_api.py +5 -0
  67. nautobot/ipam/tests/test_models.py +387 -0
  68. nautobot/ipam/tests/test_querysets.py +46 -0
  69. nautobot/ipam/utils/migrations.py +1 -1
  70. nautobot/ipam/views.py +17 -8
  71. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
  72. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
  73. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
  74. nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
  75. nautobot/project-static/docs/development/core/getting-started.html +0 -15
  76. nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
  77. nautobot/project-static/docs/objects.inv +0 -0
  78. nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
  79. nautobot/project-static/docs/search/search_index.json +1 -1
  80. nautobot/project-static/docs/sitemap.xml +300 -300
  81. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  82. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
  83. nautobot/project-static/img/nautobot_icon.svg +32 -34
  84. nautobot/project-static/js/table_sorting_indicator.js +0 -2
  85. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
  86. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
  87. nautobot/core/templates/inc/breadcrumbs.html +0 -14
  88. nautobot/project-static/docs/requirements.txt +0 -14
  89. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
  92. {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 ProtectedError, Q
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.object_detail import ObjectDetailContent, ObjectFieldsPanel, ObjectTextPanel
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 DynamicGroupListView(generic.ObjectListView):
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
- table = tables.DynamicGroupTable
655
- filterset = filters.DynamicGroupFilterSet
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
- model = instance.model
666
- table_class = get_table_for_model(model)
667
-
668
- if instance.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
669
- # Ensure that members cache is up-to-date for this specific group
670
- members = instance.update_cached_members()
671
- messages.success(request, f"Refreshed cached members list for {instance}")
672
- else:
673
- members = instance.members
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
- # Ancestors table
700
- ancestors = instance.get_ancestors()
701
- ancestors_table = tables.NestedDynamicGroupAncestorsTable(ancestors, orderable=False)
702
- ancestors_tree = instance.flatten_ancestors_tree(instance.ancestors_tree())
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
- context["raw_query"] = pretty_print_query(instance.generate_query())
706
- context["members_list_url"] = None
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
- context["raw_query"] = None
709
- try:
710
- context["members_list_url"] = reverse(get_route_for_model(instance.model, "list"))
711
- except NoReverseMatch:
712
- context["members_list_url"] = None
713
- context["members_verbose_name_plural"] = instance.model._meta.verbose_name_plural
714
- context["members_table"] = members_table
715
- context["ancestors_table"] = ancestors_table
716
- context["ancestors_tree"] = ancestors_tree
717
- context["descendants_table"] = descendants_table
718
- context["descendants_tree"] = descendants_tree
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
- return context
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
- class DynamicGroupEditView(generic.ObjectEditView):
724
- queryset = DynamicGroup.objects.all()
725
- model_form = forms.DynamicGroupForm
726
- template_name = "extras/dynamicgroup_edit.html"
801
+ return context
727
802
 
728
- def get_extra_context(self, request, instance):
729
- ctx = super().get_extra_context(request, instance)
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
- filterform_class = instance.generate_filter_form()
843
+ return obj
732
844
 
733
- if filterform_class is None:
734
- filter_form = None
735
- elif request.POST:
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
- ctx["filter_form"] = filter_form
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
- formset_kwargs = {"instance": instance}
744
- if request.POST:
745
- formset_kwargs["data"] = request.POST
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
- ctx["children"] = forms.DynamicGroupMembershipFormSet(**formset_kwargs)
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
- return ctx
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
- def post(self, request, *args, **kwargs):
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
- object_created = not form.instance.present_in_database
762
- # Obtain the instance, but do not yet `save()` it to the database.
763
- obj = form.save(commit=False)
764
-
765
- ctx = self.get_extra_context(request, obj)
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
- raise RuntimeError(filter_form.errors)
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
- # After filters have been set, now we save the object to the database.
775
- obj.save()
776
- # Save m2m fields, such as Tags https://docs.djangoproject.com/en/3.2/topics/forms/modelforms/#the-save-method
777
- form.save_m2m()
778
- # Check that the new object conforms with any assigned object-level permissions
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
- # Process the formsets for children
782
- children = ctx["children"]
783
- if children.is_valid():
784
- children.save()
785
- else:
786
- raise RuntimeError(children.errors)
787
- verb = "Created" if object_created else "Modified"
788
- msg = f"{verb} {self.queryset.model._meta.verbose_name}"
789
- logger.info(f"{msg} {obj} (PK: {obj.pk})")
790
- try:
791
- msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
792
- except AttributeError:
793
- msg = format_html("{} {}", msg, obj)
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
- if "_addanother" in request.POST:
797
- # If the object has clone_fields, pre-populate a new instance of the form
798
- if hasattr(obj, "clone_fields"):
799
- url = f"{request.path}?{prepare_cloned_fields(obj)}"
800
- return redirect(url)
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
- return redirect(request.get_full_path())
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
- return_url = form.cleaned_data.get("return_url")
805
- if url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
806
- return redirect(iri_to_uri(return_url))
807
- else:
808
- return redirect(self.get_return_url(request, obj))
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 = "Object save failed due to object-level permissions violation."
812
- logger.debug(msg)
813
- form.add_error(None, msg)
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 render(
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 GitRepositorySyncView(generic.GenericView):
1106
- def post(self, request, pk):
1107
- return check_and_call_git_repository_function(request, pk, enqueue_pull_git_repository_and_refresh_data)
1108
-
1109
-
1110
- class GitRepositoryDryRunView(generic.GenericView):
1111
- def post(self, request, pk):
1112
- return check_and_call_git_repository_function(request, pk, enqueue_git_repository_diff_origin_and_local)
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
- class GitRepositoryResultView(generic.ObjectView):
1116
- """
1117
- Display a JobResult and its Job data.
1118
- """
1157
+ return context
1119
1158
 
1120
- queryset = GitRepository.objects.all()
1121
- template_name = "extras/gitrepository_result.html"
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 get_required_permission(self):
1124
- return "extras.view_gitrepository"
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
- def get_extra_context(self, request, instance):
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
- if job_result is None:
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
  )