diff --git a/assets/src/bundles/add_forge/index.js b/assets/src/bundles/add_forge/index.js index 2e92c2df..92696858 100644 --- a/assets/src/bundles/add_forge/index.js +++ b/assets/src/bundles/add_forge/index.js @@ -1,30 +1,30 @@ /** * Copyright (C) 2022 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 */ // bundle for add forge views export * from './add-forge.css'; export * from './create-request'; export * from './moderation-dashboard'; export * from './request-dashboard'; export function formatRequestStatusName(status) { // Mapping to format the request status to a human readable text const statusLabel = { 'PENDING': 'Pending', 'WAITING_FOR_FEEDBACK': 'Waiting for feedback', 'FEEDBACK_TO_HANDLE': 'Feedback to handle', 'ACCEPTED': 'Accepted', 'SCHEDULED': 'Scheduled', 'FIRST_LISTING_DONE': 'First listing done', 'FIRST_ORIGIN_LOADED': 'First origin loaded', 'REJECTED': 'Rejected', 'SUSPENDED': 'Suspended', - 'DENIED': 'Denied' + 'UNSUCCESSFUL': 'Unsuccessful' }; return status in statusLabel ? statusLabel[status] : status; } diff --git a/assets/src/bundles/add_forge/request-dashboard.js b/assets/src/bundles/add_forge/request-dashboard.js index fcb6219e..3fed8106 100644 --- a/assets/src/bundles/add_forge/request-dashboard.js +++ b/assets/src/bundles/add_forge/request-dashboard.js @@ -1,138 +1,138 @@ /** * Copyright (C) 2022 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 */ import {handleFetchError, csrfPost, getHumanReadableDate} from 'utils/functions'; import emailTempate from './forge-admin-email.ejs'; import requestHistoryItem from './add-request-history-item.ejs'; let forgeRequest; export function onRequestDashboardLoad(requestId) { $(document).ready(() => { populateRequestDetails(requestId); $('#contactForgeAdmin').click((event) => { contactForgeAdmin(event); }); $('#updateRequestForm').submit(async function(event) { event.preventDefault(); try { const response = await csrfPost($(this).attr('action'), {'Content-Type': 'application/x-www-form-urlencoded'}, $(this).serialize()); handleFetchError(response); $('#userMessage').text('The request status has been updated '); $('#userMessage').removeClass('badge-danger'); $('#userMessage').addClass('badge-success'); populateRequestDetails(requestId); } catch (response) { $('#userMessage').text('Sorry; Updating the request failed'); $('#userMessage').removeClass('badge-success'); $('#userMessage').addClass('badge-danger'); } }); }); } async function populateRequestDetails(requestId) { try { const response = await fetch(Urls.api_1_add_forge_request_get(requestId)); handleFetchError(response); const data = await response.json(); forgeRequest = data.request; $('#requestStatus').text(swh.add_forge.formatRequestStatusName(forgeRequest.status)); $('#requestType').text(forgeRequest.forge_type); $('#requestURL').text(forgeRequest.forge_url); $('#requestContactName').text(forgeRequest.forge_contact_name); $('#requestContactConsent').text(forgeRequest.submitter_forward_username); $('#requestContactEmail').text(forgeRequest.forge_contact_email); $('#submitterMessage').text(forgeRequest.forge_contact_comment); $('#updateComment').val(''); // Setting data for the email, now adding static data $('#contactForgeAdmin').attr('emailTo', forgeRequest.forge_contact_email); $('#contactForgeAdmin').attr('emailCc', forgeRequest.inbound_email_address); $('#contactForgeAdmin').attr('emailSubject', `Software Heritage archival notification for ${forgeRequest.forge_domain}`); populateRequestHistory(data.history); populateDecisionSelectOption(forgeRequest.status); } catch (e) { if (e instanceof Response) { // The fetch request failed (in handleFetchError), show the error message $('#fetchError').removeClass('d-none'); $('#requestDetails').addClass('d-none'); } else { // Unknown exception, pass it through throw e; } } } function populateRequestHistory(history) { $('#requestHistory').children().remove(); history.forEach((event, index) => { const historyEvent = requestHistoryItem({ 'event': event, 'index': index, 'getHumanReadableDate': getHumanReadableDate }); $('#requestHistory').append(historyEvent); }); } export function populateDecisionSelectOption(currentStatus) { const nextStatusesFor = { 'PENDING': ['WAITING_FOR_FEEDBACK', 'REJECTED', 'SUSPENDED'], 'WAITING_FOR_FEEDBACK': ['FEEDBACK_TO_HANDLE'], 'FEEDBACK_TO_HANDLE': [ 'WAITING_FOR_FEEDBACK', 'ACCEPTED', 'REJECTED', 'SUSPENDED' ], 'ACCEPTED': ['SCHEDULED'], 'SCHEDULED': [ 'FIRST_LISTING_DONE', 'FIRST_ORIGIN_LOADED' ], 'FIRST_LISTING_DONE': ['FIRST_ORIGIN_LOADED'], 'FIRST_ORIGIN_LOADED': [], 'REJECTED': [], 'SUSPENDED': ['PENDING'], - 'DENIED': [] + 'UNSUCCESSFUL': [] }; // Determine the possible next status out of the current one const nextStatuses = nextStatusesFor[currentStatus]; function addStatusOption(status, index) { // Push the next possible status options const label = swh.add_forge.formatRequestStatusName(status); $('#decisionOptions').append( `` ); } // Remove all the options and add new ones $('#decisionOptions').children().remove(); nextStatuses.forEach(addStatusOption); $('#decisionOptions').append( '' ); } function contactForgeAdmin(event) { // Open the mailclient with pre-filled text const mailTo = encodeURIComponent($('#contactForgeAdmin').attr('emailTo')); const mailCc = encodeURIComponent($('#contactForgeAdmin').attr('emailCc')); const subject = encodeURIComponent($('#contactForgeAdmin').attr('emailSubject')); const emailText = encodeURIComponent(emailTempate({'forgeUrl': forgeRequest.forge_url}).trim().replace(/\n/g, '\r\n')); const w = window.open('', '_blank', '', true); w.location.href = `mailto:${mailTo}?Cc=${mailCc}&Reply-To=${mailCc}&Subject=${subject}&body=${emailText}`; w.focus(); } diff --git a/swh/web/add_forge_now/migrations/0007_rename_denied_request_status.py b/swh/web/add_forge_now/migrations/0007_rename_denied_request_status.py new file mode 100644 index 00000000..fae6d12c --- /dev/null +++ b/swh/web/add_forge_now/migrations/0007_rename_denied_request_status.py @@ -0,0 +1,51 @@ +# Generated by Django 2.2.28 on 2022-08-16 14:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("swh_web_add_forge_now", "0006_request_add_new_fields"), + ] + + operations = [ + migrations.AlterField( + model_name="request", + name="status", + field=models.TextField( + choices=[ + ("PENDING", "Pending"), + ("WAITING_FOR_FEEDBACK", "Waiting for feedback"), + ("FEEDBACK_TO_HANDLE", "Feedback to handle"), + ("ACCEPTED", "Accepted"), + ("SCHEDULED", "Scheduled"), + ("FIRST_LISTING_DONE", "First listing done"), + ("FIRST_ORIGIN_LOADED", "First origin loaded"), + ("REJECTED", "Rejected"), + ("SUSPENDED", "Suspended"), + ("UNSUCCESSFUL", "Unsuccessful"), + ], + default="PENDING", + ), + ), + migrations.AlterField( + model_name="requesthistory", + name="new_status", + field=models.TextField( + choices=[ + ("PENDING", "Pending"), + ("WAITING_FOR_FEEDBACK", "Waiting for feedback"), + ("FEEDBACK_TO_HANDLE", "Feedback to handle"), + ("ACCEPTED", "Accepted"), + ("SCHEDULED", "Scheduled"), + ("FIRST_LISTING_DONE", "First listing done"), + ("FIRST_ORIGIN_LOADED", "First origin loaded"), + ("REJECTED", "Rejected"), + ("SUSPENDED", "Suspended"), + ("UNSUCCESSFUL", "Unsuccessful"), + ], + null=True, + ), + ), + ] diff --git a/swh/web/add_forge_now/models.py b/swh/web/add_forge_now/models.py index 86abbe1c..6d6f85b7 100644 --- a/swh/web/add_forge_now/models.py +++ b/swh/web/add_forge_now/models.py @@ -1,141 +1,142 @@ # Copyright (C) 2022 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 from __future__ import annotations import enum from typing import List from urllib.parse import urlparse from django.db import models from ..config import get_config from ..inbound_email.utils import get_address_for_pk from .apps import APP_LABEL class RequestStatus(enum.Enum): """Request statuses. Values are used in the ui. """ PENDING = "Pending" WAITING_FOR_FEEDBACK = "Waiting for feedback" FEEDBACK_TO_HANDLE = "Feedback to handle" ACCEPTED = "Accepted" SCHEDULED = "Scheduled" FIRST_LISTING_DONE = "First listing done" FIRST_ORIGIN_LOADED = "First origin loaded" REJECTED = "Rejected" SUSPENDED = "Suspended" - DENIED = "Denied" + UNSUCCESSFUL = "Unsuccessful" @classmethod def choices(cls): return tuple((variant.name, variant.value) for variant in cls) def allowed_next_statuses(self) -> List[RequestStatus]: next_statuses = { self.PENDING: [self.WAITING_FOR_FEEDBACK, self.REJECTED, self.SUSPENDED], self.WAITING_FOR_FEEDBACK: [self.FEEDBACK_TO_HANDLE], self.FEEDBACK_TO_HANDLE: [ self.WAITING_FOR_FEEDBACK, self.ACCEPTED, self.REJECTED, self.SUSPENDED, + self.UNSUCCESSFUL, ], self.ACCEPTED: [self.SCHEDULED], self.SCHEDULED: [ self.FIRST_LISTING_DONE, # in case of race condition between lister and loader: self.FIRST_ORIGIN_LOADED, ], self.FIRST_LISTING_DONE: [self.FIRST_ORIGIN_LOADED], self.FIRST_ORIGIN_LOADED: [], self.REJECTED: [], self.SUSPENDED: [self.PENDING], - self.DENIED: [], + self.UNSUCCESSFUL: [], } return next_statuses[self] # type: ignore class RequestActorRole(enum.Enum): MODERATOR = "moderator" SUBMITTER = "submitter" FORGE_ADMIN = "forge admin" EMAIL = "email" @classmethod def choices(cls): return tuple((variant.name, variant.value) for variant in cls) class RequestHistory(models.Model): """Comment or status change. This is commented or changed by either submitter or moderator. """ request = models.ForeignKey("Request", models.DO_NOTHING) text = models.TextField() actor = models.TextField() actor_role = models.TextField(choices=RequestActorRole.choices()) date = models.DateTimeField(auto_now_add=True) new_status = models.TextField(choices=RequestStatus.choices(), null=True) message_source = models.BinaryField(null=True) class Meta: app_label = APP_LABEL db_table = "add_forge_request_history" class Request(models.Model): status = models.TextField( choices=RequestStatus.choices(), default=RequestStatus.PENDING.name, ) submission_date = models.DateTimeField(auto_now_add=True) submitter_name = models.TextField() submitter_email = models.TextField() submitter_forward_username = models.BooleanField(default=False) # FIXME: shall we do create a user model inside the webapp instead? forge_type = models.TextField() forge_url = models.TextField() forge_contact_email = models.EmailField() forge_contact_name = models.TextField() forge_contact_comment = models.TextField( null=True, help_text="Where did you find this contact information (url, ...)", ) last_moderator = models.TextField(default="None") last_modified_date = models.DateTimeField(null=True) class Meta: app_label = APP_LABEL db_table = "add_forge_request" @property def inbound_email_address(self) -> str: """Generate an email address for correspondence related to this request.""" base_address = get_config()["add_forge_now"]["email_address"] return get_address_for_pk(salt=APP_LABEL, base_address=base_address, pk=self.pk) @property def forge_domain(self) -> str: """Get the domain/netloc out of the forge_url. Fallback to using the first part of the url path, if the netloc can't be found (for instance, if the url scheme hasn't been set). """ parsed_url = urlparse(self.forge_url) domain = parsed_url.netloc if not domain: domain = parsed_url.path.split("/", 1)[0] return domain diff --git a/swh/web/templates/add_forge_now/help.html b/swh/web/templates/add_forge_now/help.html index 650368f2..9b4d7092 100644 --- a/swh/web/templates/add_forge_now/help.html +++ b/swh/web/templates/add_forge_now/help.html @@ -1,89 +1,89 @@ {% extends "./common.html" %} {% comment %} Copyright (C) 2022 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 {% endcomment %} {% block tab_content %}

