Skip to content

Commit 6128a4e

Browse files
authored
Add clone method on Project model #822 (#874)
Signed-off-by: Thomas Druez <tdruez@nexb.com>
1 parent 7c0b390 commit 6128a4e

12 files changed

Lines changed: 228 additions & 36 deletions

File tree

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ v32.5.3 (unreleased)
2121
processed, if all the resources in their extracted directory is mapped/processed.
2222
https://github.com/nexB/scancode.io/issues/827
2323

24+
- Add the ability to clone a project.
25+
https://github.com/nexB/scancode.io/issues/874
26+
2427
v32.5.2 (2023-08-14)
2528
--------------------
2629

scanpipe/forms.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,7 @@ def __init__(self, *args, **kwargs):
130130
name_field.help_text = "The unique name of your project."
131131

132132
def clean_name(self):
133-
name = self.cleaned_data["name"]
134-
return " ".join(name.split())
133+
return " ".join(self.cleaned_data["name"].split())
135134

136135
def save(self, *args, **kwargs):
137136
project = super().save(*args, **kwargs)
@@ -282,3 +281,45 @@ def update_project_settings(self, project):
282281
}
283282
project.settings.update(config)
284283
project.save(update_fields=["settings"])
284+
285+
286+
class ProjectCloneForm(forms.Form):
287+
clone_name = forms.CharField(widget=forms.TextInput(attrs={"class": "input"}))
288+
copy_inputs = forms.BooleanField(
289+
initial=True,
290+
required=False,
291+
help_text="Input files located in the input/ work directory will be copied.",
292+
widget=forms.CheckboxInput(attrs={"class": "checkbox mr-1"}),
293+
)
294+
copy_pipelines = forms.BooleanField(
295+
initial=True,
296+
required=False,
297+
help_text="All pipelines assigned to the original project will be copied over.",
298+
widget=forms.CheckboxInput(attrs={"class": "checkbox mr-1"}),
299+
)
300+
copy_settings = forms.BooleanField(
301+
initial=True,
302+
required=False,
303+
help_text="All project settings will be copied.",
304+
widget=forms.CheckboxInput(attrs={"class": "checkbox mr-1"}),
305+
)
306+
execute_now = forms.BooleanField(
307+
label="Execute copied pipeline(s) now",
308+
initial=False,
309+
required=False,
310+
help_text="Copied pipelines will be directly executed.",
311+
)
312+
313+
def __init__(self, instance, *args, **kwargs):
314+
self.project = instance
315+
super().__init__(*args, **kwargs)
316+
self.fields["clone_name"].initial = f"{self.project.name} clone"
317+
318+
def clean_clone_name(self):
319+
clone_name = self.cleaned_data.get("clone_name")
320+
if Project.objects.filter(name=clone_name).exists():
321+
raise ValidationError("Project with this name already exists.")
322+
return clone_name
323+
324+
def save(self, *args, **kwargs):
325+
return self.project.clone(**self.cleaned_data)

scanpipe/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,31 @@ def reset(self, keep_input=True):
642642

643643
self.setup_work_directory()
644644

