nautobot 3.0.0rc1__py3-none-any.whl → 3.0.1__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 (103) hide show
  1. nautobot/apps/forms.py +8 -0
  2. nautobot/apps/templatetags.py +231 -0
  3. nautobot/apps/testing.py +11 -1
  4. nautobot/apps/ui.py +21 -1
  5. nautobot/apps/utils.py +26 -1
  6. nautobot/core/celery/__init__.py +46 -1
  7. nautobot/core/cli/bootstrap_v3_to_v5.py +185 -44
  8. nautobot/core/cli/bootstrap_v3_to_v5_changes.yaml +314 -0
  9. nautobot/core/graphql/generators.py +2 -2
  10. nautobot/core/jobs/bulk_actions.py +12 -6
  11. nautobot/core/jobs/cleanup.py +13 -1
  12. nautobot/core/settings.py +13 -0
  13. nautobot/core/settings.yaml +22 -0
  14. nautobot/core/settings_funcs.py +11 -1
  15. nautobot/core/tables.py +19 -1
  16. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  17. nautobot/core/templates/components/panel/header_extra_content_table.html +9 -3
  18. nautobot/core/templates/generic/object_create.html +1 -1
  19. nautobot/core/templates/inc/header.html +9 -10
  20. nautobot/core/templates/login.html +16 -1
  21. nautobot/core/templates/nautobot_config.py.j2 +14 -1
  22. nautobot/core/templates/redoc_ui.html +3 -0
  23. nautobot/core/templatetags/helpers.py +3 -3
  24. nautobot/core/testing/views.py +3 -1
  25. nautobot/core/tests/test_graphql.py +13 -0
  26. nautobot/core/tests/test_jobs.py +118 -0
  27. nautobot/core/tests/test_views.py +24 -0
  28. nautobot/core/ui/bulk_buttons.py +2 -3
  29. nautobot/core/utils/lookup.py +2 -3
  30. nautobot/core/utils/permissions.py +1 -1
  31. nautobot/core/views/generic.py +1 -0
  32. nautobot/core/views/mixins.py +37 -10
  33. nautobot/core/views/renderers.py +1 -0
  34. nautobot/core/views/utils.py +3 -3
  35. nautobot/data_validation/views.py +1 -9
  36. nautobot/dcim/forms.py +9 -9
  37. nautobot/dcim/models/devices.py +3 -3
  38. nautobot/dcim/tables/power.py +3 -0
  39. nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +1 -1
  40. nautobot/dcim/views.py +30 -44
  41. nautobot/extras/api/views.py +14 -3
  42. nautobot/extras/choices.py +3 -0
  43. nautobot/extras/jobs.py +48 -2
  44. nautobot/extras/migrations/0132_approval_workflow_seed_data.py +127 -0
  45. nautobot/extras/models/approvals.py +11 -1
  46. nautobot/extras/models/models.py +19 -0
  47. nautobot/extras/models/relationships.py +3 -1
  48. nautobot/extras/tables.py +35 -18
  49. nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
  50. nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
  51. nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +1 -1
  52. nautobot/extras/templates/extras/customfield_update.html +1 -1
  53. nautobot/extras/templates/extras/dynamicgroup_update.html +2 -2
  54. nautobot/extras/templates/extras/inc/approval_buttons_column.html +10 -2
  55. nautobot/extras/templates/extras/inc/job_tiles.html +2 -2
  56. nautobot/extras/templates/extras/inc/jobresult.html +1 -1
  57. nautobot/extras/templates/extras/metadatatype_create.html +1 -1
  58. nautobot/extras/templates/extras/object_approvalworkflow.html +2 -3
  59. nautobot/extras/templates/extras/secretsgroup_update.html +1 -1
  60. nautobot/extras/tests/test_api.py +57 -3
  61. nautobot/extras/tests/test_customfields_filters.py +84 -4
  62. nautobot/extras/tests/test_views.py +323 -6
  63. nautobot/extras/views.py +114 -39
  64. nautobot/ipam/constants.py +2 -2
  65. nautobot/ipam/tables.py +7 -6
  66. nautobot/load_balancers/constants.py +6 -0
  67. nautobot/load_balancers/migrations/0001_initial.py +14 -3
  68. nautobot/load_balancers/models.py +5 -4
  69. nautobot/load_balancers/tables.py +5 -0
  70. nautobot/project-static/dist/css/nautobot.css +1 -1
  71. nautobot/project-static/dist/css/nautobot.css.map +1 -1
  72. nautobot/project-static/dist/js/graphql-libraries.js +1 -1
  73. nautobot/project-static/dist/js/graphql-libraries.js.map +1 -1
  74. nautobot/project-static/dist/js/libraries.js +1 -1
  75. nautobot/project-static/dist/js/libraries.js.LICENSE.txt +38 -2
  76. nautobot/project-static/dist/js/libraries.js.map +1 -1
  77. nautobot/project-static/dist/js/nautobot-graphiql.js +1 -1
  78. nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
  79. nautobot/project-static/dist/js/nautobot.js +1 -1
  80. nautobot/project-static/dist/js/nautobot.js.map +1 -1
  81. nautobot/project-static/img/dark-theme.png +0 -0
  82. nautobot/project-static/img/light-theme.png +0 -0
  83. nautobot/project-static/img/system-theme.png +0 -0
  84. nautobot/project-static/js/forms.js +1 -85
  85. nautobot/tenancy/tables.py +3 -2
  86. nautobot/tenancy/views.py +3 -2
  87. nautobot/ui/package-lock.json +553 -569
  88. nautobot/ui/package.json +10 -10
  89. nautobot/ui/src/js/checkbox.js +132 -0
  90. nautobot/ui/src/js/nautobot.js +6 -0
  91. nautobot/ui/src/js/select2.js +69 -73
  92. nautobot/ui/src/js/theme.js +129 -39
  93. nautobot/ui/src/scss/nautobot.scss +11 -1
  94. nautobot/vpn/templates/vpn/vpnprofile_create.html +2 -2
  95. nautobot/wireless/filters.py +15 -1
  96. nautobot/wireless/tables.py +18 -14
  97. nautobot/wireless/templates/wireless/wirelessnetwork_create.html +1 -1
  98. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/METADATA +2 -2
  99. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/RECORD +103 -98
  100. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/LICENSE.txt +0 -0
  101. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/NOTICE +0 -0
  102. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/WHEEL +0 -0
  103. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/entry_points.txt +0 -0
