nautobot 3.0.0rc2__py3-none-any.whl → 3.0.2__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 (51) hide show
  1. nautobot/apps/views.py +2 -0
  2. nautobot/core/celery/__init__.py +46 -1
  3. nautobot/core/cli/bootstrap_v3_to_v5.py +125 -44
  4. nautobot/core/jobs/bulk_actions.py +12 -6
  5. nautobot/core/settings.py +13 -0
  6. nautobot/core/settings.yaml +22 -0
  7. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  8. nautobot/core/templates/nautobot_config.py.j2 +14 -1
  9. nautobot/core/templates/redoc_ui.html +3 -0
  10. nautobot/core/testing/filters.py +2 -2
  11. nautobot/core/testing/views.py +1 -1
  12. nautobot/core/tests/test_jobs.py +118 -0
  13. nautobot/core/tests/test_views.py +24 -0
  14. nautobot/core/ui/bulk_buttons.py +2 -3
  15. nautobot/core/ui/object_detail.py +2 -2
  16. nautobot/core/views/generic.py +1 -0
  17. nautobot/core/views/mixins.py +6 -7
  18. nautobot/core/views/renderers.py +1 -0
  19. nautobot/core/views/utils.py +3 -3
  20. nautobot/dcim/tables/devices.py +1 -1
  21. nautobot/dcim/views.py +1 -1
  22. nautobot/extras/jobs.py +48 -2
  23. nautobot/extras/models/jobs.py +1 -0
  24. nautobot/extras/models/models.py +19 -0
  25. nautobot/extras/tables.py +9 -6
  26. nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
  27. nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
  28. nautobot/extras/tests/test_customfields_filters.py +84 -4
  29. nautobot/extras/tests/test_views.py +40 -4
  30. nautobot/extras/views.py +63 -38
  31. nautobot/project-static/dist/css/graphql-libraries.css.map +1 -1
  32. nautobot/project-static/dist/css/materialdesignicons.css.map +1 -1
  33. nautobot/project-static/dist/css/nautobot.css +1 -1
  34. nautobot/project-static/dist/css/nautobot.css.map +1 -1
  35. nautobot/project-static/dist/js/graphql-libraries.js +1 -1
  36. nautobot/project-static/dist/js/graphql-libraries.js.map +1 -1
  37. nautobot/project-static/dist/js/libraries.js.map +1 -1
  38. nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
  39. nautobot/project-static/dist/js/nautobot.js.map +1 -1
  40. nautobot/project-static/img/dark-theme.png +0 -0
  41. nautobot/project-static/img/light-theme.png +0 -0
  42. nautobot/project-static/img/system-theme.png +0 -0
  43. nautobot/ui/package-lock.json +25 -25
  44. nautobot/ui/package.json +6 -6
  45. nautobot/ui/src/scss/nautobot.scss +2 -1
  46. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/METADATA +6 -6
  47. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/RECORD +51 -51
  48. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/LICENSE.txt +0 -0
  49. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/NOTICE +0 -0
  50. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/WHEEL +0 -0
  51. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/entry_points.txt +0 -0
@@ -997,3 +997,27 @@ class SearchRobotsTestCase(TestCase):
997
997
  url = reverse("home")
998
998
  response = self.client.get(url)
999
999
  self.assertNotContains(response, '<meta name="robots" content="noindex, nofollow">', html=True)
1000
+
1001
+
1002
+ @tag("example_app")
1003
+ class ViewSetCustomActionsTestCase(TestCase):
1004
+ """Tests for NautobotUIViewSet custom actions."""
1005
+
1006
+ def test_custom_view_base_action(self):
1007
+ """Assert that all of the permissions in a custom action's custom_view_base_action are enforced."""
1008
+ from example_app.models import AnotherExampleModel
1009
+
1010
+ self.add_permissions("example_app.view_anotherexamplemodel", "dcim.view_location")
1011
+ self.remove_permissions("dcim.add_location", "dcim.change_location")
1012
+
1013
+ example_model = AnotherExampleModel.objects.create(name="test")
1014
+ url = reverse(
1015
+ "plugins:example_app:anotherexamplemodel_custom-action-permissions-test",
1016
+ kwargs={"pk": example_model.pk},
1017
+ )
1018
+ response = self.client.get(url)
1019
+ self.assertEqual(response.status_code, 403)
1020
+
1021
+ self.add_permissions("dcim.add_location", "dcim.change_location")
1022
+ response = self.client.get(url)
1023
+ self.assertEqual(response.status_code, 200)
@@ -1,5 +1,6 @@
1
1
  from nautobot.core.choices import ButtonActionColorChoices
