Skip to content

Commit

Permalink
Add the project "reset" feature in the UI, CLI, and REST API #375 (#377)
Browse files Browse the repository at this point in the history
* Add project reset feature in web UI #375

Signed-off-by: Thomas Druez <[email protected]>

* Add project reset feature in the REST API #375

Signed-off-by: Thomas Druez <[email protected]>

* Add reset-project CLI command #375

Signed-off-by: Thomas Druez <[email protected]>
  • Loading branch information
tdruez authored Dec 7, 2021
1 parent 23471cd commit a045184
Show file tree
Hide file tree
Showing 16 changed files with 228 additions and 9 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Changelog
Unreleased
----------

- Add the project "reset" feature in the UI, CLI, and REST API.
https:/nexB/scancode.io/issues/375

- Add a new GitHub action that build the docker-compose images and run the test suite.
This ensure that the app is properly working and tested when running with Docker.
https:/nexB/scancode.io/issues/367
Expand Down
11 changes: 11 additions & 0 deletions docs/command-line-interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,17 @@ Optional arguments:
- ``--no-input`` Does not prompt the user for input of any kind.


`$ scanpipe reset-project --project PROJECT`
--------------------------------------------

Resets a project removing all database entrie and all data on disks except for
the input/ directory.

Optional arguments:

- ``--no-input`` Does not prompt the user for input of any kind.


`$ scanpipe delete-project --project PROJECT`
---------------------------------------------

Expand Down
Binary file modified docs/images/user-interface-archive-action.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/user-interface-delete-action.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/user-interface-reset-action.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/user-interface-reset-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions docs/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ Data:
"status": "The project project_name has been archived."
}
Reset
^^^^^

This action will delete all related database entrie and all data on disks except for
the :guilabel:`input/` directory.

``POST /api/projects/6461408c-726c-4b70-aa7a-c9cc9d1c9685/reset/``

.. code-block:: json
{
"status": "All data, except inputs, for the project_name project have been removed."
}
Errors
^^^^^^

Expand Down
21 changes: 17 additions & 4 deletions docs/user-interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ the pipeline execution, or download the output files.
Please refer to the :ref:`output_files` page for more details about your
scan results.

Archiving a Project
-------------------
Archive a Project
-----------------

After a project is complete, you may want to archive it to prevent any further
modification to that project.
Expand All @@ -120,9 +120,22 @@ Data cleanup of the project's :guilabel:`input/`, :guilabel:`codebase/`, and
.. image:: images/user-interface-archive-modal.png
:width: 500

Reset a Project
---------------

Deleting a Project
------------------
The reset allows to wipe all database entrie and all data on disks related to a
project while keeping the input files.
It can be used to re-run pipelines on a clean slate of the project without having to
re-upload input files.

.. image:: images/user-interface-reset-action.png
:width: 300

.. image:: images/user-interface-reset-modal.png
:width: 500

Delete a Project
----------------

If any of your projects is no longer needed, you can delete it from the
project's details page. Deleting old projects also makes navigating existing
Expand Down
18 changes: 18 additions & 0 deletions scanpipe/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,24 @@ def archive(self, request, *args, **kwargs):
else:
return Response({"status": f"The project {project} has been archived."})

@action(detail=True, methods=["get", "post"])
def reset(self, request, *args, **kwargs):
project = self.get_object()

if self.request.method == "GET":
message = "POST on this URL to reset the project. " ""
return Response({"status": message})

try:
project.reset(keep_input=True)
except RunInProgressError as error:
return Response(error, status=status.HTTP_400_BAD_REQUEST)
else:
message = (
f"All data, except inputs, for the {project} project have been removed."
)
return Response({"status": message})


class RunViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""
Expand Down
64 changes: 64 additions & 0 deletions scanpipe/management/commands/reset-project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: Apache-2.0
#
# http://nexb.com and https:/nexB/scancode.io
# The ScanCode.io software is licensed under the Apache License version 2.0.
# Data generated with ScanCode.io is provided as-is without warranties.
# ScanCode is a trademark of nexB Inc.
#
# You may not use this software except in compliance with the License.
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software distributed
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
# specific language governing permissions and limitations under the License.
#
# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
# ScanCode.io should be considered or used as legal advice. Consult an Attorney
# for any legal advice.
#
# ScanCode.io is a free software code scanning tool from nexB Inc. and others.
# Visit https:/nexB/scancode.io for support and download.

import sys

from scanpipe.management.commands import ProjectCommand


class Command(ProjectCommand):
help = (
"Resets a project removing all database entrie and all data on disks "
"except for the input/ directory."
)

def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument(
"--no-input",
action="store_false",
dest="interactive",
help="Do not prompt the user for input of any kind.",
)

def handle(self, *inputs, **options):
super().handle(*inputs, **options)

if options["interactive"]:
confirm = input(
f"You have requested the reset of the {self.project} project.\n"
f"This will IRREVERSIBLY DESTROY all data, except inputs, related to "
f"that project. \n"
f"Are you sure you want to do this?\n"
f"Type 'yes' to continue, or 'no' to cancel: "
)
if confirm != "yes":
self.stdout.write("Reset cancelled.")
sys.exit(0)

self.project.reset(keep_input=True)

msg = (
f"All data, except inputs, for the {self.project} project have been "
f"removed."
)
self.stdout.write(self.style.SUCCESS(msg))
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
<i class="fas fa-dice-d6 mr-2"></i>Archive
</a>
{% endif %}
<a href="#" class="modal-button dropdown-item" data-target="modal-reset" aria-haspopup="true">
<i class="fas fa-eraser mr-2"></i>Reset
</a>
<a href="#" class="modal-button dropdown-item" data-target="modal-delete" aria-haspopup="true">
<i class="far fa-trash-alt mr-2"></i>Delete
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@
<p class="mb-5">
Are you sure you want to do this?
</p>
<form class="is-size-7 has-text-grey" action="{% url 'project_reset' project.uuid %}" method="post">{% csrf_token %}
Alternatively, you can "reset" the project data. This will delete all
<p>
Alternatively, you can <strong>"reset"</strong> the project data. This will delete all
related database entries and all data on disk except for the input/ directory.
<button class="button has-text-grey is-text is-no-close as-link p-0" type="submit">Reset Project</button>
</form>
</p>
</section>
<form action="{% url 'project_delete' project.uuid %}" method="post">{% csrf_token %}
<footer class="modal-card-foot is-flex is-justify-content-space-between">
Expand Down
26 changes: 26 additions & 0 deletions scanpipe/templates/scanpipe/includes/project_reset_modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<div class="modal" id="modal-reset">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Reset this project, are you sure?</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="notification is-danger has-text-weight-semibold">
This action cannot be undone.
</div>
<p class="mb-2">
This action will <strong>delete all related database entrie and all data on disks</strong> except for the input/ directory.
</p>
<p class="mb-5">
Are you sure you want to do this?
</p>
</section>
<form action="{% url 'project_reset' project.uuid %}" method="post">{% csrf_token %}
<footer class="modal-card-foot is-flex is-justify-content-space-between">
<button class="button has-text-weight-semibold" type="reset">No, Cancel</button>
<button class="button is-danger is-no-close" type="submit">Yes, Reset Project</button>
</footer>
</form>
</div>
</div>
3 changes: 2 additions & 1 deletion scanpipe/templates/scanpipe/project_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
{% include "scanpipe/includes/project_actions_dropdown.html" %}
</div>
</section>
{% include "scanpipe/includes/project_delete_modal.html" %}
{% if not project.is_archived %}
{% include "scanpipe/includes/project_archive_modal.html" %}
{% endif %}
{% include "scanpipe/includes/project_reset_modal.html" %}
{% include "scanpipe/includes/project_delete_modal.html" %}

