Skip to content

Commit cfb5726

Browse files
authored
Add replace_existing_version field on the AddToProduct form #12 (#124)
Signed-off-by: tdruez <[email protected]>
1 parent 68964a6 commit cfb5726

File tree

12 files changed

+342
-62
lines changed

12 files changed

+342
-62
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ Release notes
7878
- Enhance Package Import to support modifications.
7979
https://github.com/nexB/dejacode/issues/84
8080

81+
- Add an option on the "Add to Product" form to to replace any existing relationships
82+
with a different version of the same object by the selected object.
83+
https://github.com/nexB/dejacode/issues/12
84+
8185
### Version 5.0.1
8286

8387
- Improve the stability of the "Check for new Package versions" feature.

component_catalog/forms.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,17 @@ class AddToProductAdminForm(forms.Form):
646646
queryset=Product.objects.none(),
647647
)
648648
ids = forms.CharField(widget=forms.widgets.HiddenInput)
649+
replace_existing_version = forms.BooleanField(
650+
required=False,
651+
initial=False,
652+
label="Replace existing relationships by newer version.",
653+
help_text=(
654+
"Select this option to replace any existing relationships with a different version "
655+
"of the same object. "
656+
"If more than one version of the object is already assigned, no replacements will be "
657+
"made, and the new version will be added instead."
658+
),
659+
)
649660

650661
def __init__(self, request, model, relation_model, *args, **kwargs):
651662
super().__init__(*args, **kwargs)
@@ -663,9 +674,11 @@ def get_selected_objects(self):
663674

664675
def save(self):
665676
product = self.cleaned_data["product"]
677+
666678
return product.assign_objects(
667679
related_objects=self.get_selected_objects(),
668680
user=self.request.user,
681+
replace_version=self.cleaned_data["replace_existing_version"],
669682
)
670683

671684

@@ -719,15 +732,15 @@ def new_component_from_package_link(self):
719732
href = f"{component_add_url}?package_ids={package.id}"
720733

721734
return HTML(
735+
f"<hr>"
722736
f'<div class="text-center">'
723737
f' <a href="{href}" '
724738
f' id="new-component-link" '
725-
f' class="btn btn-success" '
739+
f' class="btn btn-outline-success" '
726740
f' data-add-url="{component_add_url}">'
727741
f" Add Component from Package data"
728742
f" </a>"
729743
f"</div>"
730-
f"<hr>"
731744
)
732745

733746
def clean_component(self):
@@ -749,9 +762,9 @@ def helper(self):
749762
helper.layout = Layout(
750763
Fieldset(
751764
None,
752-
self.new_component_from_package_link(),
753765
"object_id",
754766
"component",
767+
self.new_component_from_package_link(),
755768
),
756769
)
757770
return helper

component_catalog/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1915,12 +1915,16 @@ def get_export_cyclonedx_url(self):
19151915
return self.get_url("export_cyclonedx")
19161916

19171917
@classmethod
1918-
def get_identifier_fields(cls):
1918+
def get_identifier_fields(cls, *args, purl_fields_only=False, **kwargs):
19191919
"""
19201920
Explicit list of identifier fields as we do not enforce a unique together
19211921
on this model.
19221922
This is used in the Importer, to catch duplicate entries.
1923+
The purl_fields_only option can be use to limit the results.
19231924
"""
1925+
if purl_fields_only:
1926+
return PACKAGE_URL_FIELDS
1927+
19241928
return ["filename", "download_url", *PACKAGE_URL_FIELDS]
19251929

19261930
@property