2
2
  from nautobot.core.ui import object_detail
3
+ from nautobot.core.utils.lookup import get_route_for_model
3
4
 
4
5
 
5
6
  class BaseBulkButton(object_detail.FormButton):
@@ -12,9 +13,7 @@ class BaseBulkButton(object_detail.FormButton):
12
13
  weight = None
13
14
 
14
15
  def __init__(self, *, form_id: str, model, **kwargs):
15
- model_name = model.__name__.lower()
16
- app_label = model._meta.app_label
17
- link_name = f"{app_label}:{model_name}_bulk_{self.action}"
16
+ link_name = get_route_for_model(model, f"bulk_{self.action}")
18
17
 
19
18
  super().__init__(
20
19
  link_name=link_name,
@@ -1841,8 +1841,8 @@ class _ObjectCustomFieldsPanel(GroupedKeyValueTablePanel):
1841
1841
  elif cf.type == CustomFieldTypeChoices.TYPE_JSON and value is not None:
1842
1842
  return format_html(
1843
1843
  """<p>
1844
- <button class="btn btn-xs btn-primary" type="button" data-toggle="collapse"
1845
- data-target="#cf_{field_key}" aria-expanded="false" aria-controls="cf_{field_key}">
1844
+ <button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse"
1845
+ data-bs-target="#cf_{field_key}" aria-expanded="false" aria-controls="cf_{field_key}">
1846
1846
  Show/Hide
1847
1847
  </button>
1848
1848
  </p>
@@ -227,6 +227,7 @@ class ObjectListView(UIComponentsMixin, ObjectPermissionRequiredMixin, View):
227
227
  hide_hierarchy_ui = False
228
228
  clear_view = request.GET.get("clear_view", False)
229
229
  resolved_path = resolve(request.path)
230
+ # Note that `resolved_path.app_name` does work even for nested paths like `plugins:example_app:...`
230
231
  list_url = f"{resolved_path.app_name}:{resolved_path.url_name}"
231
232
 
232
233
  skip_user_and_global_default_saved_view = False
@@ -17,7 +17,7 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
17
17
  from django.http import HttpResponse
18
18
  from django.shortcuts import get_object_or_404, redirect
19
19
  from django.template.loader import select_template, TemplateDoesNotExist
20
- from django.urls import reverse
20
+ from django.urls import resolve, reverse
21
21
  from django.urls.exceptions import NoReverseMatch
22
22
  from django.utils.encoding import iri_to_uri
23
23
  from django.utils.html import format_html
@@ -74,9 +74,6 @@ PERMISSIONS_ACTION_MAP = {
74
74
  "changelog": "view",
75
75
  "notes": "view",
76
76
  "data_compliance": "view",
77
- "approve": "change",
78
- "deny": "change",
79
- "comment": "change",
80
77
  }
81
78
 
82
79
 
@@ -421,7 +418,7 @@ class NautobotViewSetMixin(GenericViewSet, UIComponentsMixin, AccessMixin, GetRe
421
418
  model_permissions.append(f"{model._meta.app_label}.{action}_{model._meta.model_name}")
422
419
  # Append additional object permissions if specified.
423
420
  if self.custom_view_additional_permissions:
424
- model_permissions.append(*self.custom_view_additional_permissions)
421
+ model_permissions.extend(self.custom_view_additional_permissions)
425
422
  return model_permissions
426
423
 
427
424
  def get_required_permission(self):
@@ -932,10 +929,12 @@ class ObjectListViewMixin(NautobotViewSetMixin, mixins.ListModelMixin):
932
929
  self.filterset_class(),
933
930
  )
934
931
 
932
+ resolved_path = resolve(request.path)
933
+ # Note that `resolved_path.app_name` does work even for nested paths like `plugins:example_app:...`
934
+ view_name = f"{resolved_path.app_name}:{resolved_path.url_name}"
935
+
935
936
  # Check if there is a default for this view for this specific user
936
937
  user_default_saved_view = None
937
- app_label, model_name = queryset.model._meta.label.split(".")
938
- view_name = f"{app_label}:{model_name.lower()}_list"
939
938
  user = request.user
940
939
  if not isinstance(user, AnonymousUser):
941
940
  try:
@@ -311,6 +311,7 @@ class NautobotHTMLRenderer(renderers.BrowsableAPIRenderer):
311
311
  valid_actions = self.validate_action_buttons(view, request)
312
312
  # Query SavedViews for dropdown button
313
313
  resolved_path = resolve(request.path)
314
+ # Note that `resolved_path.app_name` does work even for nested paths like `plugins:example_app:...`
314
315
  list_url = f"{resolved_path.app_name}:{resolved_path.url_name}"
315
316
  saved_views = None
316
317
  if model.is_saved_view_model:
@@ -591,9 +591,9 @@ def get_bulk_queryset_from_view(
591
591
 
592
592
  queryset = view_class.queryset.restrict(user, action)
593
593
 
594
- # The filterset_class is determined from model on purpose, as filterset_class the view as a param, will not work
595
- # with a job. It is better to be consistent with each with sending the same params that will
596
- # always be available from to the confirmation page and to the job.
594
+ # The filterset_class is determined from model on purpose versus getting it from the view itself. This is
595
+ # because the filterset_class on the view as a param, will not work with a job. It is better to be consistent
596
+ # with each with sending the same params that will always be available from to the confirmation page and to the job.
597
597
  filterset_class = get_filterset_for_model(model)
598
598
 
599
599
  if not filterset_class:
@@ -400,7 +400,7 @@ class ModularDeviceComponentTable(DeviceComponentTable):
400
400
  super().__init__(*args, **kwargs)
401
401
 
402
402
  def render_module(self, record, value, **kwargs):
403
- if value and value == self.parent_module:
403
+ if value == self.parent_module or not value:
404
404
  return self.default
405
405
  return format_html('<a href="{}">{}</a>', value.get_absolute_url(), value)
406
406
 
nautobot/dcim/views.py CHANGED
@@ -964,7 +964,7 @@ class DeviceTypeFieldsPanel(object_detail.ObjectFieldsPanel):
964
964
  image = getattr(obj, key, None)
965
965
  if image:
966
966
  return format_html(
967
- '<a href="{}" target="_blank"><img src="{}" alt="{}" class="img-responsive"></a>',
967
+ '<a href="{}" target="_blank"><img src="{}" alt="{}" class="img-responsive mw-100"></a>',
968
968
  image.url,
969
969
  image.url,
970
970
  image.name,
nautobot/extras/jobs.py CHANGED
@@ -29,6 +29,7 @@ from django.db.models.query import QuerySet
29
29
  from django.forms import ValidationError
30
30
  from django.utils.functional import classproperty
31
31
  import netaddr
32
+ from prometheus_client import Counter
32
33
  import yaml
33
34
 
34
35
  from nautobot.core.celery import import_jobs, nautobot_task
@@ -89,6 +90,27 @@ __all__ = [
89
90
 
90
91
  logger = logging.getLogger(__name__)
91
92
 
93
+ started_jobs_counter = Counter(
94
+ name="nautobot_worker_started_jobs",
95
+ documentation="Job executions that started running",
96
+ labelnames=("job_class_name", "module_name"),
97
+ )
98
+ finished_jobs_counter = Counter(
99
+ name="nautobot_worker_finished_jobs",
100
+ documentation="Job executions that finished running",
101
+ labelnames=("job_class_name", "module_name", "status"),
102
+ )
103
+ exception_jobs_counter = Counter(
104
+ name="nautobot_worker_exception_jobs",
105
+ documentation="Job executions that raised an exception",
106
+ labelnames=("job_class_name", "module_name", "exception_type"),
107
+ )
108
+ singleton_conflict_counter = Counter(
109
+ name="nautobot_worker_singleton_conflict",
110
+ documentation="Job executions that ran into a singleton lock",
111
+ labelnames=("job_class_name", "module_name"),
112
+ )
113
+
92
114
 
93
115
  class RunJobTaskFailed(Exception):
94
116
  """Celery task failed for some reason."""
@@ -1194,6 +1216,9 @@ def _prepare_job(job_class_path, request, kwargs) -> tuple[Job, dict]:
1194
1216
  extra={"object": job.job_model, "grouping": "initialization"},
1195
1217
  )
1196
1218
  else:
1219
+ singleton_conflict_counter.labels(
1220
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
1221
+ ).inc()
1197
1222
  # TODO 3.0: maybe change to logger.failure() and return cleanly, as this is an "acceptable" failure?
1198
1223
  job.logger.error(
1199
1224
  "Job %s is a singleton and already running.",
@@ -1277,6 +1302,9 @@ def run_job(self, job_class_path, *args, **kwargs):
1277
1302
 
1278
1303
  result = None
1279
1304
  status = None
1305
+ started_jobs_counter.labels(
1306
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
1307
+ ).inc()
1280
1308
  try:
1281
1309
  before_start_result = job.before_start(self.request.id, args, kwargs)
1282
1310
  if not job._failed:
@@ -1293,6 +1321,9 @@ def run_job(self, job_class_path, *args, **kwargs):
1293
1321
  job.on_success(result, self.request.id, args, kwargs)
1294
1322
  else:
1295
1323
  job.on_failure(result, self.request.id, args, kwargs, None)
1324
+ finished_jobs_counter.labels(
1325
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name, status=status
1326
+ ).inc()
1296
1327
 
1297
1328
  job.after_return(status, result, self.request.id, args, kwargs, None)
1298
1329
 
@@ -1309,11 +1340,21 @@ def run_job(self, job_class_path, *args, **kwargs):
1309
1340
  # We don't want to overwrite the manual state update that we did above, so:
1310
1341
  raise Ignore()
1311
1342
 
1312
- except Reject:
1343
+ except Reject as exc:
1344
+ exception_jobs_counter.labels(
1345
+ job_class_name=job.job_model.job_class_name,
1346
+ module_name=job.job_model.module_name,
1347
+ exception_type=type(exc).__name__,
1348
+ ).inc()
1313
1349
  status = status or JobResultStatusChoices.STATUS_REJECTED
1314
1350
  raise
1315
1351
 
1316
- except Ignore:
1352
+ except Ignore as exc:
1353
+ exception_jobs_counter.labels(
1354
+ job_class_name=job.job_model.job_class_name,
1355
+ module_name=job.job_model.module_name,
1356
+ exception_type=type(exc).__name__,
1357
+ ).inc()
1317
1358
  status = status or JobResultStatusChoices.STATUS_IGNORED
1318
1359
  raise
1319
1360
 
@@ -1326,6 +1367,11 @@ def run_job(self, job_class_path, *args, **kwargs):
1326
1367
  "exc_type": type(exc).__name__,
1327
1368
  "exc_message": sanitize(str(exc)),
1328
1369
  }
1370
+ exception_jobs_counter.labels(
1371
+ job_class_name=job.job_model.job_class_name,
1372
+ module_name=job.job_model.module_name,
1373
+ exception_type=type(exc).__name__,
1374
+ ).inc()
1329
1375
  raise
1330
1376
 
1331
1377
  finally:
@@ -1137,6 +1137,7 @@ class ScheduledJobs(models.Model):
1137
1137
  last_update = models.DateTimeField(null=False)
1138
1138
 
1139
1139
  objects = ScheduledJobsManager()
1140
+ is_version_controlled = False
1140
1141
  is_data_compliance_model = False
1141
1142
 
1142
1143
  def __str__(self):
@@ -25,6 +25,7 @@ from nautobot.core.models import BaseManager, BaseModel
25
25
  from nautobot.core.models.fields import ForeignKeyWithAutoRelatedName, LaxURLField
26
26
  from nautobot.core.models.generics import OrganizationalModel, PrimaryModel
27
27
  from nautobot.core.utils.data import deepmerge, render_jinja2
28
+ from nautobot.core.utils.lookup import get_filterset_for_model, get_model_for_view_name
28
29
  from nautobot.extras.choices import (
29
30
  ButtonClassChoices,
30
31
  WebhookHttpMethodChoices,
@@ -918,6 +919,24 @@ class SavedView(BaseModel, ChangeLoggedModel):
918
919
 
919
920
  super().save(*args, **kwargs)
920
921
 
922
+ @property
923
+ def model(self):
924
+ """
925
+ Return the model class associated with this SavedView, based on the 'view' field.
926
+ """
927
+ return get_model_for_view_name(self.view)
928
+
929
+ def get_filtered_queryset(self, user):
930
+ """
931
+ Return a queryset for the associated model, filtered by this SavedView's filter_params.
932
+ """
933
+ model = self.model
934
+ if model is None:
935
+ return None
936
+ filter_params = self.config.get("filter_params", {})
937
+ filterset_class = get_filterset_for_model(model)
938
+ return filterset_class(filter_params, model.objects.restrict(user)).qs
939
+
921
940
 
922
941
  @extras_features("graphql")
923
942
  class UserSavedViewAssociation(BaseModel):
nautobot/extras/tables.py CHANGED
@@ -336,19 +336,19 @@ class ApprovalWorkflowTable(BaseTable):
336
336
  model = ApprovalWorkflow
337
337
  fields = (
338
338
  "pk",
339
- "approval_workflow_definition",
340
339
  "object_under_review_content_type",
341
340
  "object_under_review",
342
- "current_state",
343
341
  "user",
342
+ "current_state",
343
+ "approval_workflow_definition",
344
344
  )
345
345
  default_columns = (
346
346
  "pk",
347
- "approval_workflow_definition",
348
347
  "object_under_review_content_type",
349
348
  "object_under_review",
350
- "current_state",
351
349
  "user",
350
+ "current_state",
351
+ "approval_workflow_definition",
352
352
  "actions",
353
353
  )
354
354
 
@@ -426,6 +426,7 @@ class ApproverDashboardTable(ApprovalWorkflowStageTable):
426
426
  template_code="<a href={{record.approval_workflow.get_absolute_url}}>{{ record.approval_workflow_stage_definition.name }}</a>",
427
427
  verbose_name="Current Stage",
428
428
  )
429
+ approval_workflow__object_under_review_content_type = tables.Column(verbose_name="Object Type Under Review")
429
430
  object_under_review = tables.TemplateColumn(
430
431
  template_code="<a href={{record.approval_workflow.object_under_review.get_absolute_url }}>{{ record.approval_workflow.object_under_review }}</a>"
431
432
  )
@@ -437,6 +438,7 @@ class ApproverDashboardTable(ApprovalWorkflowStageTable):
437
438
  model = ApprovalWorkflowStage
438
439
  fields = (
439
440
  "pk",
441
+ "approval_workflow__object_under_review_content_type",
440
442
  "object_under_review",
441
443
  "approval_workflow",
442
444
  "approval_workflow_stage",
@@ -446,9 +448,10 @@ class ApproverDashboardTable(ApprovalWorkflowStageTable):
446
448
  )
447
449
  default_columns = (
448
450
  "pk",
449
- "approval_workflow_stage",
450
- "approval_workflow",
451
+ "approval_workflow__object_under_review_content_type",
451
452
  "object_under_review",
453
+ "approval_workflow",
454
+ "approval_workflow_stage",
452
455
  "actions_needed",
453
456
  "state",
454
457
  "actions",
@@ -1,11 +1,18 @@
1
1
  {% extends 'utilities/confirmation_form.html' %}
2
2
  {% load form_helpers %}
3
+ {% load helpers %}
3
4
 
4
- {% block title %}Approve {{ obj_type }}?{% endblock %}
5
+
6
+ {% block title %}Approve Workflow Stage?{% endblock %}
5
7
 
6
8
  {% block message %}
7
9
  {% block message_extra %}{% endblock %}
8
- <p>Are you sure you want to approve <strong>{{ obj }}</strong> for <strong> {{ object_under_review }}</strong>?</p>
10
+ <p>Are you sure you want to approve</p>
11
+ <ul>
12
+ <li>Stage <strong>{{ obj.approval_workflow_stage_definition.name }}</strong></li>
13
+ <li>from Workflow <strong>{{ obj.approval_workflow_stage_definition.approval_workflow_definition.name }}</strong></li>
14
+ <li>for {{ object_under_review|meta:"verbose_name"|bettertitle }} <strong>{{ object_under_review|hyperlinked_object}}</strong>?</li>
15
+ </ul>
9
16
  <p>You can leave optional comments here.</p>
10
17
  {% render_field form.comments %}
11
18
  {% endblock %}
@@ -1,10 +1,16 @@
1
1
  {% extends 'utilities/confirmation_form.html' %}
2
2
  {% load form_helpers %}
3
+ {% load helpers %}
3
4
 
4
- {% block title %}Deny {{ obj_type }}?{% endblock %}
5
+ {% block title %}Deny Workflow Stage?{% endblock %}
5
6
 
6
7
  {% block message %}
7
- <p>Are you sure you want to deny <strong>{{ obj }}</strong> for <strong> {{ object_under_review }}</strong>?</p>
8
+ <p>Are you sure you want to deny</p>
9
+ <ul>
10
+ <li>Stage <strong>{{ obj.approval_workflow_stage_definition.name }}</strong></li>
11
+ <li>from Workflow <strong>{{ obj.approval_workflow_stage_definition.approval_workflow_definition.name }}</strong></li>
12
+ <li>for {{ object_under_review|meta:"verbose_name"|bettertitle }} <strong>{{ object_under_review|hyperlinked_object}}</strong>?</li>
13
+ </ul>
8
14
  <p>You can leave optional comments here.</p>
9
15
  {% render_field form.comments %}
10
- {% endblock %}
16
+ {% endblock %}
@@ -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
@@ -419,19 +419,33 @@ class ApprovalWorkflowStageViewTestCase(
419
419
  self.client.force_login(self.user)
420
420
  self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
421
421
 
422
- # Try GET with model-level permission
422
+ # Try GET with model-level permission but not belonging to the approver group
423
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)
424
430
  response = self.client.get(url)
431
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
425
432
  self.assertHttpStatus(response, 200)
426
433
  self.assertBodyContains(response, '<div class="card border-success">') # Assert the success panel is present
427
434
 
428
- # Try POST with model-level permission
435
+ # Try POST with model-level permission but not belonging to the approver group
429
436
  request = {
430
437
  "path": url,
431
438
  "data": post_data({"comments": "Approved!"}),
432
439
  }
433
440
  response = self.client.post(**request, follow=True)
434
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)
435
449
  approval_workflow_stage.refresh_from_db()
436
450
  # New response should be created
437
451
  new_response = ApprovalWorkflowStageResponse.objects.get(
@@ -469,8 +483,10 @@ class ApprovalWorkflowStageViewTestCase(
469
483
  self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
470
484
 
471
485
  # Try GET with model-level permission
486
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
472
487
  url = reverse("extras:approvalworkflowstage_approve", args=[approval_workflow_stage.pk])
473
488
  response = self.client.get(url)
489
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
474
490
  self.assertHttpStatus(response, 200)
475
491
  self.assertBodyContains(response, '<div class="card border-success">') # Assert the success panel is present
476
492
  self.assertBodyContains(response, "existing comment")
@@ -480,7 +496,9 @@ class ApprovalWorkflowStageViewTestCase(
480
496
  "path": url,
481
497
  "data": post_data({"comments": "Approved!"}),
482
498
  }
499
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
483
500
  response = self.client.post(**request, follow=True)
501
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
484
502
  self.assertHttpStatus(response, 200)
485
503
  approval_workflow_stage.refresh_from_db()
486
504
  # Response should be updated
@@ -500,19 +518,33 @@ class ApprovalWorkflowStageViewTestCase(
500
518
  approval_workflow_stage = ApprovalWorkflowStage.objects.first()
501
519
  self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
502
520
 
503
- # Try GET with model-level permission
521
+ # Try GET with model-level permission but not belonging to the approver group
504
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)
505
529
  response = self.client.get(url)
530
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
506
531
  self.assertHttpStatus(response, 200)
507
532
  self.assertBodyContains(response, '<div class="card border-danger">') # Assert the danger panel is present
508
533
 
509
- # Try POST with model-level permission
534
+ # Try POST with model-level permission but not belonging to the approver group
510
535
  request = {
511
536
  "path": url,
512
537
  "data": post_data({"comments": "Denied!"}),
513
538
  }
514
539
  response = self.client.post(**request, follow=True)
515
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)
516
548
  approval_workflow_stage.refresh_from_db()
517
549
  # New response should be created
518
550
  new_response = ApprovalWorkflowStageResponse.objects.get(
@@ -550,18 +582,22 @@ class ApprovalWorkflowStageViewTestCase(
550
582
  self.add_permissions("extras.change_approvalworkflowstage", "extras.view_approvalworkflowstage")
551
583
 
552
584
  # Try GET with model-level permission
585
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
553
586
  url = reverse("extras:approvalworkflowstage_deny", args=[approval_workflow_stage.pk])
554
587
  response = self.client.get(url)
588
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
555
589
  self.assertHttpStatus(response, 200)
556
590
  self.assertBodyContains(response, '<div class="card border-danger">') # Assert the danger panel is present
557
591
  self.assertBodyContains(response, "existing comment")
558
592
 
559
593
  # Try POST with model-level permission
594
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.add(self.user)
560
595
  request = {
561
596
  "path": url,
562
597
  "data": post_data({"comments": "Denied!"}),
563
598
  }
564
599
  response = self.client.post(**request, follow=True)
600
+ approval_workflow_stage.approval_workflow_stage_definition.approver_group.user_set.remove(self.user)
565
601
  self.assertHttpStatus(response, 200)
566
602
  approval_workflow_stage.refresh_from_db()
567
603
  # Response should be updated