diff --git a/assets/src/bundles/save/artifact-form-row.ejs b/assets/src/bundles/save/artifact-form-row.ejs new file mode 100644 --- /dev/null +++ b/assets/src/bundles/save/artifact-form-row.ejs @@ -0,0 +1,34 @@ +<%# + Copyright (C) 2021 The Software Heritage developers + See the AUTHORS file at the top-level directory of this distribution + License: GNU Affero General Public License version 3, or any later version + See top-level LICENSE file for more information +%> + +<div class="swh-save-origin-archives-form swh-save-origin-artifact-form form-row"> + <div class="form-group col-md-7"> + <label for="swh-input-artifact-url-<%= formId %>">Artifact url</label> + <input type="text" class="form-control" id="swh-input-artifact-url-<%= formId %>" required> + <div class="invalid-feedback">The artifact url is mandatory</div> + </div> + <div class="form-group col-md-2"> + <label for="swh-input-artifact-version-<%= formId %>">Artifact version</label> + <input type="text" class="form-control" id="swh-input-artifact-version-<%= formId %>" required> + <div class="invalid-feedback">The artifact version is mandatory</div> + </div> + <div class="col-md-2"> + <% if (deletableRow) { %> + <label for="swh-remove-archive-artifact-<%= formId %>"> </label> + <button id="swh-remove-archive-artifact-<%= formId %>" type="button" + class="btn btn-default btn-block" onclick="swh.save.deleteArtifactFormRow(event)"> + <i class="mdi mdi-file-remove mdi-fw" aria-hidden="true"></i>Remove artifact + </button> + <% } else { %> + <label for="swh-add-archive-artifact"> </label> + <button id="swh-add-archive-artifact" type="button" + class="btn btn-default btn-block" onclick="swh.save.addArtifactFormRow()"> + <i class="mdi mdi-file-plus mdi-fw" aria-hidden="true"></i>Add artifact + </button> + <% } %> + </div> +</div> \ No newline at end of file diff --git a/assets/src/bundles/save/index.js b/assets/src/bundles/save/index.js --- a/assets/src/bundles/save/index.js +++ b/assets/src/bundles/save/index.js @@ -7,6 +7,7 @@ import {csrfPost, handleFetchError, isGitRepoUrl, htmlAlert, removeUrlFragment} from 'utils/functions'; import {swhSpinnerSrc} from 'utils/constants'; +import artifactFormRowTemplate from './artifact-form-row.ejs'; let saveRequestsTable; @@ -49,19 +50,38 @@ // Read the actual selected value and depending on the origin type, display some extra // inputs or hide them. This makes the extra inputs disabled when not displayed. const originType = $('#swh-input-visit-type').val(); - let cssDisplay; - let disabled; + let display = 'none'; + let disabled = true; if (originType === 'archives') { + display = 'flex'; disabled = false; - cssDisplay = 'flex'; - } else { - cssDisplay = 'none'; - disabled = true; } - $('#optional-origin-forms').css('display', cssDisplay); - $('#swh-input-artifact-url').prop('disabled', disabled); - $('#swh-input-artifact-version').prop('disabled', disabled); + $('.swh-save-origin-archives-form').css('display', display); + if (!disabled) { + // help paragraph must have block display for proper rendering + $('#swh-save-origin-archives-help').css('display', 'block'); + } + $('.swh-save-origin-archives-form .form-control').prop('disabled', disabled); + + if (originType === 'archives' && $('.swh-save-origin-archives-form').length === 1) { + // insert first artifact row when the archives visit type is selected for the first time + $('.swh-save-origin-archives-form').last().after( + artifactFormRowTemplate({deletableRow: false, formId: 0})); + } +} + +export function addArtifactFormRow() { + $('.swh-save-origin-artifact-form').last().after( + artifactFormRowTemplate({ + deletableRow: true, + formId: $('.swh-save-origin-artifact-form').length + }) + ); +} + +export function deleteArtifactFormRow(event) { + $(event.target).closest('.swh-save-origin-artifact-form').remove(); } const userRequestsFilterCheckbox = ` @@ -254,10 +274,16 @@ let originUrl = $('#swh-input-origin-url').val(); // read the extra inputs for the 'archives' type - let extraData = originType !== 'archives' ? {} : { - 'artifact_url': $('#swh-input-artifact-url').val(), - 'artifact_version': $('#swh-input-artifact-version').val() - }; + let extraData = {}; + if (originType === 'archives') { + extraData['archives_data'] = []; + for (let i = 0; i < $('.swh-save-origin-artifact-form').length; ++i) { + extraData['archives_data'].push({ + 'artifact_url': $(`#swh-input-artifact-url-${i}`).val(), + 'artifact_version': $(`#swh-input-artifact-version-${i}`).val() + }); + } + } originSaveRequest(originType, originUrl, extraData, () => $('#swh-origin-save-request-status').html(saveRequestAcceptedAlert), diff --git a/cypress/integration/origin-save.spec.js b/cypress/integration/origin-save.spec.js --- a/cypress/integration/origin-save.spec.js +++ b/cypress/integration/origin-save.spec.js @@ -507,12 +507,12 @@ for (let visitType of anonymousVisitTypes) { cy.get('#swh-input-visit-type').select(visitType); - cy.get('#optional-origin-forms').should('not.be.visible'); + cy.get('.swh-save-origin-archives-form').should('not.be.visible'); } // this should display more inputs with the 'archives' type cy.get('#swh-input-visit-type').select('archives'); - cy.get('#optional-origin-forms').should('be.visible'); + cy.get('.swh-save-origin-archives-form').should('be.visible'); }); @@ -535,9 +535,9 @@ .type(originUrl) .get('#swh-input-visit-type') .select('archives') - .get('#swh-input-artifact-url') + .get('#swh-input-artifact-url-0') .type(artifactUrl) - .get('#swh-input-artifact-version') + .get('#swh-input-artifact-version-0') .type(artifactVersion) .get('#swh-save-origin-form') .submit(); @@ -548,4 +548,81 @@ }); + it('should submit multiple artifacts for the archives visit type', function() { + let originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; + let artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; + let artifactVersion = '1.1.4'; + let artifact2Url = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.5.tar.gz'; + let artifact2Version = '1.1.5'; + + cy.ambassadorLogin(); + cy.visit(url); + + cy.get('#swh-input-origin-url') + .type(originUrl) + .get('#swh-input-visit-type') + .select('archives'); + + // fill first artifact info + cy.get('#swh-input-artifact-url-0') + .type(artifactUrl) + .get('#swh-input-artifact-version-0') + .type(artifactVersion); + + // add new artifact form row + cy.get('#swh-add-archive-artifact') + .click(); + + // check new row is displayed + cy.get('#swh-input-artifact-url-1') + .should('exist'); + + // request removal of newly added row + cy.get('#swh-remove-archive-artifact-1') + .click(); + + // check row has been removed + cy.get('#swh-input-artifact-url-1') + .should('not.exist'); + + // add new artifact form row + cy.get('#swh-add-archive-artifact') + .click(); + + // fill second artifact info + cy.get('#swh-input-artifact-url-1') + .type(artifact2Url) + .get('#swh-input-artifact-version-1') + .type(artifact2Version); + + // setup request interceptor to check POST data and stub response + cy.intercept('POST', this.Urls.api_1_save_origin('archives', originUrl), (req) => { + expect(req.body).to.deep.equal({ + archives_data: [ + {artifact_url: artifactUrl, artifact_version: artifactVersion}, + {artifact_url: artifact2Url, artifact_version: artifact2Version} + ] + }); + req.reply(genOriginSaveResponse({ + visitType: 'archives', + saveRequestStatus: 'accepted', + originUrl: originUrl, + saveRequestDate: new Date(), + saveTaskStatus: 'not yet scheduled', + visitDate: null, + visitStatus: null + })); + }).as('saveRequest'); + + // submit form + cy.get('#swh-save-origin-form') + .submit(); + + // submission should be successful + cy.wait('@saveRequest').then(() => { + checkAlertVisible('success', saveCodeMsg['success']); + }); + + }); + }); diff --git a/swh/web/common/origin_save.py b/swh/web/common/origin_save.py --- a/swh/web/common/origin_save.py +++ b/swh/web/common/origin_save.py @@ -225,15 +225,11 @@ ) -def _check_origin_exists(origin_url: Optional[str]) -> OriginExistenceCheckInfo: - """Ensure the origin exists, if not raise an explicit message.""" - if not origin_url: - raise BadInputExc("The origin url provided must be set!") - metadata = origin_exists(origin_url) +def _check_origin_exists(url: str) -> OriginExistenceCheckInfo: + """Ensure an URL exists, if not raise an explicit message.""" + metadata = origin_exists(url) if not metadata["exists"]: - raise BadInputExc( - f"The provided origin url ({escape(origin_url)}) does not exist!" - ) + raise BadInputExc(f"The provided url ({escape(url)}) does not exist!") return metadata @@ -429,10 +425,6 @@ _check_visit_type_savable(visit_type, privileged_user) _check_origin_url_valid(origin_url) - artifact_url = kwargs.get("artifact_url") - if visit_type == "archives": - metadata = _check_origin_exists(artifact_url) - # if all checks passed so far, we can try and save the origin save_request_status = can_save_origin(origin_url, privileged_user) task = None @@ -447,18 +439,27 @@ } if visit_type == "archives": # extra arguments for that type are required - assert metadata is not None - task_kwargs = dict( - **task_kwargs, - artifacts=[ + archives_data = kwargs.get("archives_data", []) + if not archives_data: + raise BadInputExc( + "Artifacts data are missing for the archives visit type." + ) + artifacts = [] + for artifact in archives_data: + artifact_url = artifact.get("artifact_url") + artifact_version = artifact.get("artifact_version") + if not artifact_url or not artifact_version: + raise BadInputExc("Missing url or version for an artifact to load.") + metadata = _check_origin_exists(artifact_url) + artifacts.append( { "url": artifact_url, - "version": kwargs["artifact_version"], + "version": artifact_version, "time": metadata["last_modified"], "length": metadata["content_length"], } - ], - ) + ) + task_kwargs = dict(**task_kwargs, artifacts=artifacts, snapshot_append=True) sor = None # get list of previously sumitted save requests current_sors = list( diff --git a/swh/web/templates/misc/origin-save.html b/swh/web/templates/misc/origin-save.html --- a/swh/web/templates/misc/origin-save.html +++ b/swh/web/templates/misc/origin-save.html @@ -50,20 +50,16 @@ </div> </div> </div> - <!-- extra inputs for the 'archives' type, hidden by default --> - <div id="optional-origin-forms" class="form-row" style="display: none;"> - <div class="col-md-1">Artifact information:</div> - <div class="form-group col-md-6"> - <label for="swh-input-artifact-url">Url</label> - <input type="text" class="form-control" id="swh-input-artifact-url" required disabled> - <div class="invalid-feedback">The artifact url is mandatory</div> - </div> - <div class="form-group col-md-1"> - <label for="swh-input-artifact-version">Version</label> - <input type="text" class="form-control" id="swh-input-artifact-version" required disabled> - <div class="invalid-feedback">The artifact version is mandatory</div> - </div> - </div> + <!-- extra help text for the 'archives' type, hidden by default --> + <p id="swh-save-origin-archives-help" class="swh-save-origin-archives-form" style="display: none;"> + The <code>archives</code> visit type enables to save multiple source code archive files (*.tar.gz, *.zip) + under a same software origin. <br/> + For each archive file to save, the source code will then be available inside a branch named + <code>releases/<version></code> from the snapshot generated by Software Heritage.<br/> + All archive files previously saved under the software origin will always be available in + each snapshot generated by a new visit.<br> + Please use the form below to add such artifacts to save before submitting a request. + </p> </form> <div class="swh-processing-save-request text-center" style="display: none;"> <img src="{% static 'img/swh-spinner.gif' %}"> diff --git a/swh/web/tests/api/views/test_origin_save.py b/swh/web/tests/api/views/test_origin_save.py --- a/swh/web/tests/api/views/test_origin_save.py +++ b/swh/web/tests/api/views/test_origin_save.py @@ -507,7 +507,6 @@ }, ] - # then url = reverse( "api-1-save-origin", url_args={"visit_type": "archives", "origin_url": originUrl,}, @@ -517,7 +516,11 @@ api_client, url, status_code=200, - data={"artifact_url": artifact_url, "artifact_version": artifact_version,}, + data={ + "archives_data": [ + {"artifact_url": artifact_url, "artifact_version": artifact_version,} + ] + }, ) assert response.data["save_request_status"] == SAVE_REQUEST_ACCEPTED @@ -525,6 +528,33 @@ assert SaveAuthorizedOrigin.objects.get(url=originUrl) +def test_create_save_request_archives_missing_artifacts_data( + api_client, origin_to_review, keycloak_oidc, mocker, requests_mock, +): + + keycloak_oidc.realm_permissions = [SWH_AMBASSADOR_PERMISSION] + oidc_profile = keycloak_oidc.login() + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {oidc_profile['refresh_token']}") + + originUrl = "https://somewhere.org/simple" + + url = reverse( + "api-1-save-origin", + url_args={"visit_type": "archives", "origin_url": originUrl,}, + ) + + response = check_api_post_response(api_client, url, status_code=400, data={},) + assert "Artifacts data are missing" in response.data["reason"] + + response = check_api_post_response( + api_client, + url, + status_code=400, + data={"archives_data": [{"artifact_url": "", "arttifact_version": "1.0"}]}, + ) + assert "Missing url or version for an artifact to load" in response.data["reason"] + + def test_create_save_request_archives_accepted_ambassador_user( api_client, origin_to_review, keycloak_oidc, mocker ): diff --git a/swh/web/tests/common/test_origin_save.py b/swh/web/tests/common/test_origin_save.py --- a/swh/web/tests/common/test_origin_save.py +++ b/swh/web/tests/common/test_origin_save.py @@ -360,12 +360,6 @@ _check_origin_exists(url_ko) -@pytest.mark.parametrize("invalid_origin", [None, ""]) -def test__check_origin_invalid_input(invalid_origin): - with pytest.raises(BadInputExc, match="must be set"): - _check_origin_exists(invalid_origin) - - def test__check_origin_exists_200(requests_mock): url = "https://example.org/url" requests_mock.head(url, status_code=200)