diff --git a/dje/forms.py b/dje/forms.py index 3be984d7..44cbf4f3 100644 --- a/dje/forms.py +++ b/dje/forms.py @@ -45,6 +45,7 @@ from dje.models import is_dataspace_related from dje.permissions import get_all_tabsets from dje.permissions import get_protected_fields +from dje.utils import clone_related_objects from dje.utils import get_help_text from dje.utils import has_permission @@ -210,21 +211,12 @@ def _clone_m2m(self, original_instance_id, cloned_instance): the `save_as_new` process. """ field_name = self.instance._meta.model_name + for model_class in self.clone_m2m_classes: if model_class.__name__ == "Subcomponent": field_name = "parent" - related_instances = model_class.objects.filter( - **{ - f"{field_name}__id": original_instance_id, - } - ) - - for relation in related_instances: - relation.id = None - relation.uuid = uuid.uuid4() - setattr(relation, field_name, cloned_instance) - relation.save() + clone_related_objects(model_class, field_name, original_instance_id, cloned_instance) @property def save_as_new_submit(self): diff --git a/dje/permissions.py b/dje/permissions.py index 2b9ed498..deb17ce7 100644 --- a/dje/permissions.py +++ b/dje/permissions.py @@ -7,7 +7,9 @@ # from guardian.shortcuts import assign_perm as guardian_assign_perm +from guardian.shortcuts import get_groups_with_perms from guardian.shortcuts import get_perms_for_model +from guardian.shortcuts import get_users_with_perms def get_protected_fields(model_class, user): @@ -36,6 +38,19 @@ def assign_all_object_permissions(user, obj): guardian_assign_perm(perm, user, obj) +def copy_object_permissions(source, target): + """Copy the user and group object permissions from `source` to `target`.""" + users_with_perms = get_users_with_perms(source, attach_perms=True) + for user, perms in users_with_perms.items(): + for perm in perms: + guardian_assign_perm(perm, user, target) + + groups_with_perms = get_groups_with_perms(source, attach_perms=True) + for group, perms in groups_with_perms.items(): + for perm in perms: + guardian_assign_perm(perm, group, target) + + def get_all_tabsets(): """Return available tabs from all subclasses of ObjectDetailsView.""" from dje.views import ObjectDetailsView diff --git a/dje/tests/test_permissions.py b/dje/tests/test_permissions.py index 82c14ca9..f5f017ca 100644 --- a/dje/tests/test_permissions.py +++ b/dje/tests/test_permissions.py @@ -10,8 +10,13 @@ from django.contrib.auth.models import Group from django.test import TestCase +from guardian.shortcuts import assign_perm +from guardian.shortcuts import get_group_perms +from guardian.shortcuts import get_user_perms + from dje.models import Dataspace from dje.models import DataspaceConfiguration +from dje.permissions import copy_object_permissions from dje.permissions import get_all_tabsets from dje.permissions import get_authorized_tabs from dje.permissions import get_protected_fields @@ -237,3 +242,26 @@ def test_permissions_get_authorized_tabs(self): # Superuser users see all the tabs self.assertIsNone(get_authorized_tabs(Owner, self.super_user)) + + def test_permissions_copy_object_permissions(self): + Product = apps.get_model("product_portfolio", "Product") + source = Product.objects.create(name="Source", dataspace=self.dataspace) + target = Product.objects.create(name="Target", dataspace=self.dataspace) + group1 = Group.objects.create(name="Group1") + + assign_perm("view_product", self.basic_user, source) + assign_perm("change_product", self.basic_user, source) + assign_perm("view_product", group1, source) + + self.assertEqual(set(), set(get_user_perms(self.basic_user, target))) + self.assertEqual(set(), set(get_group_perms(group1, target))) + + copy_object_permissions(source, target) + + expected_user_perms = {"view_product", "change_product"} + self.assertEqual(expected_user_perms, set(get_user_perms(self.basic_user, target))) + self.assertEqual({"view_product"}, set(get_group_perms(group1, target))) + + # The source keeps its own permissions untouched. + self.assertEqual(expected_user_perms, set(get_user_perms(self.basic_user, source))) + self.assertEqual({"view_product"}, set(get_group_perms(group1, source))) diff --git a/dje/tests/test_utils.py b/dje/tests/test_utils.py index 317c4208..ef65f979 100644 --- a/dje/tests/test_utils.py +++ b/dje/tests/test_utils.py @@ -25,6 +25,7 @@ from dejacode_toolkit.utils import sha1 from dje.copier import copy_object from dje.models import Dataspace +from dje.utils import clone_related_objects from dje.utils import database_re_escape from dje.utils import extract_name_version from dje.utils import get_duplicates @@ -54,6 +55,8 @@ License = apps.get_model("license_library", "license") ExternalReference = apps.get_model("dje", "ExternalReference") ExternalSource = apps.get_model("dje", "ExternalSource") +Product = apps.get_model("product_portfolio", "Product") +ProductComponent = apps.get_model("product_portfolio", "ProductComponent") class DJEUtilsTestCase(TestCase): @@ -227,6 +230,27 @@ def test_dje_utils_merge_relations(self): with self.assertRaises(AssertionError): merge_relations(original, alternate_owner) + def test_dje_utils_clone_related_objects(self): + nexb_dataspace = Dataspace.objects.create(name="nexB") + product1 = Product.objects.create(name="p1", dataspace=nexb_dataspace) + product2 = Product.objects.create(name="p2", dataspace=nexb_dataspace) + component1 = Component.objects.create(name="c1", dataspace=nexb_dataspace) + relation1 = ProductComponent.objects.create( + product=product1, component=component1, dataspace=nexb_dataspace + ) + + cloned_pairs = clone_related_objects(ProductComponent, "product", product1.id, product2) + + self.assertEqual(1, product1.productcomponents.count()) + self.assertEqual(relation1, product1.productcomponents.get()) + cloned_relation = product2.productcomponents.get() + self.assertNotEqual(relation1.pk, cloned_relation.pk) + self.assertNotEqual(relation1.uuid, cloned_relation.uuid) + self.assertEqual(component1, cloned_relation.component) + self.assertEqual(product2.dataspace, cloned_relation.dataspace) + + self.assertEqual([(relation1.pk, cloned_relation)], cloned_pairs) + def test_dje_utils_group_by_name_version(self): test_cases = [ { diff --git a/dje/utils.py b/dje/utils.py index 0d1d734b..3869d0e2 100644 --- a/dje/utils.py +++ b/dje/utils.py @@ -336,6 +336,27 @@ def get_model_class_from_path(path): return apps.get_model(app_name, model) +def clone_related_objects(model_class, fk_field_name, source_id, target_object, save_kwargs=None): + """ + Duplicate the `model_class` instances related to `source_id` onto `target_object`. + + Return a list of (original_pk, cloned_instance) pairs. + """ + related_objects = model_class.objects.filter(**{f"{fk_field_name}__id": source_id}) + save_kwargs = save_kwargs or {} + + cloned_pairs = [] + for relation in related_objects: + original_pk = relation.pk + relation.id = None + relation.uuid = uuid.uuid4() + setattr(relation, fk_field_name, target_object) + relation.save(**save_kwargs) + cloned_pairs.append((original_pk, relation)) + + return cloned_pairs + + def merge_relations(original, duplicate): """Move `original` object references (ManyToOneRel, GenericRelation) from `duplicate`.""" if original.__class__ != duplicate.__class__ or original.dataspace != duplicate.dataspace: diff --git a/product_portfolio/api.py b/product_portfolio/api.py index d6d8d75e..16343efe 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -44,6 +44,7 @@ from product_portfolio.forms import ImportFromScanForm from product_portfolio.forms import ImportManifestsForm from product_portfolio.forms import LoadSBOMsForm +from product_portfolio.forms import ProductCloneForm from product_portfolio.forms import PullProjectDataForm from product_portfolio.models import CodebaseResource from product_portfolio.models import Product @@ -407,6 +408,45 @@ class AssignTriageRulesetSerializer(serializers.Serializer): assigned = serializers.BooleanField() +class CanChangeProduct(permissions.BasePermission): + """Allows the action only if the user has the `change_product` object permission.""" + + def has_object_permission(self, request, view, obj): + return request.user.has_perm("product_portfolio.change_product", obj) + + +class ProductCloneSerializer(serializers.Serializer): + name = serializers.CharField( + required=True, + help_text=ProductCloneForm.base_fields["name"].help_text, + ) + version = serializers.CharField( + required=False, + allow_blank=True, + help_text=ProductCloneForm.base_fields["version"].help_text, + ) + copy_inventory = serializers.BooleanField( + required=False, + default=True, + help_text=str(ProductCloneForm.base_fields["copy_inventory"].label), + ) + copy_codebase_resources = serializers.BooleanField( + required=False, + default=True, + help_text=str(ProductCloneForm.base_fields["copy_codebase_resources"].label), + ) + copy_triage_rulesets = serializers.BooleanField( + required=False, + default=True, + help_text=str(ProductCloneForm.base_fields["copy_triage_rulesets"].label), + ) + copy_object_permissions = serializers.BooleanField( + required=False, + default=True, + help_text=str(ProductCloneForm.base_fields["copy_object_permissions"].label), + ) + + class ProductViewSet( ObjectPermissionsMixin, SendAboutFilesMixin, @@ -465,6 +505,43 @@ def perform_create(self, serializer): super().perform_create(serializer) assign_all_object_permissions(self.request.user, serializer.instance) + @action( + detail=True, + methods=["post"], + serializer_class=ProductCloneSerializer, + permission_classes=[ + permissions.IsAuthenticated, + permissions.DjangoModelPermissions, + CanChangeProduct, + ], + ) + def clone(self, request, *args, **kwargs): + """ + Clone this Product into a new one. + + Always copies the base Product fields under the given name/version. + Optionally copies the inventory (Components, Packages, and Dependencies), + Codebase resources, Vulnerability triage rules, and object Permissions, + depending on the submitted flags (all default to true). + """ + product = self.get_object() + + clone_serializer = ProductCloneSerializer(data=request.data) + if not clone_serializer.is_valid(): + return Response(clone_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + form = ProductCloneForm( + user=request.user, + source_product=product, + data=clone_serializer.validated_data, + ) + if not form.is_valid(): + return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) + + cloned_product = form.save() + serializer = ProductSerializer(cloned_product, context=self.get_serializer_context()) + return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True) def imports(self, request, uuid): """ diff --git a/product_portfolio/forms.py b/product_portfolio/forms.py index d1024a15..1a1462fe 100644 --- a/product_portfolio/forms.py +++ b/product_portfolio/forms.py @@ -7,6 +7,7 @@ # import json +import uuid from django import forms from django.conf import settings @@ -47,6 +48,9 @@ from dje.forms import autocomplete_placeholder from dje.mass_update import DejacodeMassUpdateForm from dje.models import History +from dje.permissions import assign_all_object_permissions +from dje.permissions import copy_object_permissions +from dje.utils import clone_related_objects from dje.widgets import AdminAwesompleteInputWidget from dje.widgets import AutocompleteInput from dje.widgets import AwesompleteInputWidget @@ -54,7 +58,10 @@ from product_portfolio.models import CodebaseResource from product_portfolio.models import Product from product_portfolio.models import ProductComponent +from product_portfolio.models import ProductComponentAssignedLicense +from product_portfolio.models import ProductDependency from product_portfolio.models import ProductPackage +from product_portfolio.models import ProductPackageAssignedLicense from product_portfolio.models import ScanCodeProject from product_portfolio.tasks import pull_project_data_from_scancodeio_task from product_portfolio.tasks import scancodeio_submit_project_task @@ -192,6 +199,137 @@ def helper(self): return helper +class ProductCloneForm(NameVersionValidationFormMixin, forms.ModelForm): + copy_inventory = forms.BooleanField( + label=_("Components, Packages, and Dependencies"), + required=False, + initial=True, + ) + copy_codebase_resources = forms.BooleanField( + label=_("Codebase resources"), + required=False, + initial=True, + ) + copy_triage_rulesets = forms.BooleanField( + label=_("Vulnerability triage rules"), + required=False, + initial=True, + ) + copy_object_permissions = forms.BooleanField( + label=_("Permissions (view, change, delete grants)"), + required=False, + initial=True, + ) + + # Relations to duplicate, grouped by the form field that enables them. + relations_by_field = { + "copy_inventory": [ProductComponent, ProductPackage, ProductDependency], + "copy_codebase_resources": [CodebaseResource], + "copy_triage_rulesets": [ProductTriageRuleset], + } + # Relations carrying their own concluded license expression (`licenses` m2m): cloned + # with `copy=True` to skip the expensive per-row license re-resolution, their existing + # license assignments are copied over directly instead through their AssignedLicense model. + assigned_license_models = { + ProductComponent: (ProductComponentAssignedLicense, "productcomponent"), + ProductPackage: (ProductPackageAssignedLicense, "productpackage"), + } + + class Meta: + model = Product + fields = ["name", "version"] + + def __init__(self, user, source_product, *args, **kwargs): + self.user = user + self.source_product = source_product + + cloned_instance = Product.unsecured_objects.get(pk=source_product.pk) + cloned_instance.pk = None + cloned_instance._state.adding = True + cloned_instance.uuid = uuid.uuid4() + if source_product.version: + cloned_instance.version = f"{source_product.version} (copy)"[:100] + else: + cloned_instance.version = "copy" + cloned_instance.created_by = user + cloned_instance.last_modified_by = user + if hasattr(cloned_instance, "request_count"): + cloned_instance.request_count = None + + kwargs["instance"] = cloned_instance + super().__init__(*args, **kwargs) + + @property + def helper(self): + helper = FormHelper() + helper.form_method = "post" + helper.form_id = "product-clone-form" + helper.attrs = {"autocomplete": "off"} + helper.layout = Layout( + Fieldset( + None, + Group("name", "version"), + HTML("
"), + HTML(f"