For submitting an "Add forge now" request", you have to provide the following details:

Once submitted, your "add forge" request can be in one of the following states

{% endblock %} diff --git a/swh/web/tests/add_forge_now/test_migration.py b/swh/web/tests/add_forge_now/test_migration.py index f0b0fafc..df692242 100644 --- a/swh/web/tests/add_forge_now/test_migration.py +++ b/swh/web/tests/add_forge_now/test_migration.py @@ -1,168 +1,213 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from datetime import datetime, timezone import pytest +from django.core.exceptions import ValidationError + from swh.web.add_forge_now.apps import APP_LABEL MIGRATION_0001 = "0001_initial" MIGRATION_0002 = "0002_authorized_null_comment" MIGRATION_0003 = "0003_request_submitter_forward_username" MIGRATION_0005 = "0005_prepare_inbound_email" MIGRATION_0006 = "0006_request_add_new_fields" +MIGRATION_0007 = "0007_rename_denied_request_status" def now() -> datetime: return datetime.now(tz=timezone.utc) def test_add_forge_now_initial_migration(migrator): """Basic migration test to check the model is fine""" state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0001)) request = state.apps.get_model(APP_LABEL, "Request") request_history = state.apps.get_model(APP_LABEL, "RequestHistory") from swh.web.add_forge_now.models import RequestActorRole, RequestStatus date_now = now() req = request( status=RequestStatus.PENDING, submitter_name="dudess", submitter_email="dudess@orga.org", forge_type="cgit", forge_url="https://example.org/forge", forge_contact_email="forge@//example.org", forge_contact_name="forge", forge_contact_comment=( "Discovered on the main forge homepag, following contact link." ), ) req.save() assert req.submission_date > date_now req_history = request_history( request=req, text="some comment from the moderator", actor="moderator", actor_role=RequestActorRole.MODERATOR, new_status=None, ) req_history.save() assert req_history.date > req.submission_date req_history2 = request_history( request=req, text="some answer from the user", actor="user", actor_role=RequestActorRole.SUBMITTER, new_status=None, ) req_history2.save() assert req_history2.date > req_history.date def test_add_forge_now_allow_no_comment(migrator): """Basic migration test to check new model authorized empty comment""" from django.db.utils import IntegrityError state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0001)) def make_request_with_empty_comment(requestModel): return requestModel( status="PENDING", submitter_name="dudess", submitter_email="dudess@orga.org", forge_type="cgit", forge_url="https://example.org/forge", forge_contact_email="forge@//example.org", forge_contact_name="forge", forge_contact_comment=None, ) requestModel = state.apps.get_model(APP_LABEL, "Request") req = make_request_with_empty_comment(requestModel) with pytest.raises(IntegrityError, match="violates not-null constraint"): req.save() state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0002)) requestModel2 = state.apps.get_model(APP_LABEL, "Request") req2 = make_request_with_empty_comment(requestModel2) req2.save() def test_add_forge_now_store_submitter_forward_username(migrator): state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0002)) requestModel = state.apps.get_model(APP_LABEL, "Request") assert not hasattr(requestModel, "submitter_forward_username") state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0003)) requestModel2 = state.apps.get_model(APP_LABEL, "Request") assert hasattr(requestModel2, "submitter_forward_username") def test_add_forge_now_add_new_fields_to_request(migrator): state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0005)) Request = state.apps.get_model(APP_LABEL, "Request") RequestHistory = state.apps.get_model(APP_LABEL, "RequestHistory") assert not hasattr(Request, "last_moderator") assert not hasattr(Request, "last_modified_date") from swh.web.add_forge_now.models import RequestActorRole, RequestStatus req = Request( status=RequestStatus.PENDING, submitter_name="dudess", submitter_email="dudess@orga.org", forge_type="cgit", forge_url="https://example.org/forge", forge_contact_email="forge@//example.org", forge_contact_name="forge", forge_contact_comment=( "Discovered on the main forge homepag, following contact link." ), ) req.save() req_history = RequestHistory( request=req, text="some comment from the submitter", actor="submitter", actor_role=RequestActorRole.SUBMITTER.name, new_status=None, ) req_history.save() req_history = RequestHistory( request=req, text="some comment from the moderator", actor="moderator", actor_role=RequestActorRole.MODERATOR.name, new_status=None, ) req_history.save() state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0006)) Request = state.apps.get_model(APP_LABEL, "Request") RequestHistory = state.apps.get_model(APP_LABEL, "RequestHistory") assert hasattr(Request, "last_moderator") assert hasattr(Request, "last_modified_date") for request in Request.objects.all(): history = RequestHistory.objects.filter(request=request) history = history.order_by("id") assert request.last_modified_date == history.last().date assert request.last_moderator == history.last().actor + + +def test_add_forge_now_denied_status_renamed_to_unsuccesful(migrator): + + state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0006)) + Request = state.apps.get_model(APP_LABEL, "Request") + + from swh.web.add_forge_now.models import RequestStatus + + req = Request( + status=RequestStatus.UNSUCCESSFUL.name, + submitter_name="dudess", + submitter_email="dudess@orga.org", + forge_type="cgit", + forge_url="https://example.org/forge", + forge_contact_email="forge@example.org", + forge_contact_name="forge", + forge_contact_comment=( + "Discovered on the main forge homepag, following contact link." + ), + last_modified_date=datetime.now(timezone.utc), + ) + with pytest.raises(ValidationError): + req.clean_fields() + + state = migrator.apply_tested_migration((APP_LABEL, MIGRATION_0007)) + Request = state.apps.get_model(APP_LABEL, "Request") + + req = Request( + status=RequestStatus.UNSUCCESSFUL.name, + submitter_name="dudess", + submitter_email="dudess@orga.org", + forge_type="cgit", + forge_url="https://example.org/forge", + forge_contact_email="forge@example.org", + forge_contact_name="forge", + forge_contact_comment=( + "Discovered on the main forge homepag, following contact link." + ), + last_modified_date=datetime.now(timezone.utc), + ) + req.clean_fields() diff --git a/swh/web/tests/add_forge_now/test_models.py b/swh/web/tests/add_forge_now/test_models.py index e9e617e2..e124707a 100644 --- a/swh/web/tests/add_forge_now/test_models.py +++ b/swh/web/tests/add_forge_now/test_models.py @@ -1,38 +1,48 @@ # Copyright (C) 2022 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 import pytest from swh.web.add_forge_now.models import Request, RequestStatus @pytest.mark.parametrize( "current_status, allowed_next_statuses", [ ( RequestStatus.PENDING, [ RequestStatus.WAITING_FOR_FEEDBACK, RequestStatus.REJECTED, RequestStatus.SUSPENDED, ], ), (RequestStatus.WAITING_FOR_FEEDBACK, [RequestStatus.FEEDBACK_TO_HANDLE]), + ( + RequestStatus.FEEDBACK_TO_HANDLE, + [ + RequestStatus.WAITING_FOR_FEEDBACK, + RequestStatus.ACCEPTED, + RequestStatus.REJECTED, + RequestStatus.SUSPENDED, + RequestStatus.UNSUCCESSFUL, + ], + ), ], ) def test_allowed_next_statuses(current_status, allowed_next_statuses): assert current_status.allowed_next_statuses() == allowed_next_statuses @pytest.mark.parametrize( "forge_url, expected_domain", [ ("https://gitlab.example.com/foo/bar", "gitlab.example.com"), ("gitlab.example.com", "gitlab.example.com"), ("gitlab.example.com/foo/bar", "gitlab.example.com"), ], ) def test_request_forge_domain(forge_url, expected_domain): assert Request(forge_url=forge_url).forge_domain == expected_domain