component_catalog/templates/admin/component_catalog/add_to_product.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,22 @@ <h1>Add {% trans opts.verbose_name %} to a product</h1>
1919
The Product provides you with a group of {% trans opts.verbose_name_plural|title %} that are used together, so that
2020
you can generate Attribution documentation for all of the {% trans opts.verbose_name_plural|title %} in that Product.
2121
</p>
22-
<p style="margin-bottom: 10px;">
22+
<div style="margin-bottom: 10px;">
2323
{{ form.non_field_errors }}
2424
<label for="id_product">Product:</label>
2525
{{ form.product.errors }}
2626
{{ form.product }}
27+
<div style="margin-top:15px;margin-bottom:15px;">
28+
{{ form.replace_existing_version }}
29+
{{ form.replace_existing_version.label }}
30+
<p class="grp-help">{{ form.replace_existing_version.help_text }}</p>
31+
</div>
2732
{% if perms.product_portfolio.add_product %}
2833
<a href="{% url 'admin:product_portfolio_product_add' %}" class="add-another"></a>
2934
{% endif %}
3035
{{ form.ct }}
3136
{{ form.ids }}
32-
</p>
37+
</div>
3338
<p id="description" style="margin-bottom: 10px;"></p>
3439
<div class="grp-content-container g-d-c">
3540
<div class="grp-module">