@@ -598,10 +598,10 @@ class ApprovalWorkflowStageTest(
598
598
  response_obj = stage.approval_workflow_stage_responses.first()
599
599
  self.assertEqual(response_obj.comments, "This is a test comment.")
600
600
  self.assertEqual(response_obj.user, self.user)
601
- self.assertEqual(response_obj.state, stage.state)
601
+ self.assertEqual(response_obj.state, ApprovalWorkflowStateChoices.COMMENT)
602
602
 
603
603
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
604
- def test_approval_workflow_stage_add_more_comment_to_stage(self):
604
+ def test_approval_workflow_stage_edit_comment(self):
605
605
  for case in self.approval_workflow_content_type_cases:
606
606
  content_type = case["content_type"]
607
607
  with self.subTest(case=content_type):
@@ -618,7 +618,61 @@ class ApprovalWorkflowStageTest(
618
618
  self.assertHttpStatus(response, status.HTTP_200_OK)
619
619
 
620
620
  stage.refresh_from_db()
621
- self.assertEqual(stage.approval_workflow_stage_responses.count(), 2)
621
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 1)
622
+ self.assertEqual(stage.approval_workflow_stage_responses.first().comments, "This is a test comment.")
623
+
624
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
625
+ def test_approval_workflow_stage_not_allow_add_comment_to_approved_denied_stage(self):
626
+ for case in self.approval_workflow_content_type_cases:
627
+ content_type = case["content_type"]
628
+ with self.subTest(case=content_type):
629
+ stage = case["stage"]
630
+ # set state to approved so we can ensure comments *do not* work.
631
+ stage.state = ApprovalWorkflowStateChoices.APPROVED
632
+ stage.save()
633
+ url = reverse("extras-api:approvalworkflowstage-comment", kwargs={"pk": stage.pk})
634
+ self.add_permissions("extras.change_approvalworkflowstage")
635
+
636
+ data = {"comments": "This is a test comment."}
637
+ response = self.client.post(url, data=data, format="json", **self.header)
638
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
639
+ self.assertEqual(
640
+ response.data["detail"],
641
+ f"This stage is in {stage.state} state. Can't comment approved or denied stage.",
642
+ )
643
+
644
+ stage.refresh_from_db()
645
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 0)
646
+
647
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
648
+ def test_state_unchanged_when_comment_added_to_approved_stage(self):
649
+ for case in self.approval_workflow_content_type_cases:
650
+ content_type = case["content_type"]
651
+ with self.subTest(case=content_type):
652
+ stage = case["stage"]
653
+ # set min 2 approvers
654
+ stage.approval_workflow_stage_definition.min_approvers = 2
655
+ stage.approval_workflow_stage_definition.save()
656
+ url = reverse("extras-api:approvalworkflowstage-comment", kwargs={"pk": stage.pk})
657
+ self.add_permissions("extras.change_approvalworkflowstage")
658
+
659
+ ApprovalWorkflowStageResponse.objects.create(
660
+ approval_workflow_stage=stage,
661
+ user=self.user,
662
+ state=ApprovalWorkflowStateChoices.APPROVED,
663
+ comments="Approved comment",
664
+ )
665
+
666
+ data = {"comments": "Edit approved comment."}
667
+ response = self.client.post(url, data=data, format="json", **self.header)
668
+ self.assertHttpStatus(response, status.HTTP_200_OK)
669
+
670
+ stage.refresh_from_db()
671
+ self.assertEqual(stage.approval_workflow_stage_responses.count(), 1)
672
+ self.assertEqual(stage.approval_workflow_stage_responses.first().comments, "Edit approved comment.")
673
+ self.assertEqual(
674
+ stage.approval_workflow_stage_responses.first().state, ApprovalWorkflowStateChoices.APPROVED
675
+ )
622
676
 
