diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d9f8df957..3ae8cc761 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,9 @@ v32.5.3 (unreleased) processed, if all the resources in their extracted directory is mapped/processed. https://github.com/nexB/scancode.io/issues/827 +- Add the ability to clone a project. + https://github.com/nexB/scancode.io/issues/874 + v32.5.2 (2023-08-14) -------------------- diff --git a/scanpipe/forms.py b/scanpipe/forms.py index 813eaea45..ac5b88d92 100644 --- a/scanpipe/forms.py +++ b/scanpipe/forms.py @@ -130,8 +130,7 @@ def __init__(self, *args, **kwargs): name_field.help_text = "The unique name of your project." def clean_name(self): - name = self.cleaned_data["name"] - return " ".join(name.split()) + return " ".join(self.cleaned_data["name"].split()) def save(self, *args, **kwargs): project = super().save(*args, **kwargs) @@ -282,3 +281,45 @@ def update_project_settings(self, project): } project.settings.update(config) project.save(update_fields=["settings"]) + + +class ProjectCloneForm(forms.Form): + clone_name = forms.CharField(widget=forms.TextInput(attrs={"class": "input"})) + copy_inputs = forms.BooleanField( + initial=True, + required=False, + help_text="Input files located in the input/ work directory will be copied.", + widget=forms.CheckboxInput(attrs={"class": "checkbox mr-1"}), + ) + copy_pipelines = forms.BooleanField( + initial=True, + required=False, + help_text="All pipelines assigned to the original project will be copied over.", + widget=forms.CheckboxInput(attrs={"class": "checkbox mr-1"}), + ) + copy_settings = forms.BooleanField( + initial=True, + required=False, + help_text="All project settings will be copied.", + widget=forms.CheckboxInput(attrs={"class": "checkbox mr-1"}), + ) + execute_now = forms.BooleanField( + label="Execute copied pipeline(s) now", + initial=False, + required=False, + help_text="Copied pipelines will be directly executed.", + ) + + def __init__(self, instance, *args, **kwargs): + self.project = instance + super().__init__(*args, **kwargs) + self.fields["clone_name"].initial = f"{self.project.name} clone" + + def clean_clone_name(self): + clone_name = self.cleaned_data.get("clone_name") + if Project.objects.filter(name=clone_name).exists(): + raise ValidationError("Project with this name already exists.") + return clone_name + + def save(self, *args, **kwargs): + return self.project.clone(**self.cleaned_data) diff --git a/scanpipe/models.py b/scanpipe/models.py index db5efb82d..d736b24bc 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -642,6 +642,31 @@ def reset(self, keep_input=True): self.setup_work_directory() + def clone( + self, + clone_name, + copy_inputs=False, + copy_pipelines=False, + copy_settings=False, + execute_now=False, + ): + """Clone this project using the provided ``clone_name`` as new project name.""" + cloned_project = Project.objects.create( + name=clone_name, + input_sources=self.input_sources if copy_inputs else {}, + settings=self.settings if copy_settings else {}, + ) + + if copy_inputs: + for input_location in self.inputs(): + cloned_project.copy_input_from(input_location) + + if copy_pipelines: + for run in self.runs.all(): + cloned_project.add_pipeline(run.pipeline_name, execute_now) + + return cloned_project + def _raise_if_run_in_progress(self): """ Raise a `RunInProgressError` exception if one of the project related run is diff --git a/scanpipe/templates/scanpipe/includes/clone_modal.html b/scanpipe/templates/scanpipe/includes/clone_modal.html new file mode 100644 index 000000000..7e24603f7 --- /dev/null +++ b/scanpipe/templates/scanpipe/includes/clone_modal.html @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/scanpipe/templates/scanpipe/includes/form_errors.html b/scanpipe/templates/scanpipe/includes/form_errors.html new file mode 100644 index 000000000..14686fc29 --- /dev/null +++ b/scanpipe/templates/scanpipe/includes/form_errors.html @@ -0,0 +1,5 @@ +
+ {% for field_name, errors in form.errors.items %} + {{ errors }} + {% endfor %} +
\ No newline at end of file diff --git a/scanpipe/templates/scanpipe/includes/project_clone_form.html b/scanpipe/templates/scanpipe/includes/project_clone_form.html new file mode 100644 index 000000000..39653f6e0 --- /dev/null +++ b/scanpipe/templates/scanpipe/includes/project_clone_form.html @@ -0,0 +1,36 @@ +{% include 'scanpipe/includes/form_errors.html' %} +
+ +
+ {{ form.clone_name }} +