component_catalog/templates/component_catalog/includes/add_to.js.html

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,35 @@
11
<script>
2-
$(document).ready(function () {
3-
let add_to_btn = $('#add-to-btn');
4-
let add_to_btn_wrapper = add_to_btn.parent();
2+
document.addEventListener('DOMContentLoaded', function () {
3+
let add_to_btn = document.getElementById('add-to-btn');
4+
let add_to_btn_wrapper = add_to_btn.parentElement;
55

66
let handle_button_display = function () {
7-
let count = $('main input[type="checkbox"]:checked').length;
8-
if (count >= 1) {
9-
add_to_btn.removeClass('disabled');
10-
add_to_btn_wrapper.attr('data-bs-original-title', '');
11-
}
12-
else {
13-
add_to_btn.addClass('disabled');
14-
add_to_btn_wrapper.attr('data-bs-original-title', 'Select objects first');
15-
}
7+
let checkedCheckboxes = document.querySelectorAll('main input[type="checkbox"]:checked');
8+
if (checkedCheckboxes.length >= 1) {
9+
add_to_btn.classList.remove('disabled');
10+
add_to_btn_wrapper.setAttribute('data-bs-original-title', '');
11+
} else {
12+
add_to_btn.classList.add('disabled');
13+
add_to_btn_wrapper.setAttribute('data-bs-original-title', 'Select objects first');
14+
}
1615
};
1716

18-
$('main input[type="checkbox"]').on('change', function() {
19-
handle_button_display();
17+
// Adding change event listener to all checkboxes
18+
document.querySelectorAll('main input[type="checkbox"]').forEach(function(checkbox) {
19+
checkbox.addEventListener('change', handle_button_display);
2020
});
2121

22-
// Runs on load to support the "back" button of the browser
22+
// Initial call to handle_button_display to set the correct state on page load
2323
handle_button_display();
2424

25+
// Call handle_button_display when the page is shown (e.g., when navigating back)
26+
window.addEventListener('pageshow', function () {
27+
handle_button_display();
28+
});
29+
2530
$('#add-to-product-modal, #add-to-component-modal').on('show.bs.modal', function (event) {
26-
let checked = $('main input[type="checkbox"]:checked');
31+
// Do not include the select-all as its value is not an id we want to keep
32+
let checked = $('main input[type="checkbox"]:checked').not('#checkbox-select-all');
2733

2834
if (checked.length < 1) {
2935
event.preventDefault();
@@ -52,5 +58,13 @@
5258
}
5359
});
5460

61+
// Select all forms with id starting with "add-to-"
62+
const forms = document.querySelectorAll('form[id^="add-to-"]');
63+
forms.forEach(function (form) {
64+
form.addEventListener('submit', function () {
65+
NEXB.displayOverlay("Adding objects...");
66+
});
67+
});
68+
5569
});
56-
</script>
70+
</script>

component_catalog/templates/component_catalog/includes/add_to_modal.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ <h5 class="modal-title">{{ form.helper.modal_title }}</h5>
88
</div>
99
<form autocomplete="off" method="{{ form.helper.form_method }}" action="{{ form.helper.form_action }}" id="{{ form.helper.form_id }}" class="{{ form.helper.form_class }}">
1010
<div class="modal-body bg-body-tertiary">
11+
{% crispy form %}
1112
{# Only displayed on list views #}
1213
{% if request.resolver_match.url_name|default:""|slice:"-4:" == "list" %}
14+
<hr>
1315
<h6>Selected objects:</h6>
1416
<ul id="object-repe-list" style="word-break: break-word;"></ul>
15-
<hr>
1617
{% endif %}
17-
{% crispy form %}
1818
</div>
1919
<div class="modal-footer">
2020
<input type="button" name="close" value="Close" class="btn btn-secondary" data-bs-dismiss="modal">

component_catalog/tests/test_models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from component_catalog.importers import ComponentImporter
2828
from component_catalog.importers import PackageImporter
29+
from component_catalog.models import PACKAGE_URL_FIELDS
2930
from component_catalog.models import Component
3031
from component_catalog.models import ComponentAssignedLicense
3132
from component_catalog.models import ComponentAssignedPackage
@@ -369,6 +370,8 @@ def test_component_catalog_models_get_identifier_fields(self):
369370
for model_class, expected in inputs:
370371
self.assertEqual(expected, model_class.get_identifier_fields())
371372

373+
self.assertEqual(PACKAGE_URL_FIELDS, Package.get_identifier_fields(purl_fields_only=True))
374+
372375
def test_component_model_get_absolute_url(self):
373376
c = Component(name="c1", version="1.0", dataspace=self.dataspace)
374377
self.assertEqual("/components/nexB/c1/1.0/", c.get_absolute_url())

component_catalog/views.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,17 @@ def get_form_kwargs(self):
217217
return kwargs
218218

219219
def form_valid(self, form):
220-
created_count, unchanged_count = form.save()
220+
created_count, updated_count, unchanged_count = form.save()
221221
product = form.cleaned_data["product"]
222222
opts = self.model._meta
223223

224+
msg = ""
224225
if created_count:
225-
msg = f'{created_count} {opts.model_name}(s) added to "{product}".'
226+
msg = f'{created_count} {opts.model_name}(s) added to "{product}". '
227+
if updated_count:
228+
msg += f'{updated_count} {opts.model_name}(s) updated on "{product}".'
229+
230+
if msg:
226231
messages.success(self.request, msg)
227232
else:
228233
msg = f"No new {opts.model_name}(s) were assigned to this product."
@@ -937,12 +942,16 @@ def get_form_kwargs(self):
937942
return kwargs
938943

939944
def form_valid(self, form):
940-
created_count, unchanged_count = form.save()
945+
created_count, updated_count, unchanged_count = form.save()
941946
model_name = self.model._meta.model_name
942947
product_name = form.cleaned_data["product"].name
948+
943949
msg = f"{created_count} {model_name}(s) added to {product_name}."
950+
if updated_count:
951+
msg += f" {updated_count} {model_name}(s) updated on {product_name}."
944952
if unchanged_count:
945953
msg += f" {unchanged_count} {model_name}(s) were already assigned."
954+
946955
messages.success(self.request, msg)
947956
return super().form_valid(form)
948957

dje/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,17 @@ def update_from_data(self, user, data, override=False):
773773

774774
return updated_fields
775775

776+
def update(self, **kwargs):
777+
"""
778+
Update this instance with the provided ``kwargs`` values.
779+
The full ``save()`` process will be triggered, including signals, and the
780+
``update_fields`` is automatically set.
781+
"""
782+
for field_name, value in kwargs.items():
783+
setattr(self, field_name, value)
784+
785+
self.save(update_fields=list(kwargs.keys()))
786+
776787
def as_json(self):
777788
try:
778789
serialized_data = serialize(
@@ -881,7 +892,7 @@ def _get_local_foreign_fields(self):
881892
local_foreign_fields = property(_get_local_foreign_fields)
882893

883894
@classmethod
884-
def get_identifier_fields(cls):
895+
def get_identifier_fields(cls, *args, **kwargs):
885896
"""
886897
Return a list of the fields, based on the Meta unique_together, to be
887898
used to match a unique instance within a Dataspace.

policy/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def str_with_content_type(self):
107107
return f"{self.label} ({self.content_type.model})"
108108

109109
@classmethod
110-
def get_identifier_fields(cls):
110+
def get_identifier_fields(cls, *args, **kwargs):
111111
"""Hack required by the Component import."""
112112
return ["label"]
113113

0 commit comments

Comments
 (0)