From fdcfdfd39d1584629f12c1eebdd4600cd7e69b19 Mon Sep 17 00:00:00 2001 From: Nicolas Marcq Date: Tue, 9 Jan 2024 14:24:32 +0100 Subject: [PATCH 01/17] bump dev --- Squest/version.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Squest/version.py b/Squest/version.py index c3c61c86f..34db58545 100644 --- a/Squest/version.py +++ b/Squest/version.py @@ -1,2 +1,2 @@ -__version__ = "2.5.0" +__version__ = "2.5.1b" VERSION = __version__ diff --git a/pyproject.toml b/pyproject.toml index 0ea49faee..23780f7ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "squest" -version = "2.5.0" +version = "2.5.1b" description = "Service catalog on top of Red Hat Ansible Automation Platform(RHAAP)/AWX (formerly known as Ansible Tower)" authors = ["Nicolas Marcq ", "Elias Boulharts "] license = "MIT" From 2839db76739e7fa55994fe7789123e01bebf20f0 Mon Sep 17 00:00:00 2001 From: Anthony Belhadj Date: Tue, 9 Jan 2024 18:35:37 +0100 Subject: [PATCH 02/17] Fix background dark issue on documentatiion --- CHANGELOG.md | 6 ++++++ project-static/squest/css/squest-dark.css | 2 +- templates/generics/doc_aside.html | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba12b7b34..885a488d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# dev + +## Fix + +- CSS issue in documentation rendering for
 block in dark theme
+
 # 2.5.0 2024-01-09
 
 ## Fix 
diff --git a/project-static/squest/css/squest-dark.css b/project-static/squest/css/squest-dark.css
index ac7e826d2..86dad5aa4 100644
--- a/project-static/squest/css/squest-dark.css
+++ b/project-static/squest/css/squest-dark.css
@@ -7,7 +7,7 @@ footer{
     color: #fff !important;
 }
 