623
677
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
624
678
  def test_approval_workflow_stage_pending_my_approvals(self):
@@ -3,8 +3,8 @@ from django.db.models import Model
3
3
  from django.test import tag
4
4
 
5
5
  from nautobot.core.testing import views
6
- from nautobot.extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
7
- from nautobot.extras.models import CustomField
6
+ from nautobot.extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, DynamicGroupOperatorChoices
7
+ from nautobot.extras.models import CustomField, DynamicGroup, DynamicGroupMembership
8
8
 
9
9
 
10
10
  @tag("unit")
@@ -368,6 +368,82 @@ class CustomFieldsFilters:
368
368
  instances, filtered, test_case["expected"], assert_in_msg, assert_not_in_msg
369
369
  )
370
370
 
371
+ def test_str_custom_field_with_dynamic_groups(self):
372
+ cf_label = "test_dgs_label_str"
373
+ cf_label_ic = "test_dgs_label_str_ic"
374
+ test_data = self.filter_matrix[CustomFieldTypeChoices.TYPE_TEXT]
375
+ model = self.filterset.Meta.model
376
+ self.create_custom_field(model, cf_label)
377
+ self.create_custom_field(model, cf_label_ic, filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
378
+
379
+ instances = self.queryset.all()[:5]
380
+ self.prepare_custom_fields_values(cf_label, instances, test_data["value"], "not-matched")
381
+ self.prepare_custom_fields_values(cf_label_ic, instances, test_data["value"], "not-matched")
382
+
383
+ ct = ContentType.objects.get_for_model(model)
384
+
385
+ filter_group = DynamicGroup.objects.create(
386
+ name="CustomField DynamicGroup",
387
+ content_type=ct,
388
+ filter={},
389
+ )
390
+ parent_group = DynamicGroup.objects.create(
391
+ name="Parent CustomField DynamicGroup",
392
+ content_type=ct,
393
+ filter={},
394
+ )
395
+ group_membership = DynamicGroupMembership.objects.create(
396
+ parent_group=parent_group,
397
+ group=filter_group,
398
+ operator=DynamicGroupOperatorChoices.OPERATOR_INTERSECTION,
399
+ weight=10,
400
+ )
401
+
402
+ # Prepare test cases
403
+ # For dynamic group we're supporting only exact or icontains for now
404
+ # Depending on the custom field filter logic
405
+ for lookup_data in test_data["lookups"]:
406
+ if lookup_data["lookup"] not in ["", "ic"]:
407
+ continue
408
+
409
+ test_cases = [(lookup_data["lookup"], test_case) for test_case in lookup_data["test_cases"]]
410
+ group_membership.operator = DynamicGroupOperatorChoices.OPERATOR_INTERSECTION
411
+ group_membership.save()
412
+
413
+ for lookup, test_case in test_cases:
414
+ assert_in_msg = f'object expected to be found for searching `{lookup}` ({lookup_data["name"]}) = "{test_case["search"]}"'
415
+ assert_not_in_msg = f'object expected to be filtered out for searching `{lookup}` ({lookup_data["name"]}) = "{test_case["search"]}"'
416
+ group_filter = {f"cf_{cf_label}": test_case["search"]}
417
+ if lookup == "ic":
418
+ group_filter = {f"cf_{cf_label_ic}": test_case["search"]}
419
+
420
+ with self.subTest(f"Test filtering {group_filter}"):
421
+ filter_group.set_filter(group_filter)
422
+ filter_group.save()
423
+ members = parent_group.update_cached_members()
424
+ self.assertProperInstancesReturned(
425
+ instances, members, test_case["expected"], assert_in_msg, assert_not_in_msg
426
+ )
427
+
428
+ negated_test_cases = self.get_negated_test_cases(lookup_data["lookup"], lookup_data["test_cases"])
429
+ group_membership.operator = DynamicGroupOperatorChoices.OPERATOR_DIFFERENCE
430
+ group_membership.save()
431
+
432
+ for lookup, test_case in negated_test_cases:
433
+ assert_in_msg = f'object expected to be found for searching `{lookup}` ({lookup_data["name"]}) = "{test_case["search"]}"'
434
+ assert_not_in_msg = f'object expected to be filtered out for searching `{lookup}` ({lookup_data["name"]}) = "{test_case["search"]}"'
435
+ group_filter = {f"cf_{cf_label}": test_case["search"]}
436
+ if lookup == "ic":
437
+ group_filter = {f"cf_{cf_label_ic}": test_case["search"]}
438
+
439
+ with self.subTest(f"Test negated filtering {group_filter}"):
440
+ filter_group.set_filter(group_filter)
441
+ filter_group.save()
442
+ members = parent_group.update_cached_members()
443
+ self.assertProperInstancesReturned(
444
+ instances, members, test_case["expected"], assert_in_msg, assert_not_in_msg
445
+ )
446
+
371
447
  def test_str_custom_field_filters(self):
372
448
  cf_label = "test_fs_label_str"
373
449
  test_data = self.filter_matrix[CustomFieldTypeChoices.TYPE_TEXT]
@@ -411,11 +487,15 @@ class CustomFieldsFilters:
411
487
  )
