# 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 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 AddForgeNowRequestPublicSerializer(serializers.ModelSerializer):
"""Serializes AddForgeRequest without private fields."""
[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}