-.bg-dark table tr,.bg-dark blockquote,.bg-dark pre {
+.bg-dark table tr,.bg-dark blockquote,details.bg-dark pre {
     background-color: #343a40 !important;
     color: #fff !important;
 }
diff --git a/templates/generics/doc_aside.html b/templates/generics/doc_aside.html
index 506b82047..efecd8270 100644
--- a/templates/generics/doc_aside.html
+++ b/templates/generics/doc_aside.html
@@ -20,7 +20,7 @@ 
data-parent="#accordion_docs">
- {{ doc.content| safe_markdown }} + {{ doc.content | safe_markdown }}
From 7eca3831a711ddf13b8f15afe61dad4484f4b825 Mon Sep 17 00:00:00 2001 From: Anthony Belhadj Date: Tue, 9 Jan 2024 18:39:20 +0100 Subject: [PATCH 03/17] List docs in Operation and Service details qqq qq --- CHANGELOG.md | 3 +++ templates/service_catalog/operation_detail.html | 9 +++++++++ templates/service_catalog/service_detail.html | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 885a488d6..dfeeb212c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - CSS issue in documentation rendering for
 block in dark theme
 
+## Enhancement
+
+- List related docs in OperationDetails and ServiceDetails view
 # 2.5.0 2024-01-09
 
 ## Fix 
diff --git a/templates/service_catalog/operation_detail.html b/templates/service_catalog/operation_detail.html
index 7c21b0a35..abf6ac268 100644
--- a/templates/service_catalog/operation_detail.html
+++ b/templates/service_catalog/operation_detail.html
@@ -42,6 +42,15 @@ 

Details

{{ object.enabled | display_boolean }} +
  • + Docs + {% for doc in object.docs.all %} + + {{ doc.title }} +
    + {% endfor %} +
  • diff --git a/templates/service_catalog/service_detail.html b/templates/service_catalog/service_detail.html index 127addd85..f89a55054 100644 --- a/templates/service_catalog/service_detail.html +++ b/templates/service_catalog/service_detail.html @@ -26,6 +26,7 @@

    {{ object.name }}

    Service image
    @@ -55,6 +56,15 @@

    {{ object.name }}

    {% endfor %} +
  • + Docs + {% for doc in object.docs.all %} + + {{ doc.title }} +
    + {% endfor %} +
  • From f3b2faf97c74e5fd532d5cf672de1017039a9575 Mon Sep 17 00:00:00 2001 From: Anthony Belhadj Date: Tue, 9 Jan 2024 18:36:01 +0100 Subject: [PATCH 04/17] Add processing in home.html --- CHANGELOG.md | 2 ++ Squest/views.py | 3 +++ templates/home/home.html | 8 ++++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfeeb212c..f44d335a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ## Enhancement - List related docs in OperationDetails and ServiceDetails view +- Add PROCESSING requests in the dashboard of the main page + # 2.5.0 2024-01-09 ## Fix diff --git a/Squest/views.py b/Squest/views.py index 70dd48491..a172eab6f 100644 --- a/Squest/views.py +++ b/Squest/views.py @@ -80,6 +80,9 @@ def home(request): service_dict["hold_requests"] = sum([x["count"] for x in all_requests if x["state"] == RequestState.ON_HOLD and x[ "instance__service"] == service.id]) + service_dict["processing_requests"] = sum([x["count"] for x in all_requests if + x["state"] == RequestState.PROCESSING and x[ + "instance__service"] == service.id]) service_dict["opened_supports"] = sum([x["count"] for x in all_supports if x["state"] == SupportState.OPENED and x[ diff --git a/templates/home/home.html b/templates/home/home.html index f5d839130..3bfb64d72 100644 --- a/templates/home/home.html +++ b/templates/home/home.html @@ -36,6 +36,7 @@

    Service overview

    Submitted Accepted On hold + Processing Failed {% if can_list_support %} Supports @@ -86,6 +87,13 @@

    Service overview

    {% endif %} + + {% if service.processing_requests %} + {{ service.processing_requests }} + + {% endif %} + {% if service.failed_requests %} Date: Wed, 24 Jan 2024 16:20:22 +0100 Subject: [PATCH 05/17] Add ID on left-side of InstanceDetail --- templates/service_catalog/instance_detail.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/service_catalog/instance_detail.html b/templates/service_catalog/instance_detail.html index 5a827f82f..371573e1c 100644 --- a/templates/service_catalog/instance_detail.html +++ b/templates/service_catalog/instance_detail.html @@ -35,6 +35,11 @@

    {{ object.name }}

    alt="Service image">
    @@ -112,6 +117,11 @@

    {{ object.name }}

    {% render_table quotas %} {% endif %}
    +
    + {% if workflows %} + {% render_table workflows %} + {% endif %} +
    diff --git a/templates/profiles/team_detail.html b/templates/profiles/team_detail.html index 386b71fc3..d396f6b61 100644 --- a/templates/profiles/team_detail.html +++ b/templates/profiles/team_detail.html @@ -73,8 +73,11 @@

    {{ object.name }}

    Users {% endif %} - - + {% if workflows %} + + {% endif %}
    @@ -96,6 +99,11 @@

    {{ object.name }}

    {% render_table quotas %} {% endif %}
    +
    + {% if workflows %} + {% render_table workflows %} + {% endif %} +
    diff --git a/templates/service_catalog/approvalworkflow_detail.html b/templates/service_catalog/approvalworkflow_detail.html index cf9cd915e..73fb958ee 100644 --- a/templates/service_catalog/approvalworkflow_detail.html +++ b/templates/service_catalog/approvalworkflow_detail.html @@ -15,148 +15,161 @@ {% endblock %} {% block main %} -
    -
    -
    -
    -
    -

    {{ object.name }}

    -
    -
    -
      -
    • - ID{{ object.id }} -
    • -
    • - Name{{ object.name }} -
    • -
    • - Hash{{ object.hash | to_hexa }} -
    • -
    • - Operation - {{ object.operation.name }} -
    • +
      +
      +
      +
      +
      +

      {{ object.name }}

      +
      +
      +
        +
      • + ID{{ object.id }} +
      • +
      • + Name{{ object.name }} +
      • +
      • + Hash{{ object.hash | to_hexa }} +
      • +
      • + Operation + {{ object.operation.name }} +
      • +
      • + Enabled + {{ object.enabled | display_boolean }} +
      • + {% if object.scopes.all %}
      • - Enabled - {{ object.enabled | display_boolean }} -
      • - {% if object.scopes.all %} -
      • - Restricted scopes - + Restricted scopes + {% for scope in object.scopes.distinct %} {{ scope }} {% endfor %} -
      • - {% endif %} - {% if object.get_unused_fields %} -
      • +
      • + {% endif %} + {% if object.get_unused_fields %} +
      • Unused required fields - + {% for field in object.get_unused_fields %} {{ field }} {% endfor %} -
      • - {% endif %} - -
      -
      -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -

      Drag and drop steps to reorganize the order

      -
      +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    - - Add a step - +
    +

    Drag and drop steps to reorganize the order

    -
    - {% for approval_step in object.approval_steps.all|dictsort:"position" %} -
    -
    -

    {{ approval_step.name }}

    - + + Add a step + +
    + +
    + {% for approval_step in object.approval_steps.all|dictsort:"position" %} +
    +
    +

    {{ approval_step.name }}

    + -
    -
    -
    Permission
    -
    {{ approval_step.permission }}
    +
    +
    +
    +
    Permission
    +
    {{ approval_step.permission }}
    -
    Read survey fields
    -
    - {% for field in approval_step.readable_fields.all %} - {{ field.name }} - {% endfor %} -
    +
    Read survey fields
    +
    + {% for field in approval_step.readable_fields.all %} + {{ field.name }} + {% endfor %} +
    -
    Write survey fields
    -
    - {% for field in approval_step.editable_fields.all %} - {{ field.name }} - {% endfor %} -
    - {% if approval_step.auto_accept_condition %} -
    Auto accept condition
    -
    {{ approval_step.auto_accept_condition }}
    - {% endif %} -
    -
    +
    Write survey fields
    +
    + {% for field in approval_step.editable_fields.all %} + {{ field.name }} + {% endfor %} +
    + {% if approval_step.auto_accept_condition %} +
    Auto accept condition
    +
    {{ approval_step.auto_accept_condition }}
    + {% endif %} +
    - {% endfor %} -
    +
    + {% empty %} +
    + No steps in the workflow +
    + {% endfor %}
    -
    -
    - Requests currently using workflow -
    - {% render_table request_table %} +
    +
    +
    + Requests currently using workflow
    + {% render_table request_table %} +
    +
    +
    + Preview your workflow on your Organization/Team +
    + {% render_table scope_table %}
    -
    -
    -
    + +
    + + {% endblock %} {% block custom_script %} diff --git a/templates/service_catalog/approvalworkflow_preview.html b/templates/service_catalog/approvalworkflow_preview.html new file mode 100644 index 000000000..a63ca4611 --- /dev/null +++ b/templates/service_catalog/approvalworkflow_preview.html @@ -0,0 +1,129 @@ +{% extends 'base.html' %} +{% block title %} + #{{ object.id }} | Approval Workflow for {{ scope }} +{% endblock %} +{% load render_table from django_tables2 %} +{% load static %} + +{% block main %} +
    +
    +
    +
    +
    + {% if current_workflow.id != object.id %} +
    + {% if current_workflow %} + {{ scope }} doesn't use this + workflow. {{ scope }} use {{ current_workflow }} to + handle {{ object.operation }}. + {% else %} + {{ scope }} doesn't use workflow for {{ object.operation }} + {% endif %} +
    + + {% endif %} +
    + Example of workflow {{ object }} when requesting {{ object.operation }} for + {{ scope }}.
    + +
    + +
    + {% for approval_step in object.approval_steps.all %} +
    +
    +

    {{ approval_step.name }}

    +
    +
    +
    +
    Permission
    +
    + {{ approval_step.permission.permission_str }} +
    +
    Related roles
    +
    + {% for role in approval_step.permission.role_set.all %} + {{ role.name }}
    + {% empty %} + No related roles, create a role first. + {% endfor %} +
    +
    Read survey fields
    +
    + {% for field in approval_step.readable_fields.all %} + {{ field.name }} + {% endfor %} +
    +
    Write survey fields
    +
    + {% for field in approval_step.editable_fields.all %} + {{ field.name }} + {% endfor %} +
    + {% if approval_step.auto_accept_condition %} +
    Auto accept condition
    +
    {{ approval_step.auto_accept_condition }}
    + {% endif %} +
    Approvers
    + +
    + {% who_can_approve_on_scope approval_step scope as list_who_can_approve %} + {% if list_who_can_approve.all %} + {{ list_who_can_approve|join:", " }} + {% else %} + No + approvers + {% endif %} +
    +
    +
    + +
    + {% empty %} +
    + No steps in the workflow +
    + + {% endfor %} +
    +
    +
    +
    +
    +
    +{% endblock %} +{% block custom_script %} + + +{% endblock %} diff --git a/templates/service_catalog/custom_columns/preview_workflow.html b/templates/service_catalog/custom_columns/preview_workflow.html new file mode 100644 index 000000000..af21ef15c --- /dev/null +++ b/templates/service_catalog/custom_columns/preview_workflow.html @@ -0,0 +1,3 @@ + + Preview + diff --git a/tests/test_profiles/test_model/test_scope_get_workflow.py b/tests/test_profiles/test_model/test_scope_get_workflow.py new file mode 100644 index 000000000..e19702db6 --- /dev/null +++ b/tests/test_profiles/test_model/test_scope_get_workflow.py @@ -0,0 +1,49 @@ +from profiles.models import Scope +from service_catalog.models import ApprovalWorkflow, Request +from tests.setup import SetupRequest +from tests.utils import TransactionTestUtils + +from django.db.models.query import QuerySet + + +class TestApprovalWorkflowReset(TransactionTestUtils, SetupRequest): + + def setUp(self): + SetupRequest.setUp(self) + + def _create_approval(self, operation, scopes=None): + if isinstance(scopes, Scope): + scopes = [scopes] + aw = ApprovalWorkflow.objects.create( + name=f"AW - {operation} - {scopes}", + operation=operation, + enabled=True + ) + if scopes: + aw.scopes.set(scopes) + return aw + + def test_get_workflows(self): + # team1org2 -> no workflows + self.assertQuerysetEqualID(self.team1org2.get_workflows(), ApprovalWorkflow.objects.none()) + + # team1org2 -> workflow for all on operation_create1 + aw_for_all = self._create_approval(self.operation_create_1) + self.assertQuerysetEqualID(self.team1org2.get_workflows(), ApprovalWorkflow.objects.filter(id=aw_for_all.id)) + + # team1org2 -> workflow for org2 on operation_create1 ("All" overridden by org2) + aw_for_org2 = self._create_approval(self.operation_create_1, self.org2) + self.assertQuerysetEqualID(self.team1org2.get_workflows(), ApprovalWorkflow.objects.filter(id=aw_for_org2.id)) + + # team1org2 -> workflow for team1org2 on operation_create1 ( org2 overridden by team1org2) + aw_for_org2_team1 = self._create_approval(self.operation_create_1, self.team1org2) + self.assertQuerysetEqualID( + self.team1org2.get_workflows(), + ApprovalWorkflow.objects.filter(id=aw_for_org2_team1.id) + ) + + # team1org2 -> workflow for team1org2 on operation_create1 ( org2 overridden by team1org2) + # team1org2 -> + workflow for all on operation_update1 + aw_for_all_update1 = self._create_approval(self.operation_update_1) + self.assertQuerysetEqualID(self.team1org2.get_workflows(), ApprovalWorkflow.objects.filter( + id__in=[aw_for_org2_team1.id, aw_for_all_update1.id])) diff --git a/tests/test_service_catalog/test_urls/test_approvalworkflow.py b/tests/test_service_catalog/test_urls/test_approvalworkflow.py index 306e6c857..54493f532 100644 --- a/tests/test_service_catalog/test_urls/test_approvalworkflow.py +++ b/tests/test_service_catalog/test_urls/test_approvalworkflow.py @@ -41,6 +41,11 @@ def test_approvalworkflow_views(self): perm_str='service_catalog.change_approvalworkflow', url_kwargs={'pk': self.approval_workflow.id} ), + TestingGetContextView( + url='service_catalog:approvalworkflow_preview', + perm_str='service_catalog.view_approvalworkflow', + url_kwargs={'pk': self.approval_workflow.id, 'scope_id': self.test_quota_scope.id} + ), TestingPostContextView( url='service_catalog:approvalworkflow_edit', perm_str='service_catalog.change_approvalworkflow', From 01143363191649aead0e4b17bcc604e273941789 Mon Sep 17 00:00:00 2001 From: Anthony Belhadj Date: Fri, 26 Jan 2024 11:51:34 +0100 Subject: [PATCH 10/17] Fix clean on workflow --- CHANGELOG.md | 1 + .../forms/approval_workflow_form.py | 10 +++++- .../test_approval_workflow_serializer.py | 15 ++++++++ .../test_forms/test_approval_workflow_form.py | 36 +++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a5f218e..2a358596a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - CSS issue in documentation rendering for
     block in dark theme
     - Template issue on documentation when applying jinja filters on None
     - Remove superusers from "list_approvers". They still can approve but are not displayed in the list.
    +- When editing an ApprovalWorkflow, it was possible to use a scope already assigned to another workflow.
     
     ## Enhancement
     
    diff --git a/service_catalog/forms/approval_workflow_form.py b/service_catalog/forms/approval_workflow_form.py
    index 779505fe5..104721e3d 100644
    --- a/service_catalog/forms/approval_workflow_form.py
    +++ b/service_catalog/forms/approval_workflow_form.py
    @@ -10,9 +10,12 @@ class Meta:
             fields = ['name', 'operation', 'scopes', 'enabled']
     
         def clean(self):
    -        cleaned_data = super().clean()
    +        cleaned_data = super(ApprovalWorkflowForm, self).clean()
             operation = cleaned_data.get("operation")
             scopes = cleaned_data.get("scopes")
    +        self.custom_clean_scopes(operation, scopes)
    +
    +    def custom_clean_scopes(self, operation, scopes):
             # check that selected scopes are not already in use by another approval workflow for the selected operation
             exclude_id = self.instance.id if self.instance else None
             if not scopes.exists():
    @@ -29,3 +32,8 @@ class ApprovalWorkflowFormEdit(ApprovalWorkflowForm):
         class Meta:
             model = ApprovalWorkflow
             fields = ['name', 'scopes', 'enabled']
    +
    +    def clean(self):
    +        cleaned_data = super(ApprovalWorkflowForm, self).clean()
    +        scopes = cleaned_data.get("scopes")
    +        self.custom_clean_scopes(self.instance.operation, scopes)
    diff --git a/tests/test_service_catalog/test_api/test_serializers/test_approval_workflow_serializer.py b/tests/test_service_catalog/test_api/test_serializers/test_approval_workflow_serializer.py
    index 6a2003498..30530dabd 100644
    --- a/tests/test_service_catalog/test_api/test_serializers/test_approval_workflow_serializer.py
    +++ b/tests/test_service_catalog/test_api/test_serializers/test_approval_workflow_serializer.py
    @@ -30,6 +30,21 @@ def test_can_add_another_scope_to_scopes(self):
             serializer = ApprovalWorkflowSerializerEdit(instance=self.test_approval_workflow, data=data)
             self.assertTrue(serializer.is_valid())
     
    +    def test_can_add_another_scope_to_scopes_when_edit(self):
    +        existing_approval = ApprovalWorkflow.objects.create(name="test",
    +                                                            operation=self.create_operation_test,
    +                                                            enabled=True)
    +
    +        existing_approval.scopes.set([self.test_quota_scope])
    +        data = {
    +            "name": "test_workflow",
    +            "operation": self.create_operation_test.id,
    +            "scopes": [self.test_quota_scope.id]
    +        }
    +        serializer = ApprovalWorkflowSerializerEdit(instance=self.test_approval_workflow, data=data)
    +        self.assertFalse(serializer.is_valid())
    +        self.assertIn("has already an approval workflow based on this operation", serializer.errors["scopes"][0])
    +
         def test_cannot_valid_with_empty_scopes_if_already_exists(self):
             existing_approval = ApprovalWorkflow.objects.create(name="test",
                                                                 operation=self.create_operation_test,
    diff --git a/tests/test_service_catalog/test_forms/test_approval_workflow_form.py b/tests/test_service_catalog/test_forms/test_approval_workflow_form.py
    index 375b6c4ea..1f930ea4a 100644
    --- a/tests/test_service_catalog/test_forms/test_approval_workflow_form.py
    +++ b/tests/test_service_catalog/test_forms/test_approval_workflow_form.py
    @@ -32,6 +32,24 @@ def test_clean(self):
             self.assertFalse(form.is_valid())
             self.assertIn("has already an approval workflow", form["scopes"].errors[0])
     
    +    def test_clean_edit(self):
    +        existing_approval = ApprovalWorkflow.objects.create(name="test",
    +                                                            operation=self.create_operation_test,
    +                                                            enabled=True)
    +        existing_approval.scopes.set([self.test_quota_scope])
    +
    +        new_approval = ApprovalWorkflow.objects.create(name="test2",
    +                                                       operation=self.create_operation_test,
    +                                                       enabled=True)
    +        data = {
    +            'name': 'test_approval_workflow',
    +            'operation': self.create_operation_test,
    +            'scopes': [self.test_quota_scope]
    +        }
    +        form = ApprovalWorkflowFormEdit(data, instance=new_approval)
    +        self.assertFalse(form.is_valid())
    +        self.assertIn("has already an approval workflow", form["scopes"].errors[0])
    +
         def test_add_scope_to_scopes(self):
             existing_approval = ApprovalWorkflow.objects.create(name="test",
                                                                 operation=self.create_operation_test,
    @@ -46,6 +64,24 @@ def test_add_scope_to_scopes(self):
             form = ApprovalWorkflowFormEdit(instance=existing_approval, data=data)
             self.assertTrue(form.is_valid())
     
    +    def test_clean_edit_with_empty_scopes(self):
    +        existing_approval = ApprovalWorkflow.objects.create(name="test",
    +                                                            operation=self.create_operation_test,
    +                                                            enabled=True)
    +
    +        new_approval = ApprovalWorkflow.objects.create(name="test2",
    +                                                       operation=self.create_operation_test,
    +                                                       enabled=True)
    +        new_approval.scopes.set([self.test_quota_scope])
    +
    +        data = {
    +            'name': 'test_approval_workflow',
    +            'operation': self.create_operation_test
    +        }
    +        form = ApprovalWorkflowFormEdit(data, instance=new_approval)
    +        self.assertFalse(form.is_valid())
    +        self.assertIn("An approval workflow for all scopes already exists", form["scopes"].errors[0])
    +
         def test_clean_with_empty_scopes(self):
             existing_approval = ApprovalWorkflow.objects.create(name="test",
                                                                 operation=self.create_operation_test,
    
    From 21151e1961b1970b8ef0d43339028fdfc78d1521 Mon Sep 17 00:00:00 2001
    From: elias-boulharts 
    Date: Fri, 26 Jan 2024 14:50:14 +0100
    Subject: [PATCH 11/17] Display only docs that are not linked to services or
     operations
    
    ---
     CHANGELOG.md                                             | 1 +
     docs/manual/service_catalog/docs.md                      | 4 ++++
     service_catalog/admin.py                                 | 9 +++++++++
     service_catalog/filters/doc_filter.py                    | 4 +---
     service_catalog/tables/doc_tables.py                     | 5 +----
     service_catalog/views/doc.py                             | 3 +++
     .../service_catalog/custom_columns/doc_actions.html      | 3 ---
     .../service_catalog/custom_columns/doc_operations.html   | 3 ---
     .../service_catalog/custom_columns/doc_services.html     | 3 ---
     .../test_views/test_common/test_doc_view.py              | 5 ++++-
     10 files changed, 23 insertions(+), 17 deletions(-)
     delete mode 100644 templates/service_catalog/custom_columns/doc_actions.html
     delete mode 100644 templates/service_catalog/custom_columns/doc_operations.html
     delete mode 100644 templates/service_catalog/custom_columns/doc_services.html
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index 2a358596a..1bcee5a3d 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -11,6 +11,7 @@
     
     - List related docs in OperationDetails and ServiceDetails view
     - Add PROCESSING requests in the dashboard of the main page
    +- Display only docs that are not linked to services or operations in ListDoc
     
     ## Feature
     
    diff --git a/docs/manual/service_catalog/docs.md b/docs/manual/service_catalog/docs.md
    index 7461c9758..fa13b59d6 100644
    --- a/docs/manual/service_catalog/docs.md
    +++ b/docs/manual/service_catalog/docs.md
    @@ -4,6 +4,10 @@ Docs section allow administrators to create and link documentation to Squest ser
     
     Documentation are writen with Markdown syntax.
     
    +!!!note
    +
    +    Docs linked to a service or an operation are not listed in the global doc list from the sidebar menu.
    +
     ## Linked to services
     
     When linked to one or more service, the documentation is shown in each "instance detail" page that correspond to the type of selected services.
    diff --git a/service_catalog/admin.py b/service_catalog/admin.py
    index 478d7c3b8..a175321f9 100644
    --- a/service_catalog/admin.py
    +++ b/service_catalog/admin.py
    @@ -10,5 +10,14 @@ class DocAdmin(admin.ModelAdmin):
             models.TextField: {'widget': AdminMartorWidget},
         }
     
    +    list_filter = ['services', 'operations']
    +    list_display = ['title', 'linked_services', 'linked_operations']
    +
    +    def linked_services(self, obj):
    +        return ", ".join([str(service) for service in obj.services.all()])
    +
    +    def linked_operations(self, obj):
    +        return ", ".join([str(operation) for operation in obj.operations.all()])
    +
     
     admin.site.register(Doc, DocAdmin)
    diff --git a/service_catalog/filters/doc_filter.py b/service_catalog/filters/doc_filter.py
    index 940c468a9..282e7a3f7 100644
    --- a/service_catalog/filters/doc_filter.py
    +++ b/service_catalog/filters/doc_filter.py
    @@ -13,8 +13,6 @@ def __init__(self, *args, **kwargs):
     
     
     class DocFilter(SquestFilter):
    -    services = ServiceFilter(widget=SelectMultiple(attrs={'data-live-search': "true"}))
    -
         class Meta:
             model = Doc
    -        fields = ['title', 'services']
    +        fields = ['title']
    diff --git a/service_catalog/tables/doc_tables.py b/service_catalog/tables/doc_tables.py
    index 2368920a6..196a79c2f 100644
    --- a/service_catalog/tables/doc_tables.py
    +++ b/service_catalog/tables/doc_tables.py
    @@ -6,12 +6,9 @@
     
     
     class DocTable(SquestTable):
    -    actions = TemplateColumn(template_name='service_catalog/custom_columns/doc_actions.html', orderable=False)
    -    services = TemplateColumn(template_name='service_catalog/custom_columns/doc_services.html', verbose_name="Linked services")
    -    operations = TemplateColumn(template_name='service_catalog/custom_columns/doc_operations.html', verbose_name="Linked operations")
         title = LinkColumn("service_catalog:doc_details", args=[A("id")])
     
         class Meta:
             model = Doc
             attrs = {"id": "doc_table", "class": "table squest-pagination-tables"}
    -        fields = ("title", "services", "operations", "actions")
    +        fields = ("title",)
    diff --git a/service_catalog/views/doc.py b/service_catalog/views/doc.py
    index 9446e20e1..878ebd80b 100644
    --- a/service_catalog/views/doc.py
    +++ b/service_catalog/views/doc.py
    @@ -20,6 +20,9 @@ class DocListView(SquestListView):
         model = Doc
         filterset_class = DocFilter
     
    +    def get_queryset(self):
    +        return Doc.objects.filter(services__isnull=True, operations__isnull=True)
    +
         def get_context_data(self, **kwargs):
             context = super().get_context_data(**kwargs)
             context['html_button_path'] = ""
    diff --git a/templates/service_catalog/custom_columns/doc_actions.html b/templates/service_catalog/custom_columns/doc_actions.html
    deleted file mode 100644
    index 5afac3ee4..000000000
    --- a/templates/service_catalog/custom_columns/doc_actions.html
    +++ /dev/null
    @@ -1,3 +0,0 @@
    -
    -     Open
    -
    \ No newline at end of file
    diff --git a/templates/service_catalog/custom_columns/doc_operations.html b/templates/service_catalog/custom_columns/doc_operations.html
    deleted file mode 100644
    index a9c7496e8..000000000
    --- a/templates/service_catalog/custom_columns/doc_operations.html
    +++ /dev/null
    @@ -1,3 +0,0 @@
    -{% for operation in record.operations.all %}
    -    {{ operation.name }} ({{ operation.service.name }})
    -{% endfor %}
    diff --git a/templates/service_catalog/custom_columns/doc_services.html b/templates/service_catalog/custom_columns/doc_services.html
    deleted file mode 100644
    index d8c32b419..000000000
    --- a/templates/service_catalog/custom_columns/doc_services.html
    +++ /dev/null
    @@ -1,3 +0,0 @@
    -{% for service in record.services.all %}
    -    {{ service.name }}
    -{% endfor %}
    \ No newline at end of file
    diff --git a/tests/test_service_catalog/test_views/test_common/test_doc_view.py b/tests/test_service_catalog/test_views/test_common/test_doc_view.py
    index 23dfa0f9a..08e8d6cab 100644
    --- a/tests/test_service_catalog/test_views/test_common/test_doc_view.py
    +++ b/tests/test_service_catalog/test_views/test_common/test_doc_view.py
    @@ -11,7 +11,10 @@ def setUp(self):
             self.client.login(username=self.standard_user, password=self.common_password)
     
             self.new_doc = Doc.objects.create(title="test_doc", content="# tittle 1")
    -        self.new_doc.services.add(self.service_test)
    +        self.new_doc_on_service = Doc.objects.create(title="test_doc_service", content="Service")
    +        self.new_doc_on_service.services.add(self.service_test)
    +        self.new_doc_on_operation = Doc.objects.create(title="test_doc_operation", content="Operation")
    +        self.new_doc_on_operation.operations.add(self.create_operation_test_2)
     
         def _test_can_list_doc(self):
             url = reverse('service_catalog:doc_list')
    
    From a5aa708d37fc2758b5ffb82b2eaf41df0045bfec Mon Sep 17 00:00:00 2001
    From: elias-boulharts 
    Date: Fri, 26 Jan 2024 14:50:06 +0100
    Subject: [PATCH 12/17] Display validators in OperationDetail
    
    ---
     service_catalog/forms/operation_forms.py        | 2 +-
     service_catalog/models/operations.py            | 6 +++++-
     templates/service_catalog/operation_detail.html | 8 ++++++++
     3 files changed, 14 insertions(+), 2 deletions(-)
    
    diff --git a/service_catalog/forms/operation_forms.py b/service_catalog/forms/operation_forms.py
    index da3c15c51..ac7625535 100644
    --- a/service_catalog/forms/operation_forms.py
    +++ b/service_catalog/forms/operation_forms.py
    @@ -28,7 +28,7 @@ def __init__(self, *args, **kwargs):
             if self.instance is not None:
                 if self.instance.validators is not None:
                     # Converting comma separated string to python list
    -                instance_validator_as_list = self.instance.validators.split(",")
    +                instance_validator_as_list = self.instance.validators_name
                     # set the current value
                     self.initial["validators"] = instance_validator_as_list
     
    diff --git a/service_catalog/models/operations.py b/service_catalog/models/operations.py
    index cf14b889d..34ea04d97 100644
    --- a/service_catalog/models/operations.py
    +++ b/service_catalog/models/operations.py
    @@ -52,10 +52,14 @@ class Operation(SquestModel):
                                      help_text="Jinja supported. Job template type")
         validators = CharField(null=True, blank=True, max_length=200, verbose_name="Survey validators")
     
    +    @property
    +    def validators_name(self):
    +        return self.validators.split(",") if self.validators else None
    +
         def get_validators(self):
             validators = list()
             if self.validators is not None:
    -            all_validators = self.validators.split(",")
    +            all_validators = self.validators_name
                 all_validators.sort()
                 for validator_file in all_validators:
                     validator = PluginController.get_survey_validator_def(validator_file)
    diff --git a/templates/service_catalog/operation_detail.html b/templates/service_catalog/operation_detail.html
    index abf6ac268..b09f108da 100644
    --- a/templates/service_catalog/operation_detail.html
    +++ b/templates/service_catalog/operation_detail.html
    @@ -51,6 +51,14 @@ 

    Details


    {% endfor %} +
  • + Validators + {% for validator in object.validators_name %} + + {{ validator }} +
    + {% endfor %} +
  • From a0e918e870376e454a7f8327b3f5201083fd9baf Mon Sep 17 00:00:00 2001 From: Anthony Belhadj Date: Fri, 26 Jan 2024 12:57:34 +0100 Subject: [PATCH 13/17] Operation count in ServiceList redirect to ServiceDetail in #operations --- .../service_catalog/custom_columns/service_operations.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/service_catalog/custom_columns/service_operations.html b/templates/service_catalog/custom_columns/service_operations.html index 1a7c35038..b2181ecb0 100644 --- a/templates/service_catalog/custom_columns/service_operations.html +++ b/templates/service_catalog/custom_columns/service_operations.html @@ -1,3 +1,3 @@ - + {{ record.operations.count }} \ No newline at end of file From 40cae99fe2d1070d1195cb118ed316e8ff63c445 Mon Sep 17 00:00:00 2001 From: Anthony Belhadj Date: Mon, 29 Jan 2024 10:58:37 +0100 Subject: [PATCH 14/17] Add ID in InstanceList --- service_catalog/tables/instance_tables.py | 14 ++++++++++---- service_catalog/tables/request_tables.py | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/service_catalog/tables/instance_tables.py b/service_catalog/tables/instance_tables.py index bc7c4172b..7b8053120 100644 --- a/service_catalog/tables/instance_tables.py +++ b/service_catalog/tables/instance_tables.py @@ -1,4 +1,3 @@ - from django.utils.html import format_html from django_tables2 import TemplateColumn, LinkColumn, CheckBoxColumn, Column @@ -8,8 +7,10 @@ class InstanceTable(SquestTable): selection = CheckBoxColumn(accessor='pk', attrs={"th__input": {"onclick": "toggle(this)"}}) + id = LinkColumn() quota_scope__name = Column(verbose_name='Quota scope') - name = LinkColumn() + name = LinkColumn(verbose_name="Name") + service__name = Column(verbose_name="Service") date_available = TemplateColumn(template_name='generics/custom_columns/generic_date_format.html') last_updated = TemplateColumn(template_name='generics/custom_columns/generic_date_format.html') @@ -20,8 +21,13 @@ def before_render(self, request): class Meta: model = Instance attrs = {"id": "instance_table", "class": "table squest-pagination-tables"} - fields = ("selection", "name", "service__name", "quota_scope__name", "state", "requester", "date_available", "last_updated") + fields = ( + "selection", "id", "name", "service__name", "quota_scope__name", "state", "requester", "date_available", + "last_updated") def render_state(self, record, value): from service_catalog.views import map_instance_state - return format_html(f' { value } ') + return format_html(f' {value} ') + + def render_id(self, value, record): + return f"#{value}" diff --git a/service_catalog/tables/request_tables.py b/service_catalog/tables/request_tables.py index 5c0836426..e9c90df14 100644 --- a/service_catalog/tables/request_tables.py +++ b/service_catalog/tables/request_tables.py @@ -8,7 +8,8 @@ class RequestTable(SquestTable): selection = CheckBoxColumn(accessor='pk', attrs={"th__input": {"onclick": "toggle(this)"}}) - id = Column(linkify=True, verbose_name="Request") + id = Column(linkify=True, verbose_name="ID") + user__username = Column(verbose_name="User") date_submitted = TemplateColumn(template_name='generics/custom_columns/generic_date_format.html') instance = LinkColumn() last_updated = TemplateColumn(template_name='generics/custom_columns/generic_date_format.html') From c0e624ebbc8942ac2ce05fba6afb1411c64f8519 Mon Sep 17 00:00:00 2001 From: elias-boulharts Date: Thu, 8 Feb 2024 15:52:30 +0100 Subject: [PATCH 15/17] Add default field validators --- plugins/field_validators/is_json.py | 26 ++++++++++ plugins/field_validators/is_public_ssh_key.py | 49 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 plugins/field_validators/is_json.py create mode 100644 plugins/field_validators/is_public_ssh_key.py diff --git a/plugins/field_validators/is_json.py b/plugins/field_validators/is_json.py new file mode 100644 index 000000000..0ddaf80dd --- /dev/null +++ b/plugins/field_validators/is_json.py @@ -0,0 +1,26 @@ +import json + +# For testing +try: + from django.core.exceptions import ValidationError as UIValidationError + from rest_framework.serializers import ValidationError as APIValidationError +except ImportError: + pass + + +def is_json(json_str): + try: + json.loads(json_str) + except ValueError as e: + return False + return True + + +def validate_api(value): + if not is_json(value): + raise APIValidationError("is not JSON") + + +def validate_ui(value): + if not is_json(value): + raise UIValidationError("is not JSON") diff --git a/plugins/field_validators/is_public_ssh_key.py b/plugins/field_validators/is_public_ssh_key.py new file mode 100644 index 000000000..8fb2a61c2 --- /dev/null +++ b/plugins/field_validators/is_public_ssh_key.py @@ -0,0 +1,49 @@ +import base64 +import binascii +import struct + +# For testing +try: + from django.core.exceptions import ValidationError as UIValidationError + from rest_framework.serializers import ValidationError as APIValidationError +except ImportError: + pass + +ERROR_MESSAGE = "Is not a valid public ssh key" + +def is_public_ssh_key(ssh_key): + array = ssh_key.split() + # Each rsa-ssh key has 3 different strings in it, first one being + # type_of_key second one being keystring third one being username. + if len(array) not in [2, 3]: + return False + type_of_key = array[0] + ssh_key_str = array[1] + + # must have only valid rsa-ssh key characters ie binascii characters + try: + data = base64.decodebytes(bytes(ssh_key_str, 'utf-8')) + except binascii.Error: + return False + a = 4 + # unpack the contents of ssh_key, from ssh_key[:4] , it must be equal to 7 , property of ssh key . + try: + str_len = struct.unpack('>I', data[:a])[0] + except struct.error: + return False + # ssh_key[4:11] must have string which matches with the type_of_key , another ssh key property. + print(str_len) + if data[a:a + str_len].decode(encoding='utf-8') == type_of_key: + return True + else: + return False + + +def validate_api(value): + if not is_public_ssh_key(value): + raise APIValidationError(ERROR_MESSAGE) + + +def validate_ui(value): + if not is_public_ssh_key(value): + raise UIValidationError(ERROR_MESSAGE) From 6f8b73295c9cde56fead91dd368c1da8b187026a Mon Sep 17 00:00:00 2001 From: elias-boulharts Date: Thu, 8 Feb 2024 15:44:05 +0100 Subject: [PATCH 16/17] Add tests for provided validators --- .../test_provided_validators/__init__.py | 0 .../test_provided_validators/test_is_json.py | 73 +++++++++++++++++++ .../test_is_public_ssh_key.py | 43 +++++++++++ 3 files changed, 116 insertions(+) create mode 100644 tests/test_plugins/test_provided_validators/__init__.py create mode 100644 tests/test_plugins/test_provided_validators/test_is_json.py create mode 100644 tests/test_plugins/test_provided_validators/test_is_public_ssh_key.py diff --git a/tests/test_plugins/test_provided_validators/__init__.py b/tests/test_plugins/test_provided_validators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_plugins/test_provided_validators/test_is_json.py b/tests/test_plugins/test_provided_validators/test_is_json.py new file mode 100644 index 000000000..b9533c3da --- /dev/null +++ b/tests/test_plugins/test_provided_validators/test_is_json.py @@ -0,0 +1,73 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings +from rest_framework import serializers +from Squest.utils.plugin_controller import PluginController + + +class TestSSHPublicKeyValidator(TestCase): + def test_with_text(self): + json_text = "aaaabbbbcccc" + loaded_module = PluginController.get_ui_field_validator_def("is_json") + with self.assertRaises(ValidationError): + loaded_module(json_text) + loaded_module = PluginController.get_api_field_validator_def("is_json") + with self.assertRaises(serializers.ValidationError): + loaded_module(json_text) + + def test_with_empty_text(self): + json_text = "" + loaded_module = PluginController.get_ui_field_validator_def("is_json") + with self.assertRaises(ValidationError): + loaded_module(json_text) + loaded_module = PluginController.get_api_field_validator_def("is_json") + with self.assertRaises(serializers.ValidationError): + loaded_module(json_text) + + def test_with_double_json(self): + json_text = '{"my_json": "is_ok", other_json: {"in_side": 13}}{"outside": "is_not_ok"}' + loaded_module = PluginController.get_ui_field_validator_def("is_json") + with self.assertRaises(ValidationError): + loaded_module(json_text) + loaded_module = PluginController.get_api_field_validator_def("is_json") + with self.assertRaises(serializers.ValidationError): + loaded_module(json_text) + + def test_with_single_quote(self): + json_text = "{'my_json': 'is_ok', other_json: {'in_side': 13}}" + loaded_module = PluginController.get_ui_field_validator_def("is_json") + with self.assertRaises(ValidationError): + loaded_module(json_text) + loaded_module = PluginController.get_api_field_validator_def("is_json") + with self.assertRaises(serializers.ValidationError): + loaded_module(json_text) + + def test_with_missing_quote(self): + json_text = '{"my_json": "is_ok", other_json: {"in_side": 13}}' + loaded_module = PluginController.get_ui_field_validator_def("is_json") + with self.assertRaises(ValidationError): + loaded_module(json_text) + loaded_module = PluginController.get_api_field_validator_def("is_json") + with self.assertRaises(serializers.ValidationError): + loaded_module(json_text) + + def test_with_additional_comma(self): + json_text = '{"my_json": "is_ok", "other_json": {"in_side": 13},}' + loaded_module = PluginController.get_ui_field_validator_def("is_json") + with self.assertRaises(ValidationError): + loaded_module(json_text) + loaded_module = PluginController.get_api_field_validator_def("is_json") + with self.assertRaises(serializers.ValidationError): + loaded_module(json_text) + + def test_with_json(self): + json_text = '{"my_json": "is_ok", "other_json": {"in_side": 13}}' + loaded_module = PluginController.get_api_field_validator_def("is_json") + try: + loaded_module(json_text) + except ValidationError: + self.fail("UI validator fail") + loaded_module = PluginController.get_api_field_validator_def("is_json") + try: + loaded_module(json_text) + except serializers.ValidationError: + self.fail("API validator fail") diff --git a/tests/test_plugins/test_provided_validators/test_is_public_ssh_key.py b/tests/test_plugins/test_provided_validators/test_is_public_ssh_key.py new file mode 100644 index 000000000..825f93a0e --- /dev/null +++ b/tests/test_plugins/test_provided_validators/test_is_public_ssh_key.py @@ -0,0 +1,43 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings +from rest_framework import serializers +from Squest.utils.plugin_controller import PluginController + + +class TestSSHPublicKeyValidator(TestCase): + def test_with_wrong_ssh_key(self): + public_ssh_key = "aaaa bbbb cccc" + loaded_module = PluginController.get_ui_field_validator_def("is_public_ssh_key") + with self.assertRaises(ValidationError): + loaded_module(public_ssh_key) + loaded_module = PluginController.get_api_field_validator_def("is_public_ssh_key") + with self.assertRaises(serializers.ValidationError): + loaded_module(public_ssh_key) + + def test_with_rsa(self): + public_ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDeJvMJmt63nSi7sO6tKGHxnmvOckWaypcZKaPl5KLDCL5RsiaZMi/NrniiLsgv8FX5txQmsU2e1W7Ozh6VBGVErwV7S/GCkcYqO91lhZ+jVA7QHsK7hQWubq5zJzBm+p2HFSQk3ch0tXxhMbGivL1AkqziABgKLzhFeLIWzG3OCy8kj/F9b8doRiQ2FkMCT3sCGEts7nXQJ/0WyMj0FwyNr/R93+P/57M2QG49Wr7iFInzr6+BDcClmGMNHXNepvLQFHuW4suVU3Q7UaHFp9b1kDGbcbiw/w4JEutxuKv+DLOfm4pd8YhQsB8begILTwi5PJpvqfCSuE1jMN054t7xVssJU1Po5jMcSWfN8Q+/l7SSFgkCRH4Ul7LF+bMM8z3FejMp96CFmFagIePzqpnwjy4NLSEeESQhVCosPXpwCuwvZuebEgeptV8mK3TnVUksZmwnQrqwDqa6s9nZCjf/UZ03b73eFPcRwO0dgC2aWXvjzCB9SimdIBaMYMjtnuZJxADrb1AG9MTaBIj4bvn3phsx8OyZpc0c7mdyp1Quh5jBOwNJX7wU716o5lu5cMHTE29VFMIGz3yT+/ETxpC3/9CWuKLQoJFcV9PNzBScIIB2m7A+zkaEFbCDoCJqX8R5fs6Vsnq3vyEDu2VCGw3NUW+lQ+Di4y4+pAkhTaI4Mw== squest@fake" + loaded_module = PluginController.get_api_field_validator_def("is_public_ssh_key") + try: + loaded_module(public_ssh_key) + except ValidationError: + self.fail("UI validator fail") + loaded_module = PluginController.get_api_field_validator_def("is_public_ssh_key") + try: + loaded_module(public_ssh_key) + except serializers.ValidationError: + self.fail("API validator fail") + + def test_with_ed25519(self): + public_ssh_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPYqhDwOvBaWrGC257fdxfy1iMM6ZY2VwgmP+XlRRMT8 squest@fake" + loaded_module = PluginController.get_api_field_validator_def("is_public_ssh_key") + try: + loaded_module(public_ssh_key) + except ValidationError: + self.fail("UI validator fail") + loaded_module = PluginController.get_api_field_validator_def("is_public_ssh_key") + try: + loaded_module(public_ssh_key) + except serializers.ValidationError: + self.fail("API validator fail") + + From 62d1fa758ff4cdf702e6e34c8cebd9454ebbcf2a Mon Sep 17 00:00:00 2001 From: elias-boulharts Date: Thu, 8 Feb 2024 16:07:42 +0100 Subject: [PATCH 17/17] release v2.5.1 --- CHANGELOG.md | 3 ++- Squest/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bcee5a3d..da34a8acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# dev +# 2.5.1 2024-02-08 ## Fix @@ -16,6 +16,7 @@ ## Feature - ApprovalWorkflow preview. +- Provided field validators (json and public ssh key). # 2.5.0 2024-01-09 diff --git a/Squest/version.py b/Squest/version.py index 34db58545..037904b86 100644 --- a/Squest/version.py +++ b/Squest/version.py @@ -1,2 +1,2 @@ -__version__ = "2.5.1b" +__version__ = "2.5.1" VERSION = __version__ diff --git a/pyproject.toml b/pyproject.toml index 23780f7ee..f8994539c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "squest" -version = "2.5.1b" +version = "2.5.1" description = "Service catalog on top of Red Hat Ansible Automation Platform(RHAAP)/AWX (formerly known as Ansible Tower)" authors = ["Nicolas Marcq ", "Elias Boulharts "] license = "MIT"