412
488
 
413
489
  @staticmethod
414
- def create_custom_field(model: Model, label: str) -> CustomField:
490
+ def create_custom_field(
491
+ model: Model,
492
+ label: str,
493
+ filter_logic: CustomFieldFilterLogicChoices = CustomFieldFilterLogicChoices.FILTER_EXACT,
494
+ ) -> CustomField:
415
495
  cf = CustomField.objects.create(
416
496
  type=CustomFieldTypeChoices.TYPE_TEXT,
417
497
  label=label,
418
- filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
498
+ filter_logic=filter_logic,
419
499
  )
420
500
  cf.content_types.set([ContentType.objects.get_for_model(model)])
421
501
  return cf
@@ -133,12 +133,13 @@ class ApprovalWorkflowDefinitionViewTestCase(
133
133
  name=f"Test Approval Workflow {i}",
134
134
  model_content_type=cls.scheduledjob_ct,
135
135
  weight=i,
136
+ model_constraints={"job_model__name": "NoSuchJob"},
136
137
  )
137
138
 
138
139
  cls.form_data = {
139
140
  "name": "Test Approval Workflow Definition 5",
140
141
  "model_content_type": cls.scheduledjob_ct.pk,
141
- "model_constraints": '{"name": "Bulk Delete Objects"}',
142
+ "model_constraints": '{"job_model__name": "Bulk Delete Objects"}',
142
143
  "weight": 5,
143
144
  # These are the "management_form" fields required by the dynamic CustomFieldChoice formsets.
144
145
  "approval_workflow_stage_definitions-TOTAL_FORMS": "0", # Set to 0 so validation succeeds until we need it
@@ -162,6 +163,7 @@ class ApprovalWorkflowStageDefinitionViewTestCase(ViewTestCases.PrimaryObjectVie
162
163
  name="Test Approval Workflow Definition 1",
163
164
  model_content_type=cls.scheduledjob_ct,
164
165
  weight=10,
166
+ model_constraints={"job_model__name": "NoSuchJob"},
165
167
  )
166
168
  cls.approver_group = Group.objects.create(name="Test Group 1")
167
169
  cls.updated_approver_group = Group.objects.create(name="Test Group 2")
@@ -264,7 +266,10 @@ class ApprovalWorkflowViewTestCase(
264
266
  ]
265
267
  approval_workflow_definitions = [
266
268
  ApprovalWorkflowDefinition.objects.create(
267
- name=f"Test Approval Workflow {i}", model_content_type=cls.scheduledjob_ct, weight=i
269
+ name=f"Test Approval Workflow {i}",
270
+ model_content_type=cls.scheduledjob_ct,
271
+ weight=i,
272
+ model_constraints={"job_model__name": "NoSuchJob"},
268
273
  )
269
274
  for i in range(5)
270
275
  ]
@@ -333,6 +338,7 @@ class ApprovalWorkflowStageViewTestCase(
333
338
  name=f"Test Approval Workflow {i}",
334
339
  model_content_type=cls.scheduledjob_ct,
335
340
  weight=i,
341
+ model_constraints={"job_model__name": "NoSuchJob"},
336
342
  )
337
343
  for i in range(5)
338
344
  ]
