nautobot 3.0.0rc2__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.
- nautobot/core/celery/__init__.py +46 -1
- nautobot/core/cli/bootstrap_v3_to_v5.py +125 -44
- nautobot/core/jobs/bulk_actions.py +12 -6
- nautobot/core/settings.py +13 -0
- nautobot/core/settings.yaml +22 -0
- nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
- nautobot/core/templates/nautobot_config.py.j2 +14 -1
- nautobot/core/templates/redoc_ui.html +3 -0
- nautobot/core/testing/views.py +1 -1
- nautobot/core/tests/test_jobs.py +118 -0
- nautobot/core/tests/test_views.py +24 -0
- nautobot/core/ui/bulk_buttons.py +2 -3
- nautobot/core/views/generic.py +1 -0
- nautobot/core/views/mixins.py +6 -7
- nautobot/core/views/renderers.py +1 -0
- nautobot/core/views/utils.py +3 -3
- nautobot/extras/jobs.py +48 -2
- nautobot/extras/models/models.py +19 -0
- nautobot/extras/tables.py +9 -6
- nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
- nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
- nautobot/extras/tests/test_customfields_filters.py +84 -4
- nautobot/extras/tests/test_views.py +40 -4
- nautobot/extras/views.py +63 -38
- nautobot/project-static/dist/css/nautobot.css +1 -1
- nautobot/project-static/dist/css/nautobot.css.map +1 -1
- nautobot/ui/src/scss/nautobot.scss +2 -1
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.1.dist-info}/METADATA +1 -1
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.1.dist-info}/RECORD +33 -33
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.1.dist-info}/LICENSE.txt +0 -0
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.1.dist-info}/NOTICE +0 -0
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.1.dist-info}/WHEEL +0 -0
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.1.dist-info}/entry_points.txt +0 -0
nautobot/core/ui/bulk_buttons.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
nautobot/core/views/generic.py
CHANGED
|
@@ -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
|
nautobot/core/views/mixins.py
CHANGED
|
@@ -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.
|
|
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:
|
nautobot/core/views/renderers.py
CHANGED
|
@@ -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:
|
nautobot/core/views/utils.py
CHANGED
|
@@ -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
|
|
595
|
-
# with a job. It is better to be consistent
|
|
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:
|
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:
|
nautobot/extras/models/models.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
5
|
+
{% block title %}Deny Workflow Stage?{% endblock %}
|
|
5
6
|
|
|
6
7
|
{% block message %}
|
|
7
|
-
<p>Are you sure you want to deny
|
|
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(
|
|
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=
|
|
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
|