Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Add clone method on Project model #822 #874

Merged
merged 5 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
--------------------

Expand Down
45 changes: 43 additions & 2 deletions scanpipe/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
25 changes: 25 additions & 0 deletions scanpipe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions scanpipe/templates/scanpipe/includes/clone_modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="modal" id="clone-modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Clone this project</p>
<button class="delete" aria-label="close"></button>
</header>
<form hx-post="{% url 'project_clone' project.slug %}" hx-target="#clone-modal-form-body">{% csrf_token %}
<section id="clone-modal-form-body" class="modal-card-body" hx-get="{% url 'project_clone' project.slug %}" hx-trigger="revealed">
Loading form...
</section>
<footer class="modal-card-foot is-flex is-justify-content-space-between">
<button class="button" type="reset">Cancel</button>
<button class="button is-no-close is-link" type="submit">Clone Project</button>
</footer>
</form>
</div>
</div>
5 changes: 5 additions & 0 deletions scanpipe/templates/scanpipe/includes/form_errors.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div id="form-errors" class="notification is-danger {% if not form.errors %}is-hidden{% endif %}">
{% for field_name, errors in form.errors.items %}
{{ errors }}
{% endfor %}
</div>
36 changes: 36 additions & 0 deletions scanpipe/templates/scanpipe/includes/project_clone_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% include 'scanpipe/includes/form_errors.html' %}
<div class="field">
<label class="label" for="{{ form.clone_name.id_for_label }}">{{ form.clone_name.label }}</label>
<div class="control">
{{ form.clone_name }}
<p class="help">{{ form.clone_name.help_text }}</p>
</div>
</div>
<div class="field">
<label class="checkbox" for="{{ form.copy_inputs.id_for_label }}">
{{ form.copy_inputs }}
{{ form.copy_inputs.label }}
</label>
<p class="help">{{ form.copy_inputs.help_text }}</p>
</div>
<div class="field">
<label class="checkbox" for="{{ form.copy_pipelines.id_for_label }}">
{{ form.copy_pipelines }}
{{ form.copy_pipelines.label }}
</label>
<p class="help">{{ form.copy_pipelines.help_text }}</p>
</div>
<div class="field">
<label class="checkbox" for="{{ form.copy_settings.id_for_label }}">
{{ form.copy_settings }}
{{ form.copy_settings.label }}
</label>
<p class="help">{{ form.copy_settings.help_text }}</p>
</div>
<div class="field">
<label class="checkbox" for="{{ form.execute_now.id_for_label }}-clone">
<input type="checkbox" name="{{ form.execute_now.name }}" id="{{ form.execute_now.id_for_label }}-clone">
{{ form.execute_now.label }}
</label>
<p class="help">{{ form.execute_now.help_text }}</p>
</div>
9 changes: 8 additions & 1 deletion scanpipe/templates/scanpipe/project_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,17 @@
</div>
<div>
<a href="{% url 'project_settings' project.slug %}" class="button is-smaller is-info is-outlined">
<span class="icon is-small">
<span class="icon width-1">
<i class="fa-solid fa-sliders-h"></i>
</span>
<span>Settings</span>
</a>
<button href="{% url 'project_add' %}" class="button is-smaller is-link is-outlined modal-button" data-target="clone-modal" aria-haspopup="true">
<span class="icon width-1">
<i class="fa-regular fa-clone"></i>
</span>
<span>Clone</span>
</button>
<a href="{% url 'project_add' %}" class="button is-smaller is-link">New Project</a>
</div>
</section>
Expand Down Expand Up @@ -136,6 +142,7 @@
</div>

{% include 'scanpipe/includes/run_modal.html' %}
{% include 'scanpipe/includes/clone_modal.html' %}
{% endblock %}

{% block scripts %}
Expand Down
6 changes: 1 addition & 5 deletions scanpipe/templates/scanpipe/project_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ <h2 class="subtitle mb-0 mb-4">
Create a <strong>Project</strong>
</h2>

<div id="form-errors" class="notification is-danger {% if not form.errors %}is-hidden{% endif %}">
{% for field_name, errors in form.errors.items %}
{{ errors }}
{% endfor %}
</div>
{% include 'scanpipe/includes/form_errors.html' %}

<div class="columns">
<div class="column is-7 pr-5 pb-0">
Expand Down
6 changes: 1 addition & 5 deletions scanpipe/templates/scanpipe/project_settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@
<hr class="mx-5 mt-0">

<section class="mx-5 mt-3 pb-1">
<div id="form-errors" class="notification is-danger {% if not form.errors %}is-hidden{% endif %}">
{% for field_name, errors in form.errors.items %}
{{ errors }}
{% endfor %}
</div>
{% include 'scanpipe/includes/form_errors.html' %}

<div class="columns">
<div class="column is-one-quarter">
Expand Down
38 changes: 38 additions & 0 deletions scanpipe/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions scanpipe/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@
views.ProjectResetView.as_view(),
name="project_reset",
),
path(
"project/<slug:slug>/clone/",
views.ProjectCloneView.as_view(),
name="project_clone",
),
path(
"project/<slug:slug>/settings/",
views.ProjectSettingsView.as_view(),
Expand Down
68 changes: 45 additions & 23 deletions scanpipe/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down