@@ -413,19 +419,33 @@ class ApprovalWorkflowStageViewTestCase(
413
419
  self.client.force_login(self.user)
414
420
  self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
415
421
 
416
- # Try GET with model-level permission
422
+ # Try GET with model-level permission but not belonging to the approver group
417
423
  url = reverse("extras:approvalworkflowstage_approve", args=[approval_workflow_stage.pk])
424
+ response = self.client.get(url, follow=True)
425
+ self.assertHttpStatus(response, 200)
426
+ self.assertBodyContains(response, "You are not permitted to approve this")
427
+
428
+ # Try GET with belonging to the approver group
429
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
418
430
  response = self.client.get(url)
431
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
419
432
  self.assertHttpStatus(response, 200)
420
433
  self.assertBodyContains(response, '<div class="card border-success">') # Assert the success panel is present
421
434
 
422
- # Try POST with model-level permission
435
+ # Try POST with model-level permission but not belonging to the approver group
423
436
  request = {
424
437
  "path": url,
425
438
  "data": post_data({"comments": "Approved!"}),
426
439
  }
427
440
  response = self.client.post(**request, follow=True)
428
441
  self.assertHttpStatus(response, 200)
442
+ self.assertBodyContains(response, "You are not permitted to approve this")
443
+
444
+ # Try POST with belonging to the approver group
445
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
446
+ response = self.client.post(**request, follow=True)
447
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
448
+ self.assertHttpStatus(response, 200)
429
449
  approval_workflow_stage.refresh_from_db()
430
450
  # New response should be created
431
451
  new_response = ApprovalWorkflowStageResponse.objects.get(
@@ -443,25 +463,88 @@ class ApprovalWorkflowStageViewTestCase(
443
463
  self.assertHttpStatus(response, 200)
444
464
  self.assertBodyContains(response, "Approval Date") # Assert the approval date is present
445
465
 
466
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
467
+ def test_approve_stage_with_existing_comment_endpoint(self):
468
+ """Test the approve stage with existing comment endpoint."""
469
+ approval_workflow_stage = ApprovalWorkflowStage.objects.first()
470
+ new_response = ApprovalWorkflowStageResponse.objects.create(
471
+ approval_workflow_stage=approval_workflow_stage,
472
+ user=self.user,
473
+ comments="existing comment",
474
+ state=ApprovalWorkflowStateChoices.COMMENT,
475
+ )
476
+ self.assertEqual(
477
+ ApprovalWorkflowStageResponse.objects.filter(
478
+ approval_workflow_stage=approval_workflow_stage, user=self.user
479
+ ).count(),
480
+ 1,
481
+ )
482
+ self.client.force_login(self.user)
483
+ self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
484
+
485
+ # Try GET with model-level permission
486
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
487
+ url = reverse("extras:approvalworkflowstage_approve", args=[approval_workflow_stage.pk])
488
+ response = self.client.get(url)
489
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
490
+ self.assertHttpStatus(response, 200)
491
+ self.assertBodyContains(response, '<div class="card border-success">') # Assert the success panel is present
492
+ self.assertBodyContains(response, "existing comment")
493
+
494
+ # Try POST with model-level permission
495
+ request = {
496
+ "path": url,
497
+ "data": post_data({"comments": "Approved!"}),
498
+ }
499
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
500
+ response = self.client.post(**request, follow=True)
501
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
502
+ self.assertHttpStatus(response, 200)
503
+ approval_workflow_stage.refresh_from_db()
504
+ # Response should be updated
505
+ new_response.refresh_from_db()
506
+ self.assertEqual(
507
+ ApprovalWorkflowStageResponse.objects.filter(
508
+ approval_workflow_stage=approval_workflow_stage, user=self.user
509
+ ).count(),
510
+ 1,
511
+ )
512
+ self.assertEqual(new_response.state, ApprovalWorkflowStateChoices.APPROVED)
513
+ self.assertEqual(new_response.comments, "Approved!")
514
+
446
515
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
447
516
  def test_deny_endpoint(self):
448
517
  """Test the deny endpoint."""
449
518
  approval_workflow_stage = ApprovalWorkflowStage.objects.first()
450
519
  self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
451
520
 
452
- # Try GET with model-level permission
521
+ # Try GET with model-level permission but not belonging to the approver group
453
522
  url = reverse("extras:approvalworkflowstage_deny", args=[approval_workflow_stage.pk])
523
+ response = self.client.get(url, follow=True)
524
+ self.assertHttpStatus(response, 200)
525
+ self.assertBodyContains(response, "You are not permitted to deny this")
526
+
527
+ # Try GET with belonging to the approver group
528
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
454
529
  response = self.client.get(url)
530
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
455
531
  self.assertHttpStatus(response, 200)
456
532
  self.assertBodyContains(response, '<div class="card border-danger">') # Assert the danger panel is present
457
533
 
458
- # Try POST with model-level permission
534
+ # Try POST with model-level permission but not belonging to the approver group
459
535
  request = {
460
536
  "path": url,
461
537
  "data": post_data({"comments": "Denied!"}),
462
538
  }
463
539
  response = self.client.post(**request, follow=True)
464
540
  self.assertHttpStatus(response, 200)
541
+ self.assertBodyContains(response, "You are not permitted to deny this")
542
+
543
+ # Try POST with belonging to the approver group
544
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
545
+ response = self.client.post(**request, follow=True)
546
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
547
+ self.assertHttpStatus(response, 200)
465
548
  approval_workflow_stage.refresh_from_db()
466
549
  # New response should be created
467
550
  new_response = ApprovalWorkflowStageResponse.objects.get(
@@ -479,6 +562,239 @@ class ApprovalWorkflowStageViewTestCase(
479
562
  self.assertHttpStatus(response, 200)
480
563
  self.assertBodyContains(response, "Denial Date") # Assert the denial date is present
481
564
 
565
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
566
+ def test_deny_stage_with_existing_comment_endpoint(self):
567
+ """Test the deny stage with existing comment endpoint."""
568
+ approval_workflow_stage = ApprovalWorkflowStage.objects.first()
569
+ new_response = ApprovalWorkflowStageResponse.objects.create(
570
+ approval_workflow_stage=approval_workflow_stage,
571
+ user=self.user,
572
+ comments="existing comment",
573
+ state=ApprovalWorkflowStateChoices.COMMENT,
574
+ )
575
+ self.assertEqual(
576
+ ApprovalWorkflowStageResponse.objects.filter(
577
+ approval_workflow_stage=approval_workflow_stage, user=self.user
578
+ ).count(),
579
+ 1,
580
+ )
581
+ self.client.force_login(self.user)
582
+ self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
583
+
584
+ # Try GET with model-level permission
585
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
586
+ url = reverse("extras:approvalworkflowstage_deny", args=[approval_workflow_stage.pk])
587
+ response = self.client.get(url)
588
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
589
+ self.assertHttpStatus(response, 200)
590
+ self.assertBodyContains(response, '<div class="card border-danger">') # Assert the danger panel is present
591
+ self.assertBodyContains(response, "existing comment")
592
+
593
+ # Try POST with model-level permission
594
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
595
+ request = {
596
+ "path": url,
597
+ "data": post_data({"comments": "Denied!"}),
598
+ }
599
+ response = self.client.post(**request, follow=True)
600
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
601
+ self.assertHttpStatus(response, 200)
602
+ approval_workflow_stage.refresh_from_db()
603
+ # Response should be updated
604
+ new_response.refresh_from_db()
605
+ self.assertEqual(
606
+ ApprovalWorkflowStageResponse.objects.filter(
607
+ approval_workflow_stage=approval_workflow_stage, user=self.user
608
+ ).count(),
609
+ 1,
610
+ )
611
+ self.assertEqual(new_response.state, ApprovalWorkflowStateChoices.DENIED)
612
+ self.assertEqual(new_response.comments, "Denied!")
613
+ self.assertBodyContains(
614
+ response, f"You denied {approval_workflow_stage}."
615
+ ) # Assert the denial message is present
616
+
617
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
618
+ def test_comment_endpoint(self):
619
+ """Test the comment endpoint."""
620
+ approval_workflow_stage = ApprovalWorkflowStage.objects.first()
621
+ self.client.force_login(self.user)
622
+ self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
623
+
624
+ # Try GET with model-level permission
625
+ url = reverse("extras:approvalworkflowstage_comment", args=[approval_workflow_stage.pk])
626
+ response = self.client.get(url)
627
+ self.assertHttpStatus(response, 200)
628
+ expected_object_button = '<button type="submit" name="_confirm" class="btn btn-info"><span aria-hidden="true" class="mdi mdi-check me-4"></span><!---->Comment</button>'
629
+ self.assertContains(response, expected_object_button, html=True) # Assert button Comment
630
+ # Try POST with model-level permission
631
+ request = {
632
+ "path": url,
633
+ "data": post_data({"comments": "It is just a comment"}),
634
+ }
635
+ response = self.client.post(**request, follow=True)
636
+ self.assertHttpStatus(response, 200)
637
+ approval_workflow_stage.refresh_from_db()
638
+ # New response should be created
639
+ new_response = ApprovalWorkflowStageResponse.objects.get(
640
+ approval_workflow_stage=approval_workflow_stage, user=self.user
641
+ )
642
+ self.assertEqual(new_response.state, ApprovalWorkflowStateChoices.COMMENT)
643
+ self.assertEqual(new_response.comments, "It is just a comment")
644
+ self.assertBodyContains(
645
+ response, f"You commented {approval_workflow_stage}."
646
+ ) # Assert the comment message is present
647
+
648
+ # Try GET again in form should be previous message
649
+ url = reverse("extras:approvalworkflowstage_comment", args=[approval_workflow_stage.pk])
650
+ response = self.client.get(url)
651
+ self.assertHttpStatus(response, 200)
652
+ self.assertBodyContains(response, "It is just a comment")
653
+
654
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
655
+ def test_edit_comment_endpoint(self):
656
+ """Test the edit comment endpoint."""
657
+ approval_workflow_stage = ApprovalWorkflowStage.objects.first()
658
+ # create new response with a comment
659
+ new_response = ApprovalWorkflowStageResponse.objects.create(
660
+ approval_workflow_stage=approval_workflow_stage,
661
+ user=self.user,
662
+ comments="existing comment",
663
+ state=ApprovalWorkflowStateChoices.COMMENT,
664
+ )
665
+ self.assertEqual(
666
+ ApprovalWorkflowStageResponse.objects.filter(
667
+ approval_workflow_stage=approval_workflow_stage, user=self.user
668
+ ).count(),
669
+ 1,
670
+ )
671
+ self.client.force_login(self.user)
672
+ self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
673
+
674
+ # Try GET again in form should be previous message
675
+ url = reverse("extras:approvalworkflowstage_comment", args=[approval_workflow_stage.pk])
676
+ response = self.client.get(url)
677
+ self.assertHttpStatus(response, 200)
678
+ self.assertBodyContains(response, new_response.comments)
679
+
680
+ request = {
681
+ "path": url,
682
+ "data": post_data({"comments": "Edit existing comment"}),
683
+ }
684
+ response = self.client.post(**request, follow=True)
685
+ self.assertHttpStatus(response, 200)
686
+ approval_workflow_stage.refresh_from_db()
687
+ self.assertEqual(
688
+ ApprovalWorkflowStageResponse.objects.filter(
689
+ approval_workflow_stage=approval_workflow_stage, user=self.user
690
+ ).count(),
691
+ 1,
692
+ )
693
+ edited_response = ApprovalWorkflowStageResponse.objects.get(
694
+ approval_workflow_stage=approval_workflow_stage, user=self.user
695
+ )
696
+ self.assertEqual(edited_response.state, ApprovalWorkflowStateChoices.COMMENT)
697
+ self.assertEqual(edited_response.comments, "Edit existing comment")
698
+
699
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
700
+ def test_add_new_comment_from_different_user(self):
701
+ """Test the add comment if different user add it."""
702
+ approval_workflow_stage = ApprovalWorkflowStage.objects.first()
703
+ # create new response with a comment
704
+ second_user = User.objects.last()
705
+ new_response = ApprovalWorkflowStageResponse.objects.create(
706
+ approval_workflow_stage=approval_workflow_stage,
707
+ user=second_user,
708
+ comments="existing comment",
709
+ state=ApprovalWorkflowStateChoices.COMMENT,
710
+ )
711
+ self.assertEqual(
712
+ ApprovalWorkflowStageResponse.objects.filter(
713
+ approval_workflow_stage=approval_workflow_stage, user=second_user
714
+ ).count(),
715
+ 1,
716
+ )
717
+ self.client.force_login(self.user)
718
+ self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
719
+
720
+ # Try GET again in form should be previous message
721
+ url = reverse("extras:approvalworkflowstage_comment", args=[approval_workflow_stage.pk])
722
+ response = self.client.get(url)
723
+ self.assertHttpStatus(response, 200)
724
+ expected_object_comment = '<textarea name="comments" cols="40" rows="10" class="form-control" placeholder="Comments" id="id_comments"></textarea>'
725
+ self.assertContains(response, expected_object_comment, html=True) # Assert empty textarea
726
+
727
+ request = {
728
+ "path": url,
729
+ "data": post_data({"comments": "New comment"}),
730
+ }
731
+ response = self.client.post(**request, follow=True)
732
+ self.assertHttpStatus(response, 200)
733
+ self.assertEqual(ApprovalWorkflowStageResponse.objects.all().count(), 2)
734
+
735
+ old_response = ApprovalWorkflowStageResponse.objects.get(
736
+ approval_workflow_stage=approval_workflow_stage, user=second_user
737
+ )
738
+ self.assertEqual(old_response.state, ApprovalWorkflowStateChoices.COMMENT)
739
+ self.assertEqual(old_response.comments, "existing comment")
740
+ new_response = ApprovalWorkflowStageResponse.objects.get(
741
+ approval_workflow_stage=approval_workflow_stage, user=self.user
742
+ )
743
+ self.assertEqual(new_response.state, ApprovalWorkflowStateChoices.COMMENT)
744
+ self.assertEqual(new_response.comments, "New comment")
745
+
746
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
747
+ def test_add_comment_to_pending_approval(self):
748
+ """
749
+ Test editing or adding a comment in the approval stage that already has one approval,
750
+ but needs two. Only edit the comment don't change the state from APPROVED to COMMENT.
751
+ """
752
+ approval_workflow_stage = ApprovalWorkflowStage.objects.first()
753
+ # check min_approver to 2
754
+ approval_workflow_stage.approval_workflow_stage_definition.min_approvers = 2
755
+ approval_workflow_stage.approval_workflow_stage_definition.save()
756
+ # create new response with a comment
757
+ new_response = ApprovalWorkflowStageResponse.objects.create(
758
+ approval_workflow_stage=approval_workflow_stage,
759
+ user=self.user,
760
+ comments="approved comment",
761
+ state=ApprovalWorkflowStateChoices.APPROVED,
762
+ )
763
+ self.assertEqual(
764
+ ApprovalWorkflowStageResponse.objects.filter(
765
+ approval_workflow_stage=approval_workflow_stage, user=self.user
766
+ ).count(),
767
+ 1,
768
+ )
769
+ self.client.force_login(self.user)
770
+ self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
771
+
772
+ # Try GET again in form should be previous message
773
+ url = reverse("extras:approvalworkflowstage_comment", args=[approval_workflow_stage.pk])
774
+ response = self.client.get(url)
775
+ self.assertHttpStatus(response, 200)
776
+ self.assertBodyContains(response, new_response.comments)
777
+
778
+ request = {
779
+ "path": url,
780
+ "data": post_data({"comments": "Edit approved comment"}),
781
+ }
782
+ response = self.client.post(**request, follow=True)
783
+ self.assertHttpStatus(response, 200)
784
+ approval_workflow_stage.refresh_from_db()
785
+ self.assertEqual(
786
+ ApprovalWorkflowStageResponse.objects.filter(
787
+ approval_workflow_stage=approval_workflow_stage, user=self.user
788
+ ).count(),
789
+ 1,
790
+ )
791
+ edited_response = ApprovalWorkflowStageResponse.objects.get(
792
+ approval_workflow_stage=approval_workflow_stage, user=self.user
793
+ )
794
+ # assert state is still APPROVED
795
+ self.assertEqual(edited_response.state, ApprovalWorkflowStateChoices.APPROVED)
796
+ self.assertEqual(edited_response.comments, "Edit approved comment")
797
+
482
798
 
483
799
  class ApprovalWorkflowStageResponseViewTestCase(
484
800
  ViewTestCases.DeleteObjectViewTestCase,
@@ -517,6 +833,7 @@ class ApprovalWorkflowStageResponseViewTestCase(
517
833
  name=f"Test Approval Workflow {i} Definition",
518
834
  model_content_type=cls.scheduledjob_ct,
519
835
  weight=i,
836
+ model_constraints={"job_model__name": "NoSuchJob"},
520
837
  )
521
838
  for i in range(5)
522
839
  ]