diff --git a/cypress/integration/add-forge-now-request-create.spec.js b/cypress/integration/add-forge-now-request-create.spec.js index ace2bcd7..3b618b0e 100644 --- a/cypress/integration/add-forge-now-request-create.spec.js +++ b/cypress/integration/add-forge-now-request-create.spec.js @@ -1,166 +1,167 @@ /** * 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 */ function populateForm(type, url, contact, email, consent, comment) { cy.get('#swh-input-forge-type').select(type); cy.get('#swh-input-forge-url').type(url); cy.get('#swh-input-forge-contact-name').type(contact); cy.get('#swh-input-forge-contact-email').type(email); cy.get('#swh-input-forge-comment').type(comment); + cy.get('#swh-input-consent-check').click({force: consent === 'on'}); } describe('Test add-forge-request creation', function() { beforeEach(function() { this.addForgeNowUrl = this.Urls.forge_add(); }); it('should show both tabs for every user', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-tab') .should('have.class', 'nav-link'); cy.get('#swh-add-forge-requests-list-tab') .should('have.class', 'nav-link'); }); it('should show create forge tab by default', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-tab') .should('have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('not.have.class', 'active'); }); it('should show login link for anonymous user', function() { cy.visit(this.addForgeNowUrl); cy.get('#loginLink') .should('be.visible') .should('contain', 'log in'); }); it('should bring back after login', function() { cy.visit(this.addForgeNowUrl); cy.get('#loginLink') .should('have.attr', 'href') .and('include', `${this.Urls.login()}?next=${this.Urls.forge_add()}`); }); it('should change tabs on click', function() { cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#swh-add-forge-tab') .should('not.have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('have.class', 'active'); cy.get('#swh-add-forge-tab').click(); cy.get('#swh-add-forge-tab') .should('have.class', 'active'); cy.get('#swh-add-forge-requests-list-tab') .should('not.have.class', 'active'); }); it('should show create form elements to authenticated user', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); cy.get('#swh-input-forge-type') .should('be.visible'); cy.get('#swh-input-forge-url') .should('be.visible'); cy.get('#swh-input-forge-contact-name') .should('be.visible'); cy.get('#swh-input-consent-check') .should('be.visible'); cy.get('#swh-input-forge-comment') .should('be.visible'); cy.get('#swh-input-form-submit') .should('be.visible'); }); it('should show browse requests table for every user', function() { // testing only for anonymous cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#add-forge-request-browse') .should('be.visible'); cy.get('#loginLink') .should('not.be.visible'); }); it('should update browse list on successful submission', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); populateForm('bitbucket', 'gitlab.com', 'test', 'test@example.com', 'on', 'test comment'); cy.get('#requestCreateForm').submit(); cy.visit(this.addForgeNowUrl); cy.get('#swh-add-forge-requests-list-tab').click(); cy.get('#add-forge-request-browse') .should('be.visible') .should('contain', 'gitlab.com'); cy.get('#add-forge-request-browse') .should('be.visible') .should('contain', 'PENDING'); }); it('should show error message on conflict', function() { cy.userLogin(); cy.visit(this.addForgeNowUrl); populateForm('bitbucket', 'gitlab.com', 'test', 'test@example.com', 'on', 'test comment'); cy.get('#requestCreateForm').submit(); cy.get('#requestCreateForm').submit(); // Submitting the same data again cy.get('#userMessage') .should('have.class', 'badge-danger') .should('contain', 'already exists'); }); it('should show error message', function() { cy.userLogin(); cy.intercept('POST', `${this.Urls.api_1_add_forge_request_create()}**`, { body: { 'exception': 'BadInputExc', 'reason': '{"add-forge-comment": ["This field is required"]}' }, statusCode: 400 }).as('errorRequest'); cy.visit(this.addForgeNowUrl); populateForm( - 'bitbucket', 'gitlab.com', 'test', 'test@example.com', 'on', 'comment' + 'bitbucket', 'gitlab.com', 'test', 'test@example.com', 'off', 'comment' ); cy.get('#requestCreateForm').submit(); cy.wait('@errorRequest').then((xhr) => { cy.get('#userMessage') .should('have.class', 'badge-danger') .should('contain', 'field is required'); }); }); }); diff --git a/swh/web/add_forge_now/migrations/0003_request_submitter_forward_username.py b/swh/web/add_forge_now/migrations/0003_request_submitter_forward_username.py new file mode 100644 index 00000000..d6b0a841 --- /dev/null +++ b/swh/web/add_forge_now/migrations/0003_request_submitter_forward_username.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2022-03-21 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("add_forge_now", "0002_authorized_null_comment"), + ] + + operations = [ + migrations.AddField( + model_name="request", + name="submitter_forward_username", + field=models.BooleanField(default=False), + ), + ] diff --git a/swh/web/add_forge_now/models.py b/swh/web/add_forge_now/models.py index 28aee457..5f9f0603 100644 --- a/swh/web/add_forge_now/models.py +++ b/swh/web/add_forge_now/models.py @@ -1,99 +1,100 @@ # 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 django.db import models 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" @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.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: [], } return next_statuses[self] # type: ignore class RequestActorRole(enum.Enum): MODERATOR = "moderator" SUBMITTER = "submitter" FORGE_ADMIN = "forge admin" @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) 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, ...)", ) diff --git a/swh/web/add_forge_now/tests/test_migration.py b/swh/web/add_forge_now/tests/test_migration.py index 1f303718..fa245366 100644 --- a/swh/web/add_forge_now/tests/test_migration.py +++ b/swh/web/add_forge_now/tests/test_migration.py @@ -1,97 +1,111 @@ # 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 APP_NAME = "add_forge_now" MIGRATION_0001 = "0001_initial" MIGRATION_0002 = "0002_authorized_null_comment" +MIGRATION_0003 = "0003_request_submitter_forward_username" 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_NAME, MIGRATION_0001)) request = state.apps.get_model(APP_NAME, "Request") request_history = state.apps.get_model(APP_NAME, "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_NAME, 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_NAME, "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_NAME, MIGRATION_0002)) requestModel2 = state.apps.get_model(APP_NAME, "Request") req2 = make_request_with_empty_comment(requestModel2) req2.save() + + +def test_add_forge_now_store_submitter_forward_username(migrator): + """Basic migration test to check new model authorized empty comment""" + + state = migrator.apply_tested_migration((APP_NAME, MIGRATION_0002)) + requestModel = state.apps.get_model(APP_NAME, "Request") + assert not hasattr(requestModel, "submitter_forward_username") + + state = migrator.apply_tested_migration((APP_NAME, MIGRATION_0003)) + requestModel2 = state.apps.get_model(APP_NAME, "Request") + + assert hasattr(requestModel2, "submitter_forward_username") diff --git a/swh/web/api/views/add_forge_now.py b/swh/web/api/views/add_forge_now.py index 7479c0fa..cede85bc 100644 --- a/swh/web/api/views/add_forge_now.py +++ b/swh/web/api/views/add_forge_now.py @@ -1,357 +1,358 @@ # 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 json from typing import Any, Dict, Union from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db import transaction from django.forms import CharField, ModelForm from django.http import HttpResponseBadRequest from django.http.request import HttpRequest from django.http.response import HttpResponse, HttpResponseForbidden from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response from swh.web.add_forge_now.models import Request as AddForgeRequest from swh.web.add_forge_now.models import RequestActorRole as AddForgeNowRequestActorRole from swh.web.add_forge_now.models import RequestHistory as AddForgeNowRequestHistory from swh.web.add_forge_now.models import RequestStatus as AddForgeNowRequestStatus from swh.web.api.apidoc import api_doc, format_docstring from swh.web.api.apiurls import api_route from swh.web.auth.utils import ADD_FORGE_MODERATOR_PERMISSION from swh.web.common.exc import BadInputExc from swh.web.common.utils import has_add_forge_now_permission, reverse def _block_while_testing(): """Replaced by tests to check concurrency behavior """ pass class AddForgeNowRequestForm(ModelForm): forge_contact_comment = CharField(required=False,) class Meta: model = AddForgeRequest fields = ( "forge_type", "forge_url", "forge_contact_email", "forge_contact_name", "forge_contact_comment", + "submitter_forward_username", ) class AddForgeNowRequestHistoryForm(ModelForm): new_status = CharField(max_length=200, required=False,) class Meta: model = AddForgeNowRequestHistory fields = ("text", "new_status") class AddForgeNowRequestSerializer(serializers.ModelSerializer): class Meta: model = AddForgeRequest fields = "__all__" class AddForgeNowRequestPublicSerializer(serializers.ModelSerializer): """Serializes AddForgeRequest without private fields. """ class Meta: model = AddForgeRequest fields = ("id", "forge_url", "forge_type", "status", "submission_date") class AddForgeNowRequestHistorySerializer(serializers.ModelSerializer): class Meta: model = AddForgeNowRequestHistory exclude = ("request",) class AddForgeNowRequestHistoryPublicSerializer(serializers.ModelSerializer): class Meta: model = AddForgeNowRequestHistory fields = ("id", "date", "new_status", "actor_role") @api_route( r"/add-forge/request/create/", "api-1-add-forge-request-create", methods=["POST"], ) @api_doc("/add-forge/request/create") @format_docstring() @transaction.atomic def api_add_forge_request_create(request: Union[HttpRequest, Request]) -> HttpResponse: """ .. http:post:: /api/1/add-forge/request/create/ Create a new request to add a forge to the list of those crawled regularly by Software Heritage. .. warning:: That endpoint is not publicly available and requires authentication in order to be able to request it. {common_headers} :<json string forge_type: the type of forge :<json string forge_url: the base URL of the forge :<json string forge_contact_email: email of an administator of the forge to contact :<json string forge_contact_name: the name of the administrator :<json string forge_contact_comment: to explain how Software Heritage can verify forge administrator info are valid :statuscode 201: request successfully created :statuscode 400: missing or invalid field values :statuscode 403: user not authenticated """ if not request.user.is_authenticated: return HttpResponseForbidden( "You must be authenticated to create a new add-forge request" ) add_forge_request = AddForgeRequest() if isinstance(request, Request): # request submitted with request body in JSON (goes through DRF) form = AddForgeNowRequestForm(request.data, instance=add_forge_request) else: # request submitted with request body in form encoded format # (directly handled by Django) form = AddForgeNowRequestForm(request.POST, instance=add_forge_request) if form.errors: raise BadInputExc(json.dumps(form.errors)) try: existing_request = AddForgeRequest.objects.get( forge_url=add_forge_request.forge_url ) except ObjectDoesNotExist: pass else: return Response( f"Request for forge already exists (id {existing_request.id})", status=409, # Conflict ) add_forge_request.submitter_name = request.user.username add_forge_request.submitter_email = request.user.email form.save() request_history = AddForgeNowRequestHistory() request_history.request = add_forge_request request_history.new_status = AddForgeNowRequestStatus.PENDING.name request_history.actor = request.user.username request_history.actor_role = AddForgeNowRequestActorRole.SUBMITTER.name request_history.save() data = AddForgeNowRequestSerializer(add_forge_request).data return Response(data=data, status=201) @api_route( r"/add-forge/request/(?P<id>[0-9]+)/update/", "api-1-add-forge-request-update", methods=["POST"], ) @api_doc("/add-forge/request/update", tags=["hidden"]) @format_docstring() @transaction.atomic def api_add_forge_request_update( request: Union[HttpRequest, Request], id: int ) -> HttpResponse: """ .. http:post:: /api/1/add-forge/request/update/ Update a request to add a forge to the list of those crawled regularly by Software Heritage. .. warning:: That endpoint is not publicly available and requires authentication in order to be able to request it. {common_headers} :<json string text: comment about new request status :<json string new_status: the new request status :statuscode 200: request successfully updated :statuscode 400: missing or invalid field values :statuscode 403: user is not a moderator """ if not request.user.is_authenticated: return HttpResponseForbidden( "You must be authenticated to update a new add-forge request" ) if not has_add_forge_now_permission(request.user): return HttpResponseForbidden("You are not a moderator") add_forge_request = ( AddForgeRequest.objects.filter(id=id).select_for_update().first() ) if add_forge_request is None: return HttpResponseBadRequest("Invalid request id") request_history = AddForgeNowRequestHistory() request_history.request = add_forge_request if isinstance(request, Request): # request submitted with request body in JSON (goes through DRF) form = AddForgeNowRequestHistoryForm(request.data, instance=request_history) else: # request submitted with request body in form encoded format # (directly handled by Django) form = AddForgeNowRequestHistoryForm(request.POST, instance=request_history) if form.errors: raise BadInputExc(json.dumps(form.errors)) new_status_str = form["new_status"].value() if new_status_str is not None: new_status = AddForgeNowRequestStatus[new_status_str] current_status = AddForgeNowRequestStatus[add_forge_request.status] if new_status not in current_status.allowed_next_statuses(): raise BadInputExc( f"New request status {new_status} cannot be reached " f"from current status {add_forge_request.status}" ) _block_while_testing() request_history.actor = request.user.username request_history.actor_role = AddForgeNowRequestActorRole.MODERATOR.name form.save(commit=False) if request_history.new_status == "": request_history.new_status = None request_history.save() if request_history.new_status is not None: add_forge_request.status = request_history.new_status add_forge_request.save() data = AddForgeNowRequestSerializer(add_forge_request).data return Response(data=data, status=200) @api_route( r"/add-forge/request/list/", "api-1-add-forge-request-list", methods=["GET"], ) @api_doc("/add-forge/request/list") @format_docstring() def api_add_forge_request_list(request: Request): """ .. http:get:: /api/1/add-forge/request/list/ List requests to add forges to the list of those crawled regularly by Software Heritage. {common_headers} {resheader_link} :query int page: optional page number :query int per_page: optional number of elements per page (bounded to 1000) :statuscode 200: always """ add_forge_requests = AddForgeRequest.objects.order_by("-id") page_num = int(request.GET.get("page", 1)) per_page = int(request.GET.get("per_page", 10)) per_page = min(per_page, 1000) if ( int(request.GET.get("user_requests_only", "0")) and request.user.is_authenticated ): add_forge_requests = add_forge_requests.filter( submitter_name=request.user.username ) paginator = Paginator(add_forge_requests, per_page) page = paginator.page(page_num) if request.user.has_perm(ADD_FORGE_MODERATOR_PERMISSION): requests = AddForgeNowRequestSerializer(page.object_list, many=True).data else: requests = AddForgeNowRequestPublicSerializer(page.object_list, many=True).data results = [dict(request) for request in requests] response: Dict[str, Any] = {"results": results, "headers": {}} if page.has_previous(): response["headers"]["link-prev"] = reverse( "api-1-add-forge-request-list", query_params={"page": page.previous_page_number(), "per_page": per_page,}, request=request, ) if page.has_next(): response["headers"]["link-next"] = reverse( "api-1-add-forge-request-list", query_params={"page": page.next_page_number(), "per_page": per_page}, request=request, ) return response @api_route( r"/add-forge/request/(?P<id>[0-9]+)/get/", "api-1-add-forge-request-get", methods=["GET"], ) @api_doc("/add-forge/request/get") @format_docstring() def api_add_forge_request_get(request: Request, id: int): """ .. http:get:: /api/1/add-forge/request/get/ Return all details about an add-forge request. {common_headers} :param int id: add-forge request identifier :statuscode 200: request details successfully returned :statuscode 400: request identifier does not exist """ try: add_forge_request = AddForgeRequest.objects.get(id=id) except ObjectDoesNotExist: raise BadInputExc("Request id does not exist") request_history = AddForgeNowRequestHistory.objects.filter( request=add_forge_request ).order_by("id") if request.user.is_authenticated and request.user.has_perm( ADD_FORGE_MODERATOR_PERMISSION ): data = AddForgeNowRequestSerializer(add_forge_request).data history = AddForgeNowRequestHistorySerializer(request_history, many=True).data else: data = AddForgeNowRequestPublicSerializer(add_forge_request).data history = AddForgeNowRequestHistoryPublicSerializer( request_history, many=True ).data return {"request": data, "history": history} diff --git a/swh/web/templates/add_forge_now/create-request.html b/swh/web/templates/add_forge_now/create-request.html index 249e025d..874af611 100644 --- a/swh/web/templates/add_forge_now/create-request.html +++ b/swh/web/templates/add_forge_now/create-request.html @@ -1,234 +1,234 @@ {% extends "../layout.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 %} {% load render_bundle from webpack_loader %} {% load static %} {% block header %} {% render_bundle 'add_forge' %} {% endblock %} {% block title %} Add forge now – Software Heritage archive {% endblock %} {% block navbar-content %} <h4>Request the addition of a forge into the archive</h4> {% endblock %} {% block content %} <div class="col-md-12 offset-md-1"> <div class="col-md-8"> <h5 class="d-flex justify-content-between align-items-center mb-3"> </h5> <p style="margin-top: 1rem;"> “Add forge now” provides a service for Software Heritage users to save a complete forge in the Software Heritage archive by requesting the addition of the forge URL into the list of regularly visited forges. {% if not user.is_authenticated %} <p> You can submit an “Add forge now” request only when you are authenticated, please login to submit the request. </p> {% endif %} </p> </div> <!-- Tabs in the page --> <div class="col-md-8"> <ul class="nav nav-tabs"> <li class="nav-item"><a class="nav-link active" data-toggle="tab" id="swh-add-forge-tab" href="#swh-add-forge-submit-request">Submit a Request</a></li> <li class="nav-item"><a class="nav-link" data-toggle="tab" id="swh-add-forge-requests-list-tab" href="#swh-add-forge-requests-list">Browse Requests</a></li> </ul> <div class="tab-content"> <div id="swh-add-forge-submit-request" class="tab-pane active" style="padding-top: 10px;"> {% if not user.is_authenticated %} <h3> <p class="text-primary"> You must be logged in to submit an add forge request. Please <a id="loginLink" href="{% url 'login' %}?next={% url 'forge-add' %}" class="link-primary">log in</a> </p> </h3> {% else %} <form method="POST" action="{% url 'api-1-add-forge-request-create' %}" id="requestCreateForm" class="collapse show"> {% csrf_token %} <div class="form-row"> <div class="form-group col-md-5"> <label for="swh-input-forge-type">Forge type</label> <select class="form-control" id="swh-input-forge-type" name="forge_type" autofocus> {% for forge_type in forge_types %} <option value={{ forge_type }}>{{ forge_type}}</option> {% endfor %} </select> <small class="form-text text-muted"> Supported forge types in software archive. </small> </div> <div class="form-group col-md-7"> <label for="swh-input-forge-url">Forge URL</label> <input type="text" class="form-control" id="swh-input-forge-url" name="forge_url" required> <small class="form-text text-muted"> Remote URL of the forge to list. </small> </div> </div> <div class="form-row"> <div class="form-group col-md-5"> <label for="swh-input-forge-contact-name">Forge contact name</label> <input type="text" class="form-control" name="forge_contact_name" id="swh-input-forge-contact-name" required> <small class="form-text text-muted"> Name of the Forge administrator. </small> </div> <div class="form-group col-md-7"> <label for="swh-input-forge-contact-email">Forge contact email</label> <input type="email" class="form-control" name="forge_contact_email" id="swh-input-forge-contact-email" required> <small class="form-text text-muted"> Email of the forge administrator. The given email address will not be used for any purpose outside the “add forge now” process. </small> </div> </div> <div class="form-row"> <div class="form-group form-check"> <input class="form-check-input" type="checkbox" - id="swh-input-consent-check" name="consent_to_add_name"> + id="swh-input-consent-check" name="submitter_forward_consent"> <label for="swh-input-consent-check"> - I consent to add my username in the communication with the forge + I consent to add my username in the communication with the forge. </label> </div> </div> <div class="form-row"> <div class="form-group col-md-12"> <label for="swh-input-forge-comment">Comment</label> <textarea class="form-control" id="swh-input-forge-comment" name="forge_contact_comment" rows="3"></textarea> <small class="form-text text-muted"> Optionally, leave a comment to the moderator regarding your request. </small> </div> </div> <div class="form-row"> <div class="col-md-12"> <input id="swh-input-form-submit" type="submit" value="Submit Add Request" class="btn btn-default float-right"> </div> </div> <div class="form-row"> <div class="col-md-12"> <h3 class="text-center"> <span id="userMessage" class="badge"></span> </h3> <p class="text-center"> <span id="userMessageDetail"></span> </p> </div> </div> </form> <div class="panel panel-info"> <div class="panel-heading"> <a data-toggle="collapse" href="#swh-forge-add-request-help" role="button" aria-expanded="false" class="text-primary"> For more information </a> </div> <div id="swh-forge-add-request-help" class="collapse panel-body"> <p> Once submitted, your "add forge" request can either be: </p> <ul> <li> <strong>Pending:</strong> the request was submitted and is waiting for a moderator to check its validity. </li> <li> <strong>Waiting for feedback:</strong> the request was processed by a moderator and the forge was contacted, the request is waiting for feedback from the forge. </li> <li> <strong>Feedback to handle:</strong> the forge has responded to the request and there is feedback to handle for the request.</li> <li> <strong>Accepted:</strong> the request has been accepted and waiting to be scheduled. </li> <li> <strong>Scheduled:</strong> the request has been scheduled is considered done. </li> <li> <strong>First listing done:</strong> The first listing of the forge is completed. </li> <li> <strong>First origin loaded:</strong> The first origin or repository processed by loader and archived (using a search query). </li> <li><strong>Rejected:</strong> the request is not a valid request and is rejected by a Software Heritage moderator.</li> <li><strong>Denied:</strong> the forge has requested not to archive the forge.</li> <li><strong>Suspended:</strong> the request is for a forge with a non supported VCS.</li> </ul> <p> Once a add request has been submitted, you can follow its current status in the <a id="swh-show-forge-add-requests-list" href="#browse-requests"> submitted requests list </a>. This process is depending on human interactions and might take a few days to be handled (it primarily depends on the response time of the forge). </p> </div> </div> {% endif %} </div> <div id="swh-add-forge-requests-list" class="tab-pane fade show"> <table id="add-forge-request-browse" class="table swh-table swh-table-striped" style="width: 100%;"> <thead> <tr> <th>Submission date</th> <th>Forge type</th> <th>Forge URL</th> <th>Status</th> </tr> </thead> </table> <div id="add-forge-browse-request-error"></div> </div> </div> </div> </div> <script> swh.add_forge.onCreateRequestPageLoad(); </script> {% endblock %} diff --git a/swh/web/tests/api/views/test_add_forge_now.py b/swh/web/tests/api/views/test_add_forge_now.py index b90aa4a2..6b2ae446 100644 --- a/swh/web/tests/api/views/test_add_forge_now.py +++ b/swh/web/tests/api/views/test_add_forge_now.py @@ -1,494 +1,497 @@ # 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 datetime import threading import time from urllib.parse import urlencode import iso8601 import pytest from swh.web.add_forge_now.models import Request from swh.web.common.utils import reverse from swh.web.tests.utils import ( check_api_get_responses, check_api_post_response, check_http_post_response, ) @pytest.mark.django_db def test_add_forge_request_create_anonymous_user(api_client): url = reverse("api-1-add-forge-request-create") check_api_post_response(api_client, url, status_code=403) @pytest.mark.django_db def test_add_forge_request_create_empty(api_client, regular_user): api_client.force_login(regular_user) url = reverse("api-1-add-forge-request-create") resp = check_api_post_response(api_client, url, status_code=400) assert '"forge_type"' in resp.data["reason"] ADD_FORGE_DATA_FORGE1 = { "forge_type": "gitlab", "forge_url": "https://gitlab.example.org", "forge_contact_email": "admin@gitlab.example.org", "forge_contact_name": "gitlab.example.org admin", "forge_contact_comment": "user marked as owner in forge members", + "submitter_forward_username": True, } ADD_FORGE_DATA_FORGE2 = { "forge_type": "gitea", "forge_url": "https://gitea.example.org", "forge_contact_email": "admin@gitea.example.org", "forge_contact_name": "gitea.example.org admin", "forge_contact_comment": "user marked as owner in forge members", + "submitter_forward_username": True, } ADD_FORGE_DATA_FORGE3 = { "forge_type": "heptapod", "forge_url": "https://heptapod.host/", "forge_contact_email": "admin@example.org", "forge_contact_name": "heptapod admin", "forge_contact_comment": "", # authorized empty or null comment + "submitter_forward_username": False, } @pytest.mark.django_db(transaction=True, reset_sequences=True) @pytest.mark.parametrize( "add_forge_data", [ADD_FORGE_DATA_FORGE1, ADD_FORGE_DATA_FORGE3] ) def test_add_forge_request_create_success(api_client, regular_user, add_forge_data): api_client.force_login(regular_user) url = reverse("api-1-add-forge-request-create") date_before = datetime.datetime.now(tz=datetime.timezone.utc) resp = check_api_post_response( api_client, url, data=add_forge_data, status_code=201, ) date_after = datetime.datetime.now(tz=datetime.timezone.utc) assert resp.data == { **add_forge_data, "id": resp.data["id"], "status": "PENDING", "submission_date": resp.data["submission_date"], "submitter_name": regular_user.username, "submitter_email": regular_user.email, } assert date_before < iso8601.parse_date(resp.data["submission_date"]) < date_after request = Request.objects.all().last() assert request.forge_url == add_forge_data["forge_url"] assert request.submitter_name == regular_user.username @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_create_success_form_encoded(client, regular_user): client.force_login(regular_user) url = reverse("api-1-add-forge-request-create") date_before = datetime.datetime.now(tz=datetime.timezone.utc) resp = check_http_post_response( client, url, request_content_type="application/x-www-form-urlencoded", data=urlencode(ADD_FORGE_DATA_FORGE1), status_code=201, ) date_after = datetime.datetime.now(tz=datetime.timezone.utc) assert resp.data == { **ADD_FORGE_DATA_FORGE1, "id": 1, "status": "PENDING", "submission_date": resp.data["submission_date"], "submitter_name": regular_user.username, "submitter_email": regular_user.email, } assert date_before < iso8601.parse_date(resp.data["submission_date"]) < date_after request = Request.objects.all()[0] assert request.forge_url == ADD_FORGE_DATA_FORGE1["forge_url"] assert request.submitter_name == regular_user.username @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_create_duplicate(api_client, regular_user): api_client.force_login(regular_user) url = reverse("api-1-add-forge-request-create") check_api_post_response( api_client, url, data=ADD_FORGE_DATA_FORGE1, status_code=201, ) check_api_post_response( api_client, url, data=ADD_FORGE_DATA_FORGE1, status_code=409, ) requests = Request.objects.all() assert len(requests) == 1 @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_anonymous_user(api_client): url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response(api_client, url, status_code=403) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_regular_user(api_client, regular_user): api_client.force_login(regular_user) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response(api_client, url, status_code=403) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_non_existent(api_client, add_forge_moderator): api_client.force_login(add_forge_moderator) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response(api_client, url, status_code=400) def create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE1): api_client.force_login(regular_user) url = reverse("api-1-add-forge-request-create") return check_api_post_response(api_client, url, data=data, status_code=201,) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_empty(api_client, regular_user, add_forge_moderator): create_add_forge_request(api_client, regular_user) api_client.force_login(add_forge_moderator) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response(api_client, url, status_code=400) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_missing_field( api_client, regular_user, add_forge_moderator ): create_add_forge_request(api_client, regular_user) api_client.force_login(add_forge_moderator) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response(api_client, url, data={}, status_code=400) check_api_post_response( api_client, url, data={"new_status": "REJECTED"}, status_code=400 ) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update(api_client, regular_user, add_forge_moderator): create_add_forge_request(api_client, regular_user) api_client.force_login(add_forge_moderator) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response( api_client, url, data={"text": "updating request"}, status_code=200 ) check_api_post_response( api_client, url, data={"new_status": "REJECTED", "text": "request rejected"}, status_code=200, ) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_invalid_new_status( api_client, regular_user, add_forge_moderator ): create_add_forge_request(api_client, regular_user) api_client.force_login(add_forge_moderator) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) check_api_post_response( api_client, url, data={"new_status": "ACCEPTED", "text": "request accepted"}, status_code=400, ) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_update_status_concurrent( api_client, regular_user, add_forge_moderator, mocker ): _block_while_testing = mocker.patch( "swh.web.api.views.add_forge_now._block_while_testing" ) _block_while_testing.side_effect = lambda: time.sleep(1) create_add_forge_request(api_client, regular_user) api_client.force_login(add_forge_moderator) url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) worker_ended = False def worker(): nonlocal worker_ended check_api_post_response( api_client, url, data={"new_status": "WAITING_FOR_FEEDBACK", "text": "waiting for message"}, status_code=200, ) worker_ended = True # this thread will first modify the request status to WAITING_FOR_FEEDBACK thread = threading.Thread(target=worker) thread.start() # the other thread (slower) will attempt to modify the request status to REJECTED # but it will not be allowed as the first faster thread already modified it # and REJECTED state can not be reached from WAITING_FOR_FEEDBACK one time.sleep(0.5) check_api_post_response( api_client, url, data={"new_status": "REJECTED", "text": "request accepted"}, status_code=400, ) thread.join() assert worker_ended @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_list_anonymous(api_client, regular_user): url = reverse("api-1-add-forge-request-list") resp = check_api_get_responses(api_client, url, status_code=200) assert resp.data == [] create_add_forge_request(api_client, regular_user) resp = check_api_get_responses(api_client, url, status_code=200) add_forge_request = { "forge_url": ADD_FORGE_DATA_FORGE1["forge_url"], "forge_type": ADD_FORGE_DATA_FORGE1["forge_type"], "status": "PENDING", "submission_date": resp.data[0]["submission_date"], "id": 1, } assert resp.data == [add_forge_request] create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE2) resp = check_api_get_responses(api_client, url, status_code=200) other_forge_request = { "forge_url": ADD_FORGE_DATA_FORGE2["forge_url"], "forge_type": ADD_FORGE_DATA_FORGE2["forge_type"], "status": "PENDING", "submission_date": resp.data[0]["submission_date"], "id": 2, } assert resp.data == [other_forge_request, add_forge_request] @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_list_moderator( api_client, regular_user, add_forge_moderator ): url = reverse("api-1-add-forge-request-list") create_add_forge_request(api_client, regular_user) create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE2) api_client.force_login(add_forge_moderator) resp = check_api_get_responses(api_client, url, status_code=200) add_forge_request = { **ADD_FORGE_DATA_FORGE1, "status": "PENDING", "submission_date": resp.data[1]["submission_date"], "submitter_name": regular_user.username, "submitter_email": regular_user.email, "id": 1, } other_forge_request = { **ADD_FORGE_DATA_FORGE2, "status": "PENDING", "submission_date": resp.data[0]["submission_date"], "submitter_name": regular_user.username, "submitter_email": regular_user.email, "id": 2, } assert resp.data == [other_forge_request, add_forge_request] @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_list_pagination( api_client, regular_user, api_request_factory ): create_add_forge_request(api_client, regular_user) create_add_forge_request(api_client, regular_user, data=ADD_FORGE_DATA_FORGE2) url = reverse("api-1-add-forge-request-list", query_params={"per_page": 1}) resp = check_api_get_responses(api_client, url, 200) assert len(resp.data) == 1 request = api_request_factory.get(url) next_url = reverse( "api-1-add-forge-request-list", query_params={"page": 2, "per_page": 1}, request=request, ) assert resp["Link"] == f'<{next_url}>; rel="next"' resp = check_api_get_responses(api_client, next_url, 200) assert len(resp.data) == 1 prev_url = reverse( "api-1-add-forge-request-list", query_params={"page": 1, "per_page": 1}, request=request, ) assert resp["Link"] == f'<{prev_url}>; rel="previous"' @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_list_submitter_filtering( api_client, regular_user, regular_user2 ): create_add_forge_request(api_client, regular_user) create_add_forge_request(api_client, regular_user2, data=ADD_FORGE_DATA_FORGE2) api_client.force_login(regular_user) url = reverse( "api-1-add-forge-request-list", query_params={"user_requests_only": 1} ) resp = check_api_get_responses(api_client, url, status_code=200) assert len(resp.data) == 1 @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_get(api_client, regular_user, add_forge_moderator): resp = create_add_forge_request(api_client, regular_user) submission_date = resp.data["submission_date"] url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) api_client.force_login(add_forge_moderator) check_api_post_response( api_client, url, data={"new_status": "WAITING_FOR_FEEDBACK", "text": "waiting for message"}, status_code=200, ) api_client.logout() url = reverse("api-1-add-forge-request-get", url_args={"id": 1}) resp = check_api_get_responses(api_client, url, status_code=200) assert resp.data == { "request": { "forge_url": ADD_FORGE_DATA_FORGE1["forge_url"], "forge_type": ADD_FORGE_DATA_FORGE1["forge_type"], "id": 1, "status": "WAITING_FOR_FEEDBACK", "submission_date": submission_date, }, "history": [ { "id": 1, "actor_role": "SUBMITTER", "date": resp.data["history"][0]["date"], "new_status": "PENDING", }, { "id": 2, "actor_role": "MODERATOR", "date": resp.data["history"][1]["date"], "new_status": "WAITING_FOR_FEEDBACK", }, ], } @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_get_moderator(api_client, regular_user, add_forge_moderator): resp = create_add_forge_request(api_client, regular_user) submission_date = resp.data["submission_date"] url = reverse("api-1-add-forge-request-update", url_args={"id": 1}) api_client.force_login(add_forge_moderator) check_api_post_response( api_client, url, data={"new_status": "WAITING_FOR_FEEDBACK", "text": "waiting for message"}, status_code=200, ) url = reverse("api-1-add-forge-request-get", url_args={"id": 1}) resp = check_api_get_responses(api_client, url, status_code=200) assert resp.data == { "request": { **ADD_FORGE_DATA_FORGE1, "id": 1, "status": "WAITING_FOR_FEEDBACK", "submission_date": submission_date, "submitter_name": regular_user.username, "submitter_email": regular_user.email, }, "history": [ { "id": 1, "text": "", "actor": regular_user.username, "actor_role": "SUBMITTER", "date": resp.data["history"][0]["date"], "new_status": "PENDING", }, { "id": 2, "text": "waiting for message", "actor": add_forge_moderator.username, "actor_role": "MODERATOR", "date": resp.data["history"][1]["date"], "new_status": "WAITING_FOR_FEEDBACK", }, ], } @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_add_forge_request_get_invalid(api_client): url = reverse("api-1-add-forge-request-get", url_args={"id": 3}) check_api_get_responses(api_client, url, status_code=400)