{{ form.clone_name.help_text }}

+
+
+
+ +

{{ form.copy_inputs.help_text }}

+
+
+ +

{{ form.copy_pipelines.help_text }}

+
+
+ +

{{ form.copy_settings.help_text }}

+
+
+ +

{{ form.execute_now.help_text }}

+
\ No newline at end of file diff --git a/scanpipe/templates/scanpipe/project_detail.html b/scanpipe/templates/scanpipe/project_detail.html index 743e56b6d..5ffe230b3 100644 --- a/scanpipe/templates/scanpipe/project_detail.html +++ b/scanpipe/templates/scanpipe/project_detail.html @@ -34,11 +34,17 @@
- + Settings + New Project
@@ -136,6 +142,7 @@ {% include 'scanpipe/includes/run_modal.html' %} + {% include 'scanpipe/includes/clone_modal.html' %} {% endblock %} {% block scripts %} diff --git a/scanpipe/templates/scanpipe/project_form.html b/scanpipe/templates/scanpipe/project_form.html index 1732c9afd..5aa178c62 100644 --- a/scanpipe/templates/scanpipe/project_form.html +++ b/scanpipe/templates/scanpipe/project_form.html @@ -9,11 +9,7 @@

Create a Project

-
- {% for field_name, errors in form.errors.items %} - {{ errors }} - {% endfor %} -
+ {% include 'scanpipe/includes/form_errors.html' %}
diff --git a/scanpipe/templates/scanpipe/project_settings.html b/scanpipe/templates/scanpipe/project_settings.html index 868d72655..c799ef824 100644 --- a/scanpipe/templates/scanpipe/project_settings.html +++ b/scanpipe/templates/scanpipe/project_settings.html @@ -7,11 +7,7 @@
-
- {% for field_name, errors in form.errors.items %} - {{ errors }} - {% endfor %} -
+ {% include 'scanpipe/includes/form_errors.html' %}
diff --git a/scanpipe/tests/test_models.py b/scanpipe/tests/test_models.py index 34aa01fe3..251e3e58a 100644 --- a/scanpipe/tests/test_models.py +++ b/scanpipe/tests/test_models.py @@ -210,6 +210,44 @@ def test_scanpipe_project_model_reset(self): self.assertTrue(self.project1.codebase_path.exists()) self.assertTrue(self.project1.tmp_path.exists()) + def test_scanpipe_project_model_clone(self): + self.project1.add_input_source(filename="file1", source="uploaded") + self.project1.add_input_source(filename="file2", source="https://download.url") + self.project1.update(settings={"extract_recursively": True}) + new_file_path1 = self.project1.input_path / "file.zip" + new_file_path1.touch() + run1 = self.project1.add_pipeline("docker") + run2 = self.project1.add_pipeline("find_vulnerabilities") + + cloned_project = self.project1.clone("cloned project") + self.assertIsInstance(cloned_project, Project) + self.assertNotEqual(self.project1.pk, cloned_project.pk) + self.assertNotEqual(self.project1.slug, cloned_project.slug) + self.assertNotEqual(self.project1.work_directory, cloned_project.work_directory) + + self.assertEqual("cloned project", cloned_project.name) + self.assertEqual({}, cloned_project.settings) + self.assertEqual({}, cloned_project.input_sources) + self.assertEqual([], list(cloned_project.inputs())) + self.assertEqual([], list(cloned_project.runs.all())) + + cloned_project2 = self.project1.clone( + "cloned project full", + copy_inputs=True, + copy_pipelines=True, + copy_settings=True, + execute_now=False, + ) + self.assertEqual(self.project1.settings, cloned_project2.settings) + self.assertEqual(self.project1.input_sources, cloned_project2.input_sources) + self.assertEqual(1, len(list(cloned_project2.inputs()))) + runs = cloned_project2.runs.all() + self.assertEqual( + ["docker", "find_vulnerabilities"], [run.pipeline_name for run in runs] + ) + self.assertNotEqual(run1.pk, runs[0].pk) + self.assertNotEqual(run2.pk, runs[1].pk) + def test_scanpipe_project_model_input_sources_list_property(self): self.project1.add_input_source(filename="file1", source="uploaded") self.project1.add_input_source(filename="file2", source="https://download.url") diff --git a/scanpipe/urls.py b/scanpipe/urls.py index 004d9caf4..a9aecc11a 100644 --- a/scanpipe/urls.py +++ b/scanpipe/urls.py @@ -91,6 +91,11 @@ views.ProjectResetView.as_view(), name="project_reset", ), + path( + "project//clone/", + views.ProjectCloneView.as_view(), + name="project_clone", + ), path( "project//settings/", views.ProjectSettingsView.as_view(), diff --git a/scanpipe/views.py b/scanpipe/views.py index 695e7ff31..dec61d52b 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -39,6 +39,7 @@ from django.http import FileResponse from django.http import Http404 from django.http import HttpResponse +from django.http import HttpResponseRedirect from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.shortcuts import redirect @@ -68,6 +69,7 @@ from scanpipe.forms import AddInputsForm from scanpipe.forms import AddPipelineForm from scanpipe.forms import ArchiveProjectForm +from scanpipe.forms import ProjectCloneForm from scanpipe.forms import ProjectForm from scanpipe.forms import ProjectSettingsForm from scanpipe.models import CodebaseRelation @@ -442,6 +444,30 @@ def get(self, request, *args, **kwargs): return response +class FormAjaxMixin: + def is_xhr(self): + return self.request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" + + def form_valid(self, form): + response = super().form_valid(form) + + if self.is_xhr(): + return JsonResponse({"redirect_url": self.get_success_url()}, status=201) + + return response + + def form_invalid(self, form): + response = super().form_invalid(form) + + if self.is_xhr(): + return JsonResponse({"errors": str(form.errors)}, status=400) + + return response + + def get_success_url(self): + return self.object.get_absolute_url() + + class PaginatedFilterView(FilterView): """ Add a `url_params_without_page` value in the template context to include the @@ -518,7 +544,7 @@ def get_queryset(self): ) -class ProjectCreateView(ConditionalLoginRequired, generic.CreateView): +class ProjectCreateView(ConditionalLoginRequired, FormAjaxMixin, generic.CreateView): model = Project form_class = ProjectForm template_name = "scanpipe/project_form.html" @@ -531,28 +557,6 @@ def get_context_data(self, **kwargs): } return context - def is_xhr(self): - return self.request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" - - def form_valid(self, form): - response = super().form_valid(form) - - if self.is_xhr(): - return JsonResponse({"redirect_url": self.get_success_url()}, status=201) - - return response - - def form_invalid(self, form): - response = super().form_invalid(form) - - if self.is_xhr(): - return JsonResponse({"errors": str(form.errors)}, status=400) - - return response - - def get_success_url(self): - return self.object.get_absolute_url() - class ProjectDetailView(ConditionalLoginRequired, generic.DetailView): model = Project @@ -921,6 +925,24 @@ def form_valid(self, form): return redirect(project) +class HTTPResponseHXRedirect(HttpResponseRedirect): + status_code = 200 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self["HX-Redirect"] = self["Location"] + + +class ProjectCloneView(ConditionalLoginRequired, FormAjaxMixin, generic.UpdateView): + model = Project + form_class = ProjectCloneForm + template_name = "scanpipe/includes/project_clone_form.html" + + def form_valid(self, form): + super().form_valid(form) + return HTTPResponseHXRedirect(self.get_success_url()) + + @conditional_login_required def execute_pipeline_view(request, slug, run_uuid): project = get_object_or_404(Project, slug=slug)