{_('Data to copy over to the clone:')}

"), + "copy_inventory", + "copy_codebase_resources", + "copy_triage_rulesets", + "copy_object_permissions", + HTML("
"), + HTML( + format_html( + '{}', + self.source_product.get_absolute_url(), + _("Back to product"), + _("Cancel"), + ) + ), + Submit("submit", _("Clone Product"), css_class="btn-success"), + ), + ) + return helper + + def _clone_relations(self, model_class, target_product): + """Clone `model_class` relations from the source product onto `target_product`.""" + assigned_license_model = self.assigned_license_models.get(model_class) + save_kwargs = {"copy": True} if assigned_license_model else None + + cloned_pairs = clone_related_objects( + model_class, "product", self.source_product.id, target_product, save_kwargs=save_kwargs + ) + + if not assigned_license_model: + return + + license_model, license_fk_name = assigned_license_model + for original_pk, cloned_relation in cloned_pairs: + clone_related_objects(license_model, license_fk_name, original_pk, cloned_relation) + + def save(self, commit=True): + from product_portfolio.importers import paused_product_package_reevaluation + from product_portfolio.importers import reevaluate_products + + instance = super().save(commit) + + History.log_addition(self.user, instance) + assign_all_object_permissions(self.user, instance) + + with paused_product_package_reevaluation(): + for field_name, model_classes in self.relations_by_field.items(): + if not self.cleaned_data.get(field_name): + continue + for model_class in model_classes: + self._clone_relations(model_class, instance) + + reevaluate_products([instance]) + + if self.cleaned_data.get("copy_object_permissions"): + copy_object_permissions(self.source_product, instance) + + return instance + + class ProductAdminForm( LicenseExpressionFormMixin, NameVersionValidationFormMixin, diff --git a/product_portfolio/models.py b/product_portfolio/models.py index fd4b8c0a..00c00009 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -498,6 +498,9 @@ def get_evaluate_policy_rules_url(self): def get_manage_triage_rulesets_url(self): return self.get_url("manage_triage_rulesets") + def get_clone_url(self): + return self.get_url("clone") + @property def cyclonedx_bom_ref(self): return str(self.uuid) diff --git a/product_portfolio/templates/product_portfolio/clone_product_form.html b/product_portfolio/templates/product_portfolio/clone_product_form.html new file mode 100644 index 00000000..bf8927bd --- /dev/null +++ b/product_portfolio/templates/product_portfolio/clone_product_form.html @@ -0,0 +1,53 @@ +{% extends "bootstrap_base.html" %} +{% load i18n static crispy_forms_tags %} +{% load inject_preserved_filters from dje_tags %} + +{% block page_title %}{% trans "Clone Product" %}{% endblock %} + +{% block content %} +
+
+
+
+
+ {% trans "Products" %} + / {{ object.get_absolute_link }} +
+

+ {% trans "Clone Product" %} +

+
+
+
+
+ +
+
+ {% include 'includes/messages_alert.html' %} + + + +
+
+ {% include 'includes/form_errors_alert.html' %} + {% crispy form %} +
+
+
+
+{% endblock %} + +{% block javascripts %} + +{% endblock %} diff --git a/product_portfolio/templates/product_portfolio/product_details.html b/product_portfolio/templates/product_portfolio/product_details.html index b093ccf8..918f4a72 100644 --- a/product_portfolio/templates/product_portfolio/product_details.html +++ b/product_portfolio/templates/product_portfolio/product_details.html @@ -2,6 +2,27 @@ {% load i18n static %} {% load inject_preserved_filters from dje_tags %} +{% block header_title %} + {{ object|truncatechars:100 }} + + {% if is_user_dataspace %} + {% if has_change_permission %} + {% with object.get_change_url as object_change_url %} + + {% endwith %} + {% endif %} + {% if has_change_permission and perms.product_portfolio.add_product %} + + {% endif %} + {% with object.get_delete_url as object_delete_url %} + {% if has_delete_permission and object_delete_url %} + + {% endif %} + {% endwith %} + {% endif %} + +{% endblock %} + {% block pager-toolbar %} {% if is_user_dataspace %} {% if product.is_locked %} diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index ffdd2cf5..612a635a 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -455,6 +455,58 @@ def test_api_product_endpoint_import_manifests_action(self): self.assertEqual(expected, response.data) self.assertEqual(1, ScanCodeProject.objects.count()) + def test_api_product_endpoint_clone_action(self): + url = reverse("api_v2:product-clone", args=[self.product1.uuid]) + make_product_package(self.product1) + make_product_triage_ruleset(self.product1) + + self.client.login(username=self.base_user.username, password="secret") + response = self.client.get(url) + self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, response.status_code) + response = self.client.post(url, data={}) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + + # The global `add_product` permission alone is not enough: object-level + # `change_product` on the source Product is also required. + add_perm(self.base_user, "add_product") + assign_perm("view_product", self.base_user, self.product1) + response = self.client.post(url, data={"name": self.product1.name, "version": "2.0"}) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + + assign_perm("change_product", self.base_user, self.product1) + + response = self.client.post(url, data={}) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + expected = {"name": ["This field is required."]} + self.assertEqual(expected, response.data) + + # The copy_* flags default to true when omitted from the payload + data = {"name": self.product1.name, "version": "2.0"} + response = self.client.post(url, data) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + cloned_product = Product.objects.get_queryset(self.base_user).get(version="2.0") + self.assertEqual(str(cloned_product.uuid), response.data["uuid"]) + self.assertEqual(1, cloned_product.productpackages.count()) + self.assertEqual(1, cloned_product.product_triage_rulesets.count()) + + # Submitting the same name/version again is rejected + response = self.client.post(url, data) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + # Explicitly opting out of a copy flag is honored + data = { + "name": self.product1.name, + "version": "3.0", + "copy_inventory": False, + "copy_triage_rulesets": False, + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + empty_clone = Product.objects.get_queryset(self.base_user).get(version="3.0") + self.assertEqual(0, empty_clone.productpackages.count()) + self.assertEqual(0, empty_clone.product_triage_rulesets.count()) + def test_api_product_endpoint_import_from_scan_action(self): url = reverse("api_v2:product-import-from-scan", args=[self.product1.uuid]) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 267b0f99..3d0e6f53 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -2059,6 +2059,102 @@ def test_product_portfolio_product_update_save_as_new(self): self.assertEqual(1, cloned_product.codebaseresources.count()) self.assertEqual(1, cloned_product.product_triage_rulesets.count()) + def test_product_portfolio_product_clone_view(self): + clone_url = self.product1.get_clone_url() + self.client.login(username=self.basic_user.username, password="secret") + + assign_perm("view_product", self.basic_user, self.product1) + response = self.client.get(clone_url) + self.assertEqual(403, response.status_code) + + assign_perm("change_product", self.basic_user, self.product1) + response = self.client.get(clone_url) + self.assertEqual(403, response.status_code) + + self.basic_user = add_perms(self.basic_user, ["add_product"]) + response = self.client.get(clone_url) + self.assertEqual(200, response.status_code) + self.assertEqual(self.product1.name, response.context["form"]["name"].value()) + self.assertEqual("1.0 (copy)", response.context["form"]["version"].value()) + + license1 = make_license(self.dataspace, key="license1") + ProductComponent.objects.create( + product=self.product1, + component=self.component1, + license_expression=license1.key, + dataspace=self.dataspace, + ) + ProductPackage.objects.create( + product=self.product1, + package=self.package1, + license_expression=license1.key, + dataspace=self.dataspace, + ) + CodebaseResource.objects.create( + path="/path1/", product=self.product1, dataspace=self.dataspace + ) + make_product_dependency(self.product1) + ruleset = TriageRuleset.objects.create( + name="Upgrade Ruleset", precedence=100, dataspace=self.dataspace + ) + ProductTriageRuleset.objects.create( + product=self.product1, ruleset=ruleset, dataspace=self.dataspace + ) + assign_perm("view_product", self.super_user, self.product1) + initial_product_count = Product.objects.get_queryset(self.basic_user).count() + + data = { + "name": self.product1.name, + "version": "2.0", + "copy_inventory": "on", + "copy_codebase_resources": "on", + "copy_triage_rulesets": "on", + "copy_object_permissions": "on", + } + response = self.client.post(clone_url, data, follow=True) + + new_count = Product.objects.get_queryset(self.basic_user).count() + self.assertEqual(new_count, initial_product_count + 1) + cloned_product = Product.objects.get_queryset(self.basic_user).get(version="2.0") + self.assertRedirects(response, cloned_product.get_absolute_url()) + self.assertContains(response, "was successfully cloned into") + + self.assertNotEqual(self.product1.id, cloned_product.id) + self.assertEqual(1, cloned_product.productcomponents.count()) + self.assertEqual(1, cloned_product.productpackages.count()) + self.assertEqual(1, cloned_product.codebaseresources.count()) + self.assertEqual(1, cloned_product.dependencies.count()) + self.assertEqual(1, cloned_product.product_triage_rulesets.count()) + self.assertIn("view_product", get_user_perms(self.super_user, cloned_product)) + + cloned_productcomponent = cloned_product.productcomponents.get() + self.assertEqual(license1.key, cloned_productcomponent.license_expression) + self.assertEqual([license1], list(cloned_productcomponent.licenses.all())) + cloned_productpackage = cloned_product.productpackages.get() + self.assertEqual(license1.key, cloned_productpackage.license_expression) + self.assertEqual([license1], list(cloned_productpackage.licenses.all())) + + # Submitting the same name/version again is rejected. + response = self.client.post(clone_url, data) + self.assertEqual(200, response.status_code) + expected = "Product with this Name and Version already exists." + self.assertContains(response, expected) + + # Unchecking every option only creates the base Product. + data["version"] = "3.0" + data["copy_inventory"] = "" + data["copy_codebase_resources"] = "" + data["copy_triage_rulesets"] = "" + data["copy_object_permissions"] = "" + response = self.client.post(clone_url, data, follow=True) + empty_clone = Product.objects.get_queryset(self.basic_user).get(version="3.0") + self.assertEqual(0, empty_clone.productcomponents.count()) + self.assertEqual(0, empty_clone.productpackages.count()) + self.assertEqual(0, empty_clone.codebaseresources.count()) + self.assertEqual(0, empty_clone.dependencies.count()) + self.assertEqual(0, empty_clone.product_triage_rulesets.count()) + self.assertNotIn("view_product", get_user_perms(self.super_user, empty_clone)) + def test_product_portfolio_product_delete_view(self): delete_url = self.product1.get_delete_url() details_url = self.product1.get_absolute_url() diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index 7cfa0675..40f99391 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -18,6 +18,7 @@ from product_portfolio.views import ManageComponentGridView from product_portfolio.views import ManagePackageGridView from product_portfolio.views import ProductAddView +from product_portfolio.views import ProductCloneView from product_portfolio.views import ProductDeleteView from product_portfolio.views import ProductDetailsView from product_portfolio.views import ProductExportCSAFDocumentView @@ -148,6 +149,7 @@ def product_path(path_segment, view): *product_path("export_license_compliance", ProductLicenseComplianceExportView.as_view()), *product_path("export_security_compliance", ProductSecurityComplianceExportView.as_view()), *product_path("attribution", AttributionView.as_view()), + *product_path("clone", ProductCloneView.as_view()), *product_path("change", ProductUpdateView.as_view()), *product_path("delete", ProductDeleteView.as_view()), *product_path("import_from_scan", import_from_scan_view), diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 86f86318..9b39e484 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -125,6 +125,7 @@ from product_portfolio.forms import ImportFromScanForm from product_portfolio.forms import ImportManifestsForm from product_portfolio.forms import LoadSBOMsForm +from product_portfolio.forms import ProductCloneForm from product_portfolio.forms import ProductComponentForm from product_portfolio.forms import ProductComponentInlineForm from product_portfolio.forms import ProductCustomComponentForm @@ -2793,6 +2794,28 @@ class ImportManifestsView(BaseProductImportFormView): success_msg = "Manifest file submitted to ScanCode.io for inspection." +class ProductCloneView(BaseProductImportFormView): + template_name = "product_portfolio/clone_product_form.html" + form_class = ProductCloneForm + permission_required = "product_portfolio.add_product" + + def get_form_kwargs(self): + form_kwargs = super().get_form_kwargs() + form_kwargs["user"] = self.request.user + form_kwargs["source_product"] = self.object + return form_kwargs + + def form_valid(self, form): + self.object = self.get_object() + cloned_product = form.save() + + messages.success( + self.request, + f'Product "{self.object}" was successfully cloned into "{cloned_product}".', + ) + return redirect(cloned_product) + + @method_decorator(require_POST, name="dispatch") class PullProjectDataFromScanCodeIOView(BaseProductImportFormView): form_class = PullProjectDataForm