645+
def clone(
646+
self,
647+
clone_name,
648+
copy_inputs=False,
649+
copy_pipelines=False,
650+
copy_settings=False,
651+
execute_now=False,
652+
):
653+
"""Clone this project using the provided ``clone_name`` as new project name."""
654+
cloned_project = Project.objects.create(
655+
name=clone_name,
656+
input_sources=self.input_sources if copy_inputs else {},
657+
settings=self.settings if copy_settings else {},
658+
)
659+
660+
if copy_inputs:
661+
for input_location in self.inputs():
662+
cloned_project.copy_input_from(input_location)
663+
664+
if copy_pipelines:
665+
for run in self.runs.all():
666+
cloned_project.add_pipeline(run.pipeline_name, execute_now)
667+
668+
return cloned_project
669+
645670
def _raise_if_run_in_progress(self):
646671
"""
647672
Raise a `RunInProgressError` exception if one of the project related run is
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<div class="modal" id="clone-modal">
2+
<div class="modal-background"></div>
3+
<div class="modal-card">
4+
<header class="modal-card-head">
5+
<p class="modal-card-title">Clone this project</p>
6+
<button class="delete" aria-label="close"></button>
7+
</header>
8+
<form hx-post="{% url 'project_clone' project.slug %}" hx-target="#clone-modal-form-body">{% csrf_token %}
9+
<section id="clone-modal-form-body" class="modal-card-body" hx-get="{% url 'project_clone' project.slug %}" hx-trigger="revealed">
10+
Loading form...
11+
</section>
12+
<footer class="modal-card-foot is-flex is-justify-content-space-between">
13+
<button class="button" type="reset">Cancel</button>
14+
<button class="button is-no-close is-link" type="submit">Clone Project</button>
15+
</footer>
16+
</form>
17+
</div>
18+
</div>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div id="form-errors" class="notification is-danger {% if not form.errors %}is-hidden{% endif %}">
2+
{% for field_name, errors in form.errors.items %}
3+
{{ errors }}
4+
{% endfor %}
5+
</div>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{% include 'scanpipe/includes/form_errors.html' %}
2+
<div class="field">
3+
<label class="label" for="{{ form.clone_name.id_for_label }}">{{ form.clone_name.label }}</label>
4+
<div class="control">
5+
{{ form.clone_name }}
6+
<p class="help">{{ form.clone_name.help_text }}</p>
7+
</div>
8+
</div>
9+
<div class="field">
10+
<label class="checkbox" for="{{ form.copy_inputs.id_for_label }}">
11+
{{ form.copy_inputs }}
12+
{{ form.copy_inputs.label }}
13+
</label>
14+
<p class="help">{{ form.copy_inputs.help_text }}</p>
15+
</div>
16+
<div class="field">
17+
<label class="checkbox" for="{{ form.copy_pipelines.id_for_label }}">
18+
{{ form.copy_pipelines }}
19+
{{ form.copy_pipelines.label }}
20+
</label>
21+
<p class="help">{{ form.copy_pipelines.help_text }}</p>
22+
</div>
23+
<div class="field">
24+
<label class="checkbox" for="{{ form.copy_settings.id_for_label }}">
25+
{{ form.copy_settings }}
26+
{{ form.copy_settings.label }}
27+
</label>
28+
<p class="help">{{ form.copy_settings.help_text }}</p>
29+
</div>
30+
<div class="field">
31+
<label class="checkbox" for="{{ form.execute_now.id_for_label }}-clone">
32+
<input type="checkbox" name="{{ form.execute_now.name }}" id="{{ form.execute_now.id_for_label }}-clone">
33+
{{ form.execute_now.label }}
34+
</label>
35+
<p class="help">{{ form.execute_now.help_text }}</p>
36+
</div>

scanpipe/templates/scanpipe/project_detail.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,17 @@
3434
</div>
3535
<div>
3636
<a href="{% url 'project_settings' project.slug %}" class="button is-smaller is-info is-outlined">
37-
<span class="icon is-small">
37+
<span class="icon width-1">
3838
<i class="fa-solid fa-sliders-h"></i>
3939
</span>
4040
<span>Settings</span>
4141
</a>
42+
<button href="{% url 'project_add' %}" class="button is-smaller is-link is-outlined modal-button" data-target="clone-modal" aria-haspopup="true">
43+
<span class="icon width-1">
44+
<i class="fa-regular fa-clone"></i>
45+
</span>
46+
<span>Clone</span>
47+
</button>
4248
<a href="{% url 'project_add' %}" class="button is-smaller is-link">New Project</a>
4349
</div>
4450
</section>
@@ -136,6 +142,7 @@
136142
</div>
137143

138144
{% include 'scanpipe/includes/run_modal.html' %}
145+
{% include 'scanpipe/includes/clone_modal.html' %}
139146
{% endblock %}
140147

141148
{% block scripts %}

scanpipe/templates/scanpipe/project_form.html

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@ <h2 class="subtitle mb-0 mb-4">
99
Create a <strong>Project</strong>
1010
</h2>
1111

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

1814
<div class="columns">
1915
<div class="column is-7 pr-5 pb-0">

scanpipe/templates/scanpipe/project_settings.html

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@
77
<hr class="mx-5 mt-0">
88

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

1612
<div class="columns">
1713
<div class="column is-one-quarter">

scanpipe/tests/test_models.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,44 @@ def test_scanpipe_project_model_reset(self):
210210
self.assertTrue(self.project1.codebase_path.exists())
211211
self.assertTrue(self.project1.tmp_path.exists())
212212

213+
def test_scanpipe_project_model_clone(self):
214+
self.project1.add_input_source(filename="file1", source="uploaded")
215+
self.project1.add_input_source(filename="file2", source="https://download.url")
216+
self.project1.update(settings={"extract_recursively": True})
217+
new_file_path1 = self.project1.input_path / "file.zip"
218+
new_file_path1.touch()
219+
run1 = self.project1.add_pipeline("docker")
220+
run2 = self.project1.add_pipeline("find_vulnerabilities")
221+
222+
cloned_project = self.project1.clone("cloned project")
223+
self.assertIsInstance(cloned_project, Project)
224+
self.assertNotEqual(self.project1.pk, cloned_project.pk)
225+
self.assertNotEqual(self.project1.slug, cloned_project.slug)
226+
self.assertNotEqual(self.project1.work_directory, cloned_project.work_directory)
227+
228+
self.assertEqual("cloned project", cloned_project.name)
229+
self.assertEqual({}, cloned_project.settings)
230+
self.assertEqual({}, cloned_project.input_sources)
231+
self.assertEqual([], list(cloned_project.inputs()))
232+
self.assertEqual([], list(cloned_project.runs.all()))
233+
234+
cloned_project2 = self.project1.clone(
235+
"cloned project full",
236+
copy_inputs=True,
237+
copy_pipelines=True,
238+
copy_settings=True,
239+
execute_now=False,
240+
)
241+
self.assertEqual(self.project1.settings, cloned_project2.settings)
242+
self.assertEqual(self.project1.input_sources, cloned_project2.input_sources)
243+
self.assertEqual(1, len(list(cloned_project2.inputs())))
244+
runs = cloned_project2.runs.all()
245+
self.assertEqual(
246+
["docker", "find_vulnerabilities"], [run.pipeline_name for run in runs]
247+
)
248+
self.assertNotEqual(run1.pk, runs[0].pk)
249+
self.assertNotEqual(run2.pk, runs[1].pk)
250+
213251
def test_scanpipe_project_model_input_sources_list_property(self):
214252
self.project1.add_input_source(filename="file1", source="uploaded")
215253
self.project1.add_input_source(filename="file2", source="https://download.url")

0 commit comments

Comments
 (0)