diff --git a/ietf/api/urls.py b/ietf/api/urls.py index a88ea5d6629..4e4cf4be681 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -24,6 +24,7 @@ # todo more general name for this API? red_router = PrefixedSimpleRouter(name_prefix="ietf.api.red_api") # red api router red_router.register("doc", doc_api.RfcViewSet) +red_router.register("subseries", doc_api.SubseriesViewSet, basename="subseries") api.autodiscover() diff --git a/ietf/doc/api.py b/ietf/doc/api.py index 2fecdfa2394..81e9df49025 100644 --- a/ietf/doc/api.py +++ b/ietf/doc/api.py @@ -1,6 +1,7 @@ # Copyright The IETF Trust 2024, All Rights Reserved """Doc API implementations""" -from django.db.models import OuterRef, Subquery, Prefetch, Value, JSONField + +from django.db.models import OuterRef, Subquery, Prefetch, Value, JSONField, QuerySet from django.db.models.functions import TruncDate from django_filters import rest_framework as filters from rest_framework import filters as drf_filters @@ -10,10 +11,16 @@ from rest_framework.viewsets import GenericViewSet from ietf.group.models import Group -from ietf.name.models import StreamName +from ietf.name.models import StreamName, DocTypeName from ietf.utils.timezone import RPC_TZINFO -from .models import Document, DocEvent, RelatedDocument -from .serializers import RfcMetadataSerializer, RfcStatus, RfcSerializer +from .models import Document, DocEvent, RelatedDocument, DocumentAuthor, \ + SUBSERIES_DOC_TYPE_IDS +from .serializers import ( + RfcMetadataSerializer, + RfcStatus, + RfcSerializer, + SubseriesDocSerializer, +) class RfcLimitOffsetPagination(LimitOffsetPagination): @@ -55,38 +62,37 @@ class PrefetchRelatedDocument(Prefetch): those for which the current RFC is the `source`. If `reverse` is True, includes those for which it is the `target` instead. Defaults to only "rfc" documents. """ + @staticmethod + def _get_queryset(relationship_id, reverse, doc_type_id): + """Get queryset to use for the prefetch""" + return RelatedDocument.objects.filter( + **{ + "relationship_id": relationship_id, + f"{'source' if reverse else 'target'}__type_id": doc_type_id, + } + ).select_related("source" if reverse else "target") def __init__(self, to_attr, relationship_id, reverse=False, doc_type_id="rfc"): super().__init__( lookup="targets_related" if reverse else "relateddocument_set", - queryset=RelatedDocument.objects.filter( - **{ - "relationship_id": relationship_id, - f"{'source' if reverse else 'target'}__type_id": doc_type_id, - } - ), + queryset=self._get_queryset(relationship_id, reverse, doc_type_id), to_attr=to_attr, ) -class RfcViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): - permission_classes: list[BasePermission] = [] - lookup_field = "rfc_number" - queryset = ( - Document.objects.filter(type_id="rfc", rfc_number__isnull=False) - .annotate( - published_datetime=Subquery( - DocEvent.objects.filter( - doc_id=OuterRef("pk"), - type="published_rfc", - ) - .order_by("-time") - .values("time")[:1] - ), - ) - .annotate(published=TruncDate("published_datetime", tzinfo=RPC_TZINFO)) - .order_by("-rfc_number") +def augment_rfc_queryset(queryset: QuerySet[Document]): + return ( + queryset + .select_related("std_level", "stream") .prefetch_related( + Prefetch( + "group", + Group.objects.select_related("parent"), + ), + Prefetch( + "documentauthor_set", + DocumentAuthor.objects.select_related("email", "person"), + ), PrefetchRelatedDocument( to_attr="drafts", relationship_id="became_rfc", @@ -102,6 +108,17 @@ class RfcViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): to_attr="updated_by", relationship_id="updates", reverse=True ), ) + .annotate( + published_datetime=Subquery( + DocEvent.objects.filter( + doc_id=OuterRef("pk"), + type="published_rfc", + ) + .order_by("-time") + .values("time")[:1] + ), + ) + .annotate(published=TruncDate("published_datetime", tzinfo=RPC_TZINFO)) .annotate( # TODO implement these fake fields for real is_also=Value([], output_field=JSONField()), @@ -110,7 +127,16 @@ class RfcViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): keywords=Value(["keyword"], output_field=JSONField()), errata=Value([], output_field=JSONField()), ) - ) # default ordering - RfcFilter may override + ) + + +class RfcViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + permission_classes: list[BasePermission] = [] + lookup_field = "rfc_number" + queryset = augment_rfc_queryset( + Document.objects.filter(type_id="rfc", rfc_number__isnull=False) + ).order_by("-rfc_number") + pagination_class = RfcLimitOffsetPagination filter_backends = [filters.DjangoFilterBackend, drf_filters.SearchFilter] filterset_class = RfcFilter @@ -120,3 +146,37 @@ def get_serializer_class(self): if self.action == "retrieve": return RfcSerializer return RfcMetadataSerializer + + +class PrefetchSubseriesContents(Prefetch): + def __init__(self, to_attr): + super().__init__( + lookup="relateddocument_set", + queryset=RelatedDocument.objects.filter( + relationship_id="contains", + target__type_id="rfc", + ).prefetch_related( + Prefetch( + "target", + queryset=augment_rfc_queryset(Document.objects.all()), + ) + ), + to_attr=to_attr, + ) + + +class SubseriesFilter(filters.FilterSet): + type = filters.ModelMultipleChoiceFilter( + queryset=DocTypeName.objects.filter(pk__in=SUBSERIES_DOC_TYPE_IDS) + ) + + +class SubseriesViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + permission_classes: list[BasePermission] = [] + lookup_field = "name" + serializer_class = SubseriesDocSerializer + queryset = Document.objects.subseries_docs().prefetch_related( + PrefetchSubseriesContents(to_attr="contents") + ) + filter_backends = [filters.DjangoFilterBackend] + filterset_class = SubseriesFilter diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 52a42e845cc..20f500d4495 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -937,7 +937,18 @@ def role_for_doc(self): 'invalid' ) + +SUBSERIES_DOC_TYPE_IDS = ("bcp", "fyi", "std") + + +class DocumentQuerySet(models.QuerySet): + def subseries_docs(self): + return self.filter(type_id__in=SUBSERIES_DOC_TYPE_IDS) + + class Document(StorableMixin, DocumentInfo): + objects = DocumentQuerySet.as_manager() + name = models.CharField(max_length=255, validators=[validate_docname,], unique=True) # immutable action_holders = models.ManyToManyField(Person, through=DocumentActionHolder, blank=True) diff --git a/ietf/doc/serializers.py b/ietf/doc/serializers.py index ff590e8700d..4e130966f7d 100644 --- a/ietf/doc/serializers.py +++ b/ietf/doc/serializers.py @@ -3,12 +3,13 @@ from dataclasses import dataclass from typing import Literal, ClassVar +from django.db.models.manager import BaseManager from drf_spectacular.utils import extend_schema_field from rest_framework import serializers, fields from ietf.group.serializers import GroupSerializer from ietf.name.serializers import StreamNameSerializer -from .models import Document, DocumentAuthor +from .models import Document, DocumentAuthor, RelatedDocument class RfcAuthorSerializer(serializers.ModelSerializer): @@ -114,10 +115,10 @@ def to_representation(self, instance: Document): return super().to_representation(instance=RfcStatus.from_document(instance)) -class RelatedDraftSerializer(serializers.ModelSerializer): - class Meta: - model = Document - fields = ["id", "name", "title"] +class RelatedDraftSerializer(serializers.Serializer): + id = serializers.IntegerField(source="source.id") + name = serializers.CharField(source="source.name") + title = serializers.CharField(source="source.title") class RelatedRfcSerializer(serializers.Serializer): @@ -144,7 +145,7 @@ class RfcMetadataSerializer(serializers.ModelSerializer): area = GroupSerializer(source="group.area", required=False) stream = StreamNameSerializer() identifiers = fields.SerializerMethodField() - draft = RelatedDraftSerializer(source="came_from_draft", read_only=True) # todo prefetch this + draft = serializers.SerializerMethodField() obsoletes = RelatedRfcSerializer(many=True, read_only=True) obsoleted_by = ReverseRelatedRfcSerializer(many=True, read_only=True) updates = RelatedRfcSerializer(many=True, read_only=True) @@ -190,6 +191,14 @@ def get_identifiers(self, doc: Document): ) return DocIdentifierSerializer(instance=identifiers, many=True).data + @extend_schema_field(RelatedDraftSerializer) + def get_draft(self, object): + try: + related_doc = object.drafts[0] + except IndexError: + return None + return RelatedDraftSerializer(related_doc).data + class RfcSerializer(RfcMetadataSerializer): """Serialize an RFC, including its metadata and text content if available""" @@ -199,3 +208,38 @@ class RfcSerializer(RfcMetadataSerializer): class Meta: model = RfcMetadataSerializer.Meta.model fields = RfcMetadataSerializer.Meta.fields + ["text"] + + +class SubseriesContentListSerializer(serializers.ListSerializer): + """ListSerializer that gets its object from item.target""" + + def to_representation(self, data): + """ + List of object instances -> List of dicts of primitive datatypes. + """ + # Dealing with nested relationships, data can be a Manager, + # so, first get a queryset from the Manager if needed + iterable = ( + data.all() if isinstance(data, BaseManager) else data + ) + # Serialize item.target instead of item itself + return [self.child.to_representation(item.target) for item in iterable] + + +class SubseriesContentSerializer(RfcMetadataSerializer): + """Serialize RFC contained in a subseries doc""" + class Meta(RfcMetadataSerializer.Meta): + list_serializer_class = SubseriesContentListSerializer + + +class SubseriesDocSerializer(serializers.ModelSerializer): + """Serialize a subseries document (e.g., a BCP or STD)""" + contents = SubseriesContentSerializer(many=True) + + class Meta: + model = Document + fields = [ + "name", + "type", + "contents", + ]