diff --git a/ietf/api/serializers_rpc.py b/ietf/api/serializers_rpc.py index 2793690b06..ba1e4020e6 100644 --- a/ietf/api/serializers_rpc.py +++ b/ietf/api/serializers_rpc.py @@ -1,6 +1,12 @@ # Copyright The IETF Trust 2025, All Rights Reserved +import datetime +from typing import Literal, Optional + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from ietf.doc.models import DocumentAuthor, Document, RelatedDocument from ietf.person.models import Person @@ -11,3 +17,148 @@ class Meta: model = Person fields = ["id", "plain_name", "picture"] read_only_fields = ["id", "plain_name", "picture"] + + +class EmailPersonSerializer(serializers.Serializer): + email = serializers.EmailField(source="address") + person_pk = serializers.IntegerField(source="person.pk") + name = serializers.CharField(source="person.name") + last_name = serializers.CharField(source="person.last_name") + initials = serializers.CharField(source="person.initials") + + +class LowerCaseEmailField(serializers.EmailField): + def to_representation(self, value): + return super().to_representation(value).lower() + + +class AuthorPersonSerializer(serializers.ModelSerializer): + person_pk = serializers.IntegerField(source="pk", read_only=True) + last_name = serializers.CharField() + initials = serializers.CharField() + email_addresses = serializers.ListField( + source="email_set.all", child=LowerCaseEmailField() + ) + + class Meta: + model = Person + fields = ["person_pk", "name", "last_name", "initials", "email_addresses"] + + +class RfcWithAuthorsSerializer(serializers.ModelSerializer): + authors = AuthorPersonSerializer(many=True) + + class Meta: + model = Document + fields = ["rfc_number", "authors"] + + +class DraftWithAuthorsSerializer(serializers.ModelSerializer): + draft_name = serializers.CharField(source="name") + authors = AuthorPersonSerializer(many=True) + + class Meta: + model = Document + fields = ["draft_name", "authors"] + + +class DocumentAuthorSerializer(serializers.ModelSerializer): + """Serializer for a Person in a response""" + + plain_name = serializers.SerializerMethodField() + + class Meta: + model = DocumentAuthor + fields = ["person", "plain_name"] + + def get_plain_name(self, document_author: DocumentAuthor) -> str: + return document_author.person.plain_name() + + +class FullDraftSerializer(serializers.ModelSerializer): + source_format = serializers.SerializerMethodField() + authors = DocumentAuthorSerializer(many=True, source="documentauthor_set") + shepherd = serializers.SerializerMethodField() + + class Meta: + model = Document + fields = [ + "id", + "name", + "rev", + "stream", + "title", + "pages", + "source_format", + "authors", + "shepherd", + "intended_std_level", + ] + + def get_source_format( + self, doc: Document + ) -> Literal["unknown", "xml-v2", "xml-v3", "txt"]: + submission = doc.submission() + if submission is None: + return "unknown" + if ".xml" in submission.file_types: + if submission.xml_version == "3": + return "xml-v3" + else: + return "xml-v2" + elif ".txt" in submission.file_types: + return "txt" + return "unknown" + + @extend_schema_field(OpenApiTypes.EMAIL) + def get_shepherd(self, doc: Document) -> str: + if doc.shepherd: + return doc.shepherd.formatted_ascii_email() + return "" + + +class DraftSerializer(FullDraftSerializer): + class Meta: + model = Document + fields = [ + "id", + "name", + "rev", + "stream", + "title", + "pages", + "source_format", + "authors", + ] + + +class SubmittedToQueueSerializer(FullDraftSerializer): + submitted = serializers.SerializerMethodField() + + class Meta: + model = Document + fields = [ + "id", + "name", + "stream", + "submitted", + ] + + def get_submitted(self, doc) -> Optional[datetime.datetime]: + event = doc.sent_to_rfc_editor_event() + return None if event is None else event.time + + +class OriginalStreamSerializer(serializers.ModelSerializer): + stream = serializers.CharField(read_only=True, source="orig_stream_id") + + class Meta: + model = Document + fields = ["rfc_number", "stream"] + + +class ReferenceSerializer(serializers.ModelSerializer): + class Meta: + model = Document + fields = ["id", "name"] + read_only_fields = ["id", "name"] diff --git a/ietf/api/urls.py b/ietf/api/urls.py index abf359c0b6..f75a1ceb67 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -36,6 +36,7 @@ url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()), # --- DRF API --- # path("core/", include(core_router.urls)), + path("purple/", include("ietf.api.urls_rpc")), path("red/", include(red_router.urls)), path("schema/", SpectacularAPIView.as_view()), # @@ -97,7 +98,6 @@ url(r'^rfcdiff-latest-json/(?P[Rr][Ff][Cc] [0-9]+?)(\.txt|\.html)?/?$', api_views.rfcdiff_latest_json), # direct authentication url(r'^directauth/?$', api_views.directauth), - url(r'^rpc/', include('ietf.api.urls_rpc')), ] # Additional (standard) Tastypie endpoints diff --git a/ietf/api/urls_rpc.py b/ietf/api/urls_rpc.py index b69755f9dc..925a091cb9 100644 --- a/ietf/api/urls_rpc.py +++ b/ietf/api/urls_rpc.py @@ -1,30 +1,33 @@ -# Copyright The IETF Trust 2023-2024, All Rights Reserved +# Copyright The IETF Trust 2023-2025, All Rights Reserved + +from rest_framework import routers from django.conf import settings +from django.urls import include, path from ietf.api import views_rpc, views_rpc_demo from ietf.utils.urls import url +router = routers.DefaultRouter() +router.register(r"draft", views_rpc.DraftViewSet, basename="draft") +router.register(r"person", views_rpc.PersonViewSet) +router.register(r"rfc", views_rpc.RfcViewSet, basename="rfc") + +if settings.SERVER_MODE not in {"production", "test"}: + # for non production demos + router.register(r"demo", views_rpc_demo.DemoViewSet, basename="demo") + + urlpatterns = [ - url(r"^doc/drafts/(?P[0-9]+)/$", views_rpc.rpc_draft), - url(r"^doc/drafts/(?P[0-9]+)/references/$", views_rpc.rpc_draft_refs), - url(r"^doc/drafts_by_names/$", views_rpc.drafts_by_names), - url(r"^doc/submitted_to_rpc/$", views_rpc.submitted_to_rpc), - url(r"^doc/rfc/original_stream/$", views_rpc.rfc_original_stream), - url(r"^doc/rfc/authors/$", views_rpc.rfc_authors), - url(r"^doc/draft/authors/$", views_rpc.draft_authors), - url(r"^person/persons_by_email/$", views_rpc.persons_by_email), - url(r"^person/(?P[0-9]+)/$", views_rpc.rpc_person), - url(r"^persons/$", views_rpc.rpc_persons), + url(r"^doc/drafts_by_names/", views_rpc.DraftsByNamesView.as_view()), url(r"^persons/search/", views_rpc.RpcPersonSearch.as_view()), - url(r"^subject/(?P[0-9]+)/person/$", views_rpc.rpc_subject_person), + path(r"subject//person/", views_rpc.SubjectPersonView.as_view()), ] -if settings.SERVER_MODE not in {"production", "test"}: - # for non production demos - urlpatterns.append( - url(r"^doc/create_demo_draft/$", views_rpc_demo.create_demo_draft) - ) - urlpatterns.append( - url(r"^person/create_demo_person/$", views_rpc_demo.create_demo_person) - ) +# add routers at the end so individual routes can steal parts of their address +# space (specifically, ^person/ routes so far) +urlpatterns.extend( + [ + path("", include(router.urls)), + ] +) diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index b4ea0c240c..77fbae60b2 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -1,72 +1,108 @@ # Copyright The IETF Trust 2023-2025, All Rights Reserved -import json - -from django.db.models import OuterRef, Subquery, Q -from django.http import ( - HttpResponseBadRequest, - JsonResponse, - HttpResponseNotAllowed, - HttpResponseNotFound, -) -from django.shortcuts import get_object_or_404 -from django.views.decorators.csrf import csrf_exempt -from django.contrib.auth.models import User +from drf_spectacular.utils import OpenApiParameter +from rest_framework import serializers, viewsets, mixins +from rest_framework.decorators import action +from rest_framework.views import APIView +from rest_framework.response import Response + +from django.db.models import CharField, OuterRef, Subquery, Q +from django.db.models.functions import Coalesce +from django.http import Http404 from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import generics from rest_framework.fields import CharField from rest_framework.filters import SearchFilter from rest_framework.pagination import LimitOffsetPagination -from ietf.api.ietf_utils import requires_api_token -from ietf.api.serializers_rpc import PersonSerializer -from ietf.doc.models import Document, DocHistory, RelatedDocument +from ietf.api.serializers_rpc import ( + PersonSerializer, + FullDraftSerializer, + DraftSerializer, + SubmittedToQueueSerializer, + OriginalStreamSerializer, + ReferenceSerializer, + EmailPersonSerializer, + RfcWithAuthorsSerializer, + DraftWithAuthorsSerializer, +) +from ietf.doc.models import Document, DocHistory from ietf.person.models import Email, Person -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rpc_person(request, person_id): - person = get_object_or_404(Person, pk=person_id) - return JsonResponse( - { - "id": person.id, - "plain_name": person.plain_name(), - "picture": person.cdn_photo_url() or None, - } +@extend_schema_view( + retrieve=extend_schema( + operation_id="get_person_by_id", + summary="Find person by ID", + description="Returns a single person", + ), +) +class PersonViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = Person.objects.all() + serializer_class = PersonSerializer + api_key_endpoint = "ietf.api.views_rpc" + lookup_url_kwarg = "person_id" + + @extend_schema( + operation_id="get_persons", + summary="Get a batch of persons", + description="Returns a list of persons matching requested ids. Omits any that are missing.", + request=list[int], + responses=PersonSerializer(many=True), + ) + @action(detail=False, methods=["post"]) + def batch(self, request): + """Get a batch of rpc person names""" + pks = request.data + return Response( + self.get_serializer(Person.objects.filter(pk__in=pks), many=True).data + ) + + @extend_schema( + operation_id="persons_by_email", + summary="Get a batch of persons by email addresses", + description=( + "Returns a list of persons matching requested ids. " + "Omits any that are missing." + ), + request=list[str], + responses=EmailPersonSerializer(many=True), ) + @action(detail=False, methods=["post"], serializer_class=EmailPersonSerializer) + def batch_by_email(self, request): + emails = Email.objects.filter(address__in=request.data, person__isnull=False) + serializer = self.get_serializer(emails, many=True) + return Response(serializer.data) + +class SubjectPersonView(APIView): + api_key_endpoint = "ietf.api.views_rpc" -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rpc_subject_person(request, subject_id): - try: - user_id = int(subject_id) - except ValueError: - return JsonResponse({"error": "Invalid subject id"}, status=400) - try: - user = User.objects.get(pk=user_id) - except User.DoesNotExist: - return JsonResponse({"error": "Unknown subject"}, status=404) - if hasattr( - user, "person" - ): # test this way to avoid exception on reverse OneToOneField - return rpc_person(request, person_id=user.person.pk) - return JsonResponse({"error": "Subject has no person"}, status=404) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rpc_persons(request): - """Get a batch of rpc person names""" - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - - pks = json.loads(request.body) - response = dict() - for p in Person.objects.filter(pk__in=pks): - response[str(p.pk)] = p.plain_name() - return JsonResponse(response) + @extend_schema( + operation_id="get_subject_person_by_id", + summary="Find person for OIDC subject by ID", + description="Returns a single person", + responses=PersonSerializer, + parameters=[ + OpenApiParameter( + name="subject_id", + type=str, + description="subject ID of person to return", + location="path", + ), + ], + ) + def get(self, request, subject_id: str): + try: + user_id = int(subject_id) + except ValueError: + raise serializers.ValidationError( + {"subject_id": "This field must be an integer value."} + ) + person = Person.objects.filter(user__pk=user_id).first() + if person: + return Response(PersonSerializer(person).data) + raise Http404 class RpcLimitOffsetPagination(LimitOffsetPagination): @@ -76,13 +112,13 @@ class RpcLimitOffsetPagination(LimitOffsetPagination): class SingleTermSearchFilter(SearchFilter): """SearchFilter backend that does not split terms - + The default SearchFilter treats comma or whitespace-separated terms as individual search terms. This backend instead searches for the exact term. """ def get_search_terms(self, request): - value = request.query_params.get(self.search_param, '') + value = request.query_params.get(self.search_param, "") field = CharField(trim_whitespace=False, allow_blank=True) cleaned_value = field.run_validation(value) return [cleaned_value] @@ -108,238 +144,139 @@ class RpcPersonSearch(generics.ListAPIView): search_fields = ["name", "plain", "email__address"] -def _document_source_format(doc): - submission = doc.submission() - if submission is None: - return "unknown" - if ".xml" in submission.file_types: - if submission.xml_version == "3": - return "xml-v3" - else: - return "xml-v2" - elif ".txt" in submission.file_types: - return "txt" - return "unknown" - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rpc_draft(request, doc_id): - if request.method != "GET": - return HttpResponseNotAllowed(["GET"]) - - try: - d = Document.objects.get(pk=doc_id, type_id="draft") - except Document.DoesNotExist: - return HttpResponseNotFound() - return JsonResponse( - { - "id": d.pk, - "name": d.name, - "rev": d.rev, - "stream": d.stream.slug, - "title": d.title, - "pages": d.pages, - "source_format": _document_source_format(d), - "authors": [ - { - "id": p.pk, - "plain_name": p.person.plain_name(), - } - for p in d.documentauthor_set.all() +@extend_schema_view( + retrieve=extend_schema( + operation_id="get_draft_by_id", + summary="Get a draft", + description="Returns the draft for the requested ID", + ), + submitted_to_rpc=extend_schema( + operation_id="submitted_to_rpc", + summary="List documents ready to enter the RFC Editor Queue", + description="List documents ready to enter the RFC Editor Queue", + responses=SubmittedToQueueSerializer(many=True), + ), +) +class DraftViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = Document.objects.filter(type_id="draft") + serializer_class = FullDraftSerializer + api_key_endpoint = "ietf.api.views_rpc" + lookup_url_kwarg = "doc_id" + + @action(detail=False, serializer_class=SubmittedToQueueSerializer) + def submitted_to_rpc(self, request): + """Return documents in datatracker that have been submitted to the RPC but are not yet in the queue + + Those queries overreturn - there may be things, particularly not from the IETF stream that are already in the queue. + """ + ietf_docs = Q(states__type_id="draft-iesg", states__slug__in=["ann"]) + irtf_iab_ise_docs = Q( + states__type_id__in=[ + "draft-stream-iab", + "draft-stream-irtf", + "draft-stream-ise", ], - "shepherd": d.shepherd.formatted_ascii_email() if d.shepherd else "", - "intended_std_level": ( - d.intended_std_level.slug if d.intended_std_level else "" - ), - } + states__slug__in=["rfc-edit"], + ) + # TODO: Need a way to talk about editorial stream docs + docs = ( + self.get_queryset() + .filter(type_id="draft") + .filter(ietf_docs | irtf_iab_ise_docs) + ) + serializer = self.get_serializer(docs, many=True) + return Response(serializer.data) + + @extend_schema( + operation_id="get_draft_references", + summary="Get normative references to I-Ds", + description=( + "Returns the id and name of each normatively " + "referenced Internet-Draft for the given docId" + ), + responses=ReferenceSerializer(many=True), ) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rpc_draft_refs(request, doc_id): - """Return normative references""" - if request.method != "GET": - return HttpResponseNotAllowed(["GET"]) - - return JsonResponse( - dict( - references=[ - dict(id=t[0], name=t[1]) - for t in RelatedDocument.objects.filter( - source_id=doc_id, target__type_id="draft", relationship_id="refnorm" - ).values_list("target_id", "target__name") - ] + @action(detail=True, serializer_class=ReferenceSerializer) + def references(self, request, doc_id=None): + doc = self.get_object() + serializer = self.get_serializer( + [ + reference + for reference in doc.related_that_doc("refnorm") + if reference.type_id == "draft" + ], + many=True, ) + return Response(serializer.data) + + @extend_schema( + operation_id="get_draft_authors", + summary="Gather authors of the drafts with the given names", + description="returns a list mapping draft names to objects describing authors", + request=list[int], + responses=RfcWithAuthorsSerializer(many=True), ) + @action(detail=False, methods=["post"], serializer_class=DraftWithAuthorsSerializer) + def authors(self, request): + drafts = self.get_queryset().filter(name__in=request.data) + serializer = self.get_serializer(drafts, many=True) + return Response(serializer.data) -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def drafts_by_names(request): - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - try: - names = json.loads(request.body) - except json.JSONDecodeError: - return HttpResponseBadRequest() - docs = Document.objects.filter(type_id="draft", name__in=names) - response = dict() - for doc in docs: - response[doc.name] = { - "id": doc.pk, - "name": doc.name, - "rev": doc.rev, - "stream": doc.stream.slug if doc.stream else "none", - "title": doc.title, - "pages": doc.pages, - "source_format": _document_source_format(doc), - "authors": [ - { - "id": p.pk, - "plain_name": p.person.plain_name(), - } - for p in doc.documentauthor_set.all() - ], - } - return JsonResponse(response) +@extend_schema_view( + rfc_original_stream=extend_schema( + operation_id="get_rfc_original_streams", + summary="Get the streams RFCs were originally published into", + description="returns a list of dicts associating an RFC with its originally published stream", + responses=OriginalStreamSerializer(many=True), + ) +) +class RfcViewSet(viewsets.GenericViewSet): + queryset = Document.objects.filter(type_id="rfc") + api_key_endpoint = "ietf.api.views_rpc" + + @action(detail=False, serializer_class=OriginalStreamSerializer) + def rfc_original_stream(self, request): + rfcs = self.get_queryset().annotate( + orig_stream_id=Coalesce( + Subquery( + DocHistory.objects.filter(doc=OuterRef("pk")) + .exclude(stream__isnull=True) + .order_by("time") + .values_list("stream_id", flat=True)[:1] + ), + "stream_id", + output_field=CharField(), + ), + ) + serializer = self.get_serializer(rfcs, many=True) + return Response(serializer.data) + + @extend_schema( + operation_id="get_rfc_authors", + summary="Gather authors of the RFCs with the given numbers", + description="returns a list mapping rfc numbers to objects describing authors", + request=list[int], + responses=RfcWithAuthorsSerializer(many=True), + ) + @action(detail=False, methods=["post"], serializer_class=RfcWithAuthorsSerializer) + def authors(self, request): + rfcs = self.get_queryset().filter(rfc_number__in=request.data) + serializer = self.get_serializer(rfcs, many=True) + return Response(serializer.data) -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def submitted_to_rpc(request): - """Return documents in datatracker that have been submitted to the RPC but are not yet in the queue +class DraftsByNamesView(APIView): + api_key_endpoint = "ietf.api.views_rpc" - Those queries overreturn - there may be things, particularly not from the IETF stream that are already in the queue. - """ - ietf_docs = Q(states__type_id="draft-iesg", states__slug__in=["ann"]) - irtf_iab_ise_docs = Q( - states__type_id__in=[ - "draft-stream-iab", - "draft-stream-irtf", - "draft-stream-ise", - ], - states__slug__in=["rfc-edit"], - ) - # TODO: Need a way to talk about editorial stream docs - docs = Document.objects.filter(type_id="draft").filter( - ietf_docs | irtf_iab_ise_docs + @extend_schema( + operation_id="get_drafts_by_names", + summary="Get a batch of drafts by draft names", + description="returns a list of drafts with matching names", + request=list[str], + responses=DraftSerializer(many=True), ) - response = {"submitted_to_rpc": []} - for doc in docs: - response["submitted_to_rpc"].append( - { - "name": doc.name, - "id": doc.pk, - "stream": doc.stream_id, - "submitted": f"{doc.sent_to_rfc_editor_event().time.isoformat()}", - } - ) - return JsonResponse(response) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rfc_original_stream(request): - """Return the stream that an rfc was first published into for all rfcs""" - rfcs = Document.objects.filter(type="rfc").annotate( - orig_stream_id=Subquery( - DocHistory.objects.filter(doc=OuterRef("pk")) - .exclude(stream__isnull=True) - .order_by("time") - .values_list("stream_id", flat=True)[:1] - ) - ) - response = {"original_stream": []} - for rfc in rfcs: - response["original_stream"].append( - { - "rfc_number": rfc.rfc_number, - "stream": ( - rfc.orig_stream_id - if rfc.orig_stream_id is not None - else rfc.stream_id - ), - } - ) - return JsonResponse(response) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def persons_by_email(request): - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - try: - emails = json.loads(request.body) - except json.JSONDecodeError: - return HttpResponseBadRequest() - response = [] - for email in Email.objects.filter(address__in=emails).exclude(person__isnull=True): - response.append( - { - "email": email.address, - "person_pk": email.person.pk, - "name": email.person.name, - "last_name": email.person.last_name(), - "initials": email.person.initials(), - } - ) - return JsonResponse(response, safe=False) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def rfc_authors(request): - """Gather authors of the RFCs with the given numbers""" - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - try: - rfc_numbers = json.loads(request.body) - except json.JSONDecodeError: - return HttpResponseBadRequest() - response = [] - for rfc in Document.objects.filter(type="rfc", rfc_number__in=rfc_numbers): - item = {"rfc_number": rfc.rfc_number, "authors": []} - for author in rfc.authors(): - item_author = dict() - item_author["person_pk"] = author.pk - item_author["name"] = author.name - item_author["last_name"] = author.last_name() - item_author["initials"] = author.initials() - item_author["email_addresses"] = [ - address.lower() - for address in author.email_set.values_list("address", flat=True) - ] - item["authors"].append(item_author) - response.append(item) - return JsonResponse(response, safe=False) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def draft_authors(request): - """Gather authors of the RFCs with the given numbers""" - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - try: - draft_names = json.loads(request.body) - except json.JSONDecodeError: - return HttpResponseBadRequest() - response = [] - for draft in Document.objects.filter(type="draft", name__in=draft_names): - item = {"draft_name": draft.name, "authors": []} - for author in draft.authors(): - item_author = dict() - item_author["person_pk"] = author.pk - item_author["name"] = author.name - item_author["last_name"] = author.last_name() - item_author["initials"] = author.initials() - item_author["email_addresses"] = [ - address.lower() - for address in author.email_set.values_list("address", flat=True) - ] - item["authors"].append(item_author) - response.append(item) - return JsonResponse(response, safe=False) + def post(self, request): + names = request.data + docs = Document.objects.filter(type_id="draft", name__in=names) + return Response(DraftSerializer(docs, many=True).data) diff --git a/ietf/api/views_rpc_demo.py b/ietf/api/views_rpc_demo.py index 51dff32bf0..ad969b0832 100644 --- a/ietf/api/views_rpc_demo.py +++ b/ietf/api/views_rpc_demo.py @@ -1,60 +1,98 @@ -# Copyright The IETF Trust 2023-2024, All Rights Reserved +# Copyright The IETF Trust 2023-2025, All Rights Reserved -import json +from drf_spectacular.utils import extend_schema_view, extend_schema +from rest_framework import serializers, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response -from django.views.decorators.csrf import csrf_exempt -from django.http import HttpResponse, HttpResponseNotAllowed, JsonResponse - -from ietf.api.ietf_utils import requires_api_token from ietf.doc.factories import WgDraftFactory from ietf.doc.models import Document from ietf.person.factories import PersonFactory from ietf.person.models import Person -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def create_demo_person(request): - """Helper for creating rpc demo objects - SHOULD NOT MAKE IT INTO PRODUCTION""" - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - - request_params = json.loads(request.body) - name = request_params["name"] - person = Person.objects.filter(name=name).first() or PersonFactory(name=name) - return JsonResponse({"user_id": person.user.pk, "person_pk": person.pk}) - - -@csrf_exempt -@requires_api_token("ietf.api.views_rpc") -def create_demo_draft(request): - """Helper for creating rpc demo objects - SHOULD NOT MAKE IT INTO PRODUCTION""" - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - - request_params = json.loads(request.body) - name = request_params.get("name") - rev = request_params.get("rev") - states = request_params.get("states") - stream_id = request_params.get("stream_id", "ietf") - doc = None - if not name: - return HttpResponse(status=400, content="Name is required") - doc = Document.objects.filter(name=name).first() - if not doc: - kwargs = {"name": name, "stream_id": stream_id} - if states: - kwargs["states"] = states - if rev: - kwargs["rev"] = rev - doc = WgDraftFactory( - **kwargs - ) # Yes, things may be a little strange if the stream isn't IETF, but until we nned something different... - event_type = "iesg_approved" if stream_id == "ietf" else "requested_publication" - if not doc.docevent_set.filter( - type=event_type - ).exists(): # Not using get_or_create here on purpose - these are wobbly facades we're creating - doc.docevent_set.create( - type=event_type, by_id=1, desc="Sent off to the RPC" +class DemoPersonCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255) + + +class DemoPersonSerializer(serializers.ModelSerializer): + person_pk = serializers.IntegerField(source="pk") + + class Meta: + model = Person + fields = ["user_id", "person_pk"] + + +class DemoDraftCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255, required=True) + stream_id = serializers.CharField(default="ietf") + rev = serializers.CharField(default=None) + states = serializers.DictField(child=serializers.CharField(), default=None) + + +class DemoDraftSerializer(serializers.ModelSerializer): + doc_id = serializers.IntegerField(source="pk") + + class Meta: + model = Document + fields = ["doc_id", "name"] + + +@extend_schema_view( + create_demo_person=extend_schema( + operation_id="create_demo_person", + summary="Build a datatracker Person for RPC demo purposes", + description="returns a datatracker User id for a person created with the given name", + request=DemoPersonCreateSerializer, + responses=DemoPersonSerializer, + ), + create_demo_draft=extend_schema( + operation_id="create_demo_draft", + summary="Build a datatracker WG draft for RPC demo purposes", + description="returns a datatracker document id for a draft created with the provided name and states. " + "The arguments, if present, are passed directly to the WgDraftFactory", + request=DemoDraftCreateSerializer, + responses=DemoDraftSerializer, + ), +) +class DemoViewSet(viewsets.ViewSet): + """SHOULD NOT MAKE IT INTO PRODUCTION""" + + api_key_endpoint = "ietf.api.views_rpc" + + @action(detail=False, methods=["post"]) + def create_demo_person(self, request): + """Helper for creating rpc demo objects - SHOULD NOT MAKE IT INTO PRODUCTION""" + request_params = DemoPersonCreateSerializer(request.data) + name = request_params.data["name"] + person = Person.objects.filter(name=name).first() or PersonFactory(name=name) + return Response(DemoPersonSerializer(person).data) + + @action(detail=False, methods=["post"]) + def create_demo_draft(self, request): + """Helper for creating rpc demo objects - SHOULD NOT MAKE IT INTO PRODUCTION""" + request_params = DemoDraftCreateSerializer(request.data) + name = request_params.data["name"] + rev = request_params.data["rev"] + stream_id = request_params.data["stream_id"] + states = request_params.data["states"] + doc = Document.objects.filter(name=name).first() + if not doc: + kwargs = {"name": name, "stream_id": stream_id} + if states: + kwargs["states"] = states + if rev: + kwargs["rev"] = rev + doc = WgDraftFactory( + **kwargs + ) # Yes, things may be a little strange if the stream isn't IETF, but until we need something different... + event_type = ( + "iesg_approved" if stream_id == "ietf" else "requested_publication" ) - return JsonResponse({"doc_id": doc.pk, "name": doc.name}) + if not doc.docevent_set.filter( + type=event_type + ).exists(): # Not using get_or_create here on purpose - these are wobbly facades we're creating + doc.docevent_set.create( + type=event_type, by_id=1, desc="Sent off to the RPC" + ) + return Response(DemoDraftSerializer(doc).data) diff --git a/ietf/doc/migrations/0026_alter_dochistory_title_alter_document_title.py b/ietf/doc/migrations/0026_alter_dochistory_title_alter_document_title.py new file mode 100644 index 0000000000..5f5140a278 --- /dev/null +++ b/ietf/doc/migrations/0026_alter_dochistory_title_alter_document_title.py @@ -0,0 +1,41 @@ +# Copyright The IETF Trust 2025, All Rights Reserved + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("doc", "0025_storedobject_storedobject_unique_name_per_store"), + ] + + operations = [ + migrations.AlterField( + model_name="dochistory", + name="title", + field=models.CharField( + max_length=255, + validators=[ + django.core.validators.ProhibitNullCharactersValidator, + django.core.validators.RegexValidator( + message="Please enter a string without control characters.", + regex="^[^\x01-\x1f]*$", + ), + ], + ), + ), + migrations.AlterField( + model_name="document", + name="title", + field=models.CharField( + max_length=255, + validators=[ + django.core.validators.ProhibitNullCharactersValidator, + django.core.validators.RegexValidator( + message="Please enter a string without control characters.", + regex="^[^\x01-\x1f]*$", + ), + ], + ), + ), + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index bdf250ebcb..5c5468fb49 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -20,7 +20,11 @@ from django.core import checks from django.core.files.base import File from django.core.cache import caches -from django.core.validators import URLValidator, RegexValidator +from django.core.validators import ( + URLValidator, + RegexValidator, + ProhibitNullCharactersValidator, +) from django.urls import reverse as urlreverse from django.contrib.contenttypes.models import ContentType from django.conf import settings @@ -107,7 +111,13 @@ class DocumentInfo(models.Model): time = models.DateTimeField(default=timezone.now) # should probably have auto_now=True type = ForeignKey(DocTypeName, blank=True, null=True) # Draft, Agenda, Minutes, Charter, Discuss, Guideline, Email, Review, Issue, Wiki, External ... - title = models.CharField(max_length=255, validators=[validate_no_control_chars, ]) + title = models.CharField( + max_length=255, + validators=[ + ProhibitNullCharactersValidator, + validate_no_control_chars, + ], + ) states = models.ManyToManyField(State, blank=True) # plain state (Active/Expired/...), IESG state, stream state tags = models.ManyToManyField(DocTagName, blank=True) # Revised ID Needed, ExternalParty, AD Followup, ... diff --git a/ietf/utils/validators.py b/ietf/utils/validators.py index 8fe989df99..48d0898be2 100644 --- a/ietf/utils/validators.py +++ b/ietf/utils/validators.py @@ -25,8 +25,9 @@ # Note that this is an instantiation of the regex validator, _not_ the # regex-string validator defined right below validate_no_control_chars = RegexValidator( - regex="^[^\x00-\x1f]*$", - message="Please enter a string without control characters." ) + regex="^[^\x01-\x1f]*$", + message="Please enter a string without control characters.", +) @deconstructible diff --git a/rpcapi.yaml b/rpcapi.yaml deleted file mode 100644 index 6e6e316ca0..0000000000 --- a/rpcapi.yaml +++ /dev/null @@ -1,522 +0,0 @@ -openapi: 3.0.3 -info: - title: Datatracker RPC API - description: Datatracker RPC API - version: 1.0.0 -servers: - - url: 'http://localhost:8000/api/rpc' -paths: - /person/{personId}/: - get: - operationId: get_person_by_id - summary: Find person by ID - description: Returns a single person - parameters: - - name: personId - in: path - description: ID of person to return - required: true - schema: - type: integer - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/Person' - '404': - description: Not found - - /person/persons_by_email/: - post: - operationId: persons_by_email - summary: Get a batch of persons by email addresses - description: returns a list of objects with the email address and related person information - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - type: object - properties: - email: - type: string - person_pk: - type: integer - name: - type: string - last_name: - type: string - initials: - type: string - - - /persons/: - post: - operationId: get_persons - summary: Get a batch of persons - description: returns a dict of person pks to person names - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: integer - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - additionalProperties: - type: string - - /persons/search/: - get: - operationId: search_person - description: Get a list of persons, matching by partial name or email - parameters: - - name: limit - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - - name: offset - required: false - in: query - description: The initial index from which to return the results. - schema: - type: integer - - name: search - required: false - in: query - description: A search term. - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/PaginatedPersonList' - - /person/create_demo_person/: - post: - operationId: create_demo_person - summary: Build a datatracker Person for RPC demo purposes - description: returns a datatracker User id for a person created with the given name - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - user_id: - type: integer - person_pk: - type: integer - - /doc/create_demo_draft/: - post: - operationId: create_demo_draft - summary: Build a datatracker WG draft for RPC demo purposes - description: returns a datatracker document id for a draft created with the provided name and states. The arguments, if present, are passed directly to the WgDraftFactory - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - stream_id: - type: string - rev: - type: string - states: - type: array - items: - type: array - items: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - doc_id: - type: integer - name: - type: string - - /doc/submitted_to_rpc/: - get: - operationId: submitted_to_rpc - summary: List documents ready to enter the RFC Editor Queue - description: List documents ready to enter the RFC Editor Queue - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/SubmittedToQueue' - - /doc/drafts/{docId}/: - get: - operationId: get_draft_by_id - summary: Get a draft - description: Returns the draft for the requested ID - parameters: - - name: docId - in: path - description: ID of draft to retrieve - required: true - schema: - type: integer - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/Draft' - '404': - description: Not found - - /doc/drafts/{docId}/references/: - get: - operationId: get_draft_references - summary: Get normative references to Internet-Drafts - description: Returns the id and name of each normatively referenced Internet-Draft for the given docId - parameters: - - name: docId - in: path - description: ID of draft - required: true - schema: - type: integer - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/References' - - /doc/drafts_by_names/: - post: - operationId: get_drafts_by_names - summary: Get a batch of drafts by draft names - description: returns a dict of drafts with matching names - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - additionalProperties: - $ref: '#/components/schemas/Draft' - - /doc/rfc/authors/: - post: - operationId: get_rfc_authors - summary: Gather authors of the RFCs with the given numbers - description: returns a dict mapping rfc numbers to objects describing authors - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: integer - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - type: object - properties: - rfc_number: - type: integer - authors: - type: array - items: - type: object - properties: - person_pk: - type: integer - name: - type: string - last_name: - type: string - initials: - type: string - email_addresses: - type: array - items: - type: string - - /doc/draft/authors/: - post: - operationId: get_draft_authors - summary: Gather authors of the drafts with the given names - description: returns a dict mapping draft names to objects describing authors - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - type: object - properties: - draft_name: - type: string - authors: - type: array - items: - type: object - properties: - person_pk: - type: integer - name: - type: string - last_name: - type: string - initials: - type: string - email_addresses: - type: array - items: - type: string - - /doc/rfc/original_stream/: - get: - operationId: get_rfc_original_streams - summary: Get the streams rfcs were originally published into - description: returns a list of dicts associating an rfc with its originally published stream - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/OriginalStream' - - /subject/{subjectId}/person/: - get: - operationId: get_subject_person_by_id - summary: Find person for OIDC subject by ID - description: Returns a single person - parameters: - - name: subjectId - in: path - description: subject ID of person to return - required: true - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/Person' - '400': - description: No such subject or no person for this subject - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '404': - description: No such subject or no person for this subject - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - -components: - schemas: - Person: - type: object - properties: - id: - type: integer - example: 1234 - plain_name: - type: string - example: John Doe - picture: - type: string - example: https://cdn.example.com/avatars/some-photo.png - format: uri - nullable: true - - SubmittedToQueue: - type: object - properties: - submitted_to_rpc: - type: array - items: - type: object - properties: - name: - type: string - id: - type: integer - stream: - type: string - submitted: - type: string - format: date-time - - ErrorResponse: - type: object - properties: - error: - type: string - - Draft: - type: object - properties: - id: - type: integer - name: - type: string - rev: - type: string - stream: - type: string - title: - type: string - pages: - type: integer - source_format: - type: string - enum: - - unknown - - txt - - xml-v2 - - xml-v3 - authors: - type: array - items: - $ref: '#/components/schemas/Person' - shepherd: - type: string - format: email - intended_std_level: - type: string - - References: - type: object - properties: - references: - type: array - items: - type: object - properties: - id: - type: integer - name: - type: string - - OriginalStream: - type: object - properties: - original_stream: - type: array - items: - properties: - rfc_number: - type: integer - stream: - type: string - - PaginatedPersonList: - type: object - required: - - count - - results - properties: - count: - type: integer - example: 123 - next: - type: string - nullable: true - format: uri - example: https://api.example.org/accounts/?offset=400&limit=100 - previous: - type: string - nullable: true - format: uri - example: https://api.example.org/accounts/?offset=200&limit=100 - results: - type: array - items: - $ref: '#/components/schemas/Person' - - securitySchemes: - ApiKeyAuth: - type: apiKey - in: header - name: X-API-KEY - -security: - - ApiKeyAuth: []