<div class="container mx-5 mb-5">
<div class="field is-grouped is-grouped-multiline">
Expand Down
29 changes: 29 additions & 0 deletions scanpipe/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,35 @@ def test_scanpipe_api_project_action_archive(self):
self.assertEqual(0, len(Project.get_root_content(self.project1.input_path)))
self.assertEqual(1, len(Project.get_root_content(self.project1.codebase_path)))

def test_scanpipe_api_project_action_reset(self):
self.project1.add_pipeline("docker")
self.assertEqual(1, self.project1.runs.count())
self.assertEqual(1, self.project1.codebaseresources.count())
self.assertEqual(1, self.project1.discoveredpackages.count())

(self.project1.input_path / "input_file").touch()
(self.project1.codebase_path / "codebase_file").touch()
self.assertEqual(1, len(Project.get_root_content(self.project1.input_path)))
self.assertEqual(1, len(Project.get_root_content(self.project1.codebase_path)))

url = reverse("project-reset", args=[self.project1.uuid])
response = self.csrf_client.get(url)
self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertIn("POST on this URL to reset the project.", response.data["status"])

response = self.csrf_client.post(url)
self.assertEqual(status.HTTP_200_OK, response.status_code)
expected = {
"status": "All data, except inputs, for the Analysis project have been "
"removed."
}
self.assertEqual(expected, response.data)
self.assertEqual(0, self.project1.runs.count())
self.assertEqual(0, self.project1.codebaseresources.count())
self.assertEqual(0, self.project1.discoveredpackages.count())
self.assertEqual(1, len(Project.get_root_content(self.project1.input_path)))
self.assertEqual(0, len(Project.get_root_content(self.project1.codebase_path)))

@mock.patch("scanpipe.models.Run.execute_task_async")
def test_scanpipe_api_project_action_add_pipeline(self, mock_execute_pipeline_task):
url = reverse("project-add-pipeline", args=[self.project1.uuid])
Expand Down
38 changes: 38 additions & 0 deletions scanpipe/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@

from scanpipe.management.commands.graph import is_graphviz_installed
from scanpipe.management.commands.graph import pipeline_graph_dot
from scanpipe.models import CodebaseResource
from scanpipe.models import DiscoveredPackage
from scanpipe.models import Project
from scanpipe.models import Run

Expand Down Expand Up @@ -468,3 +470,39 @@ def test_scanpipe_management_command_archive_project(self):

self.assertEqual(1, len(Project.get_root_content(project.input_path)))
self.assertEqual(0, len(Project.get_root_content(project.codebase_path)))

def test_scanpipe_management_command_reset_project(self):
project = Project.objects.create(name="my_project")
project.add_pipeline("docker")
CodebaseResource.objects.create(project=project, path="filename.ext")
DiscoveredPackage.objects.create(project=project)

self.assertEqual(1, project.runs.count())
self.assertEqual(1, project.codebaseresources.count())
self.assertEqual(1, project.discoveredpackages.count())

(project.input_path / "input_file").touch()
(project.codebase_path / "codebase_file").touch()
self.assertEqual(1, len(Project.get_root_content(project.input_path)))
self.assertEqual(1, len(Project.get_root_content(project.codebase_path)))

out = StringIO()
options = [
"--project",
project.name,
"--no-color",
"--no-input",
]
call_command("reset-project", *options, stdout=out)
out_value = out.getvalue().strip()

expected = (
"All data, except inputs, for the my_project project have been removed."
)
self.assertEqual(expected, out_value)

self.assertEqual(0, project.runs.count())
self.assertEqual(0, project.codebaseresources.count())
self.assertEqual(0, project.discoveredpackages.count())
self.assertEqual(1, len(Project.get_root_content(project.input_path)))
self.assertEqual(0, len(Project.get_root_content(project.codebase_path)))

0 comments on commit a045184

Please sign in to comment.