diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ba93356a8..9d98de713a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 7e05020b6e..1914dfb579 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2215c9a806..21b2b31bda 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -44,6 +44,10 @@ v33.2.0 (unreleased) - Add URL scheme validation with explicit error messages for input URLs. https://github.com/nexB/scancode.io/issues/1047 +- All supported `output_format` can now be downloaded using the results_download API + action providing a value for the new `output_format` parameter. + https://github.com/nexB/scancode.io/issues/1091 + - Update matchcode-toolkit to v3.0.0 v33.1.0 (2024-02-02) diff --git a/docs/rest-api.rst b/docs/rest-api.rst index bf544c2b05..f256e6b41e 100644 --- a/docs/rest-api.rst +++ b/docs/rest-api.rst @@ -427,12 +427,19 @@ Displays the results as JSON content compatible with ScanCode data format. ] } -Results (download) +Results (Download) ^^^^^^^^^^^^^^^^^^ -Finally, this action downloads the JSON results as an attachment. +Finally, use this action to download the project results in the provided +``output_format`` as an attachment file. -``GET /api/projects/d4ed9405-5568-45ad-99f6-782a9b82d1d2/results_download/`` +Data: + - ``output_format``: ``json``, ``xlsx``, ``spdx``, ``cyclonedx``, ``attribution`` + +``GET /api/projects/d4ed9405-5568-45ad-99f6-782a9b82d1d2/results_download/?output_format=cyclonedx`` + +.. tip:: + Refer to :ref:`output_files` to learn more about the available output formats. Run details ----------- diff --git a/scanpipe/api/views.py b/scanpipe/api/views.py index f27c5ceb99..ed70c61c92 100644 --- a/scanpipe/api/views.py +++ b/scanpipe/api/views.py @@ -47,18 +47,12 @@ from scanpipe.models import Project from scanpipe.models import Run from scanpipe.models import RunInProgressError +from scanpipe.pipes import output from scanpipe.views import project_results_json_response scanpipe_app = apps.get_app_config("scanpipe") -class PassThroughRenderer(renderers.BaseRenderer): - media_type = "" - - def render(self, data, **kwargs): - return data - - class ProjectFilterSet(django_filters.rest_framework.FilterSet): name = django_filters.CharFilter() name__contains = django_filters.CharFilter( @@ -140,12 +134,32 @@ def results(self, request, *args, **kwargs): """ return project_results_json_response(self.get_object()) - @action( - detail=True, name="Results (download)", renderer_classes=[PassThroughRenderer] - ) + @action(detail=True, name="Results (download)") def results_download(self, request, *args, **kwargs): - """Return the results as an attachment.""" - return project_results_json_response(self.get_object(), as_attachment=True) + """Return the results in the provided `output_format` as an attachment.""" + project = self.get_object() + format = request.query_params.get("output_format", "json") + + if format == "json": + return project_results_json_response(project, as_attachment=True) + elif format == "xlsx": + output_file = output.to_xlsx(project) + elif format == "spdx": + output_file = output.to_spdx(project) + elif format == "cyclonedx": + output_file = output.to_cyclonedx(project) + elif format == "attribution": + output_file = output.to_attribution(project) + else: + message = {"status": f"Format {format} not supported."} + return Response(message, status=status.HTTP_400_BAD_REQUEST) + + filename = output.safe_filename(f"scancodeio_{project.name}_{output_file.name}") + return FileResponse( + output_file.open("rb"), + filename=filename, + as_attachment=True, + ) @action(detail=True) def summary(self, request, *args, **kwargs): diff --git a/scanpipe/tests/test_api.py b/scanpipe/tests/test_api.py index 859c490e4c..7e1da0c18c 100644 --- a/scanpipe/tests/test_api.py +++ b/scanpipe/tests/test_api.py @@ -53,6 +53,7 @@ from scanpipe.pipes.input import copy_input from scanpipe.pipes.output import JSONResultsGenerator from scanpipe.tests import dependency_data1 +from scanpipe.tests import mocked_now from scanpipe.tests import package_data1 @@ -524,6 +525,29 @@ def test_scanpipe_api_project_action_results_download(self): expected = ["dependencies", "files", "headers", "packages", "relations"] self.assertEqual(expected, sorted(results.keys())) + @mock.patch("scanpipe.pipes.datetime", mocked_now) + def test_scanpipe_api_project_action_results_download_output_formats(self): + url = reverse("project-results-download", args=[self.project1.uuid]) + data = {"output_format": "cyclonedx"} + response = self.csrf_client.get(url, data=data) + + expected_filename = "scancodeio_analysis_results-2010-10-10-10-10-10.cdx.json" + expected = f'attachment; filename="{expected_filename}"' + self.assertEqual(expected, response["Content-Disposition"]) + self.assertEqual("application/json", response["Content-Type"]) + + response_value = response.getvalue() + results = json.loads(response_value) + self.assertIn("$schema", sorted(results.keys())) + + data = {"output_format": "xlsx"} + response = self.csrf_client.get(url, data=data) + expected = [ + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/octet-stream", + ] + self.assertIn(response["Content-Type"], expected) + def test_scanpipe_api_project_action_pipelines(self): url = reverse("project-pipelines") response = self.csrf_client.get(url)