Source code for swh.web.add_forge_now.api_views

# Copyright (C) 2022-2024  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.contrib.auth.models import User
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.add_forge_now.utils import trigger_request_processing_pipeline
from swh.web.api.apidoc import api_doc, format_docstring
from swh.web.api.apiurls import APIUrls, api_route
from swh.web.auth.utils import is_add_forge_now_moderator
from swh.web.utils import reverse
from swh.web.utils.exc import BadInputExc


def _block_while_testing():
    """Replaced by tests to check concurrency behavior"""
    pass


[docs] class AddForgeNowRequestForm(ModelForm): forge_contact_comment = CharField( required=False, )
[docs] class Meta: model = AddForgeRequest fields = ( "forge_type", "forge_url", "forge_contact_email", "forge_contact_name", "forge_contact_comment", "submitter_forward_username", )
[docs] class AddForgeNowRequestHistoryForm(ModelForm): new_status = CharField( max_length=200, required=False, )
[docs] class Meta: model = AddForgeNowRequestHistory fields = ("text", "new_status")
[docs] class AddForgeNowRequestSerializer(serializers.ModelSerializer): inbound_email_address = serializers.CharField() forge_domain = serializers.CharField()
[docs] class Meta: model = AddForgeRequest fields = "__all__"
[docs] class AddForgeNowRequestPublicSerializer(serializers.ModelSerializer): """Serializes AddForgeRequest without private fields."""
[docs] class Meta: model = AddForgeRequest fields = ("id", "forge_url", "forge_type", "status", "submission_date")
[docs] class AddForgeNowRequestHistorySerializer(serializers.ModelSerializer): message_source_url = serializers.SerializerMethodField()
[docs] class Meta: model = AddForgeNowRequestHistory exclude = ("request", "message_source")
[docs] def get_message_source_url(self, request_history): if request_history.message_source is None: return None return reverse( "forge-add-message-source", url_args={"id": request_history.pk}, request=self.context["request"], )
[docs] class AddForgeNowRequestHistoryPublicSerializer(serializers.ModelSerializer):
[docs] class Meta: model = AddForgeNowRequestHistory fields = ("id", "date", "new_status", "actor_role")
add_forge_now_api_urls = APIUrls()
[docs] @api_route( r"/add-forge/request/create/", "api-1-add-forge-request-create", methods=["POST"], api_urls=add_forge_now_api_urls, ) @api_doc("/add-forge/request/create", category="Request archival") @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 administrator 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 ) assert isinstance(request.user, User) 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() add_forge_request.last_modified_date = request_history.date add_forge_request.save() data = AddForgeNowRequestSerializer(add_forge_request).data return Response(data=data, status=201)
[docs] @api_route( r"/add-forge/request/(?P<id>[0-9]+)/update/", "api-1-add-forge-request-update", methods=["POST"], api_urls=add_forge_now_api_urls, ) @api_doc("/add-forge/request/update", category="Request archival", 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/(id)/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 is_add_forge_now_moderator(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() request_id = request_history.request.id forge_type = add_forge_request.forge_type forge_url = add_forge_request.forge_url former_status = ("PENDING", "WAITING_FOR_FEEDBACK", "FEEDBACK_TO_HANDLE") if ( request_history.new_status == "ACCEPTED" and add_forge_request.status in former_status ): trigger_request_processing_pipeline(request_id, forge_type, forge_url) if request_history.new_status is not None: add_forge_request.status = request_history.new_status add_forge_request.last_moderator = request_history.actor add_forge_request.last_modified_date = request_history.date add_forge_request.save() data = AddForgeNowRequestSerializer(add_forge_request).data return Response(data=data, status=200)
[docs] @api_route( r"/add-forge/request/list/", "api-1-add-forge-request-list", methods=["GET"], api_urls=add_forge_now_api_urls, ) @api_doc("/add-forge/request/list", category="Request archival") @format_docstring() def api_add_forge_request_list(request: Request): """ .. http:get:: /api/1/add-forge/request/list/ List add forge requests submitted by users. {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 is_add_forge_now_moderator(request.user): 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": str(page.previous_page_number()), "per_page": str(per_page), }, request=request, ) if page.has_next(): response["headers"]["link-next"] = reverse( "api-1-add-forge-request-list", query_params={ "page": str(page.next_page_number()), "per_page": str(per_page), }, request=request, ) return response
[docs] @api_route( r"/add-forge/request/(?P<id>[0-9]+)/get/", "api-1-add-forge-request-get", methods=["GET"], api_urls=add_forge_now_api_urls, ) @api_doc("/add-forge/request/get", category="Request archival") @format_docstring() def api_add_forge_request_get(request: Request, id: int): """ .. http:get:: /api/1/add-forge/request/(id)/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 is_add_forge_now_moderator(request.user): data = AddForgeNowRequestSerializer(add_forge_request).data history = AddForgeNowRequestHistorySerializer( request_history, many=True, context={"request": request} ).data else: data = AddForgeNowRequestPublicSerializer(add_forge_request).data history = AddForgeNowRequestHistoryPublicSerializer( request_history, many=True ).data return {"request": data, "history": history}