diff --git a/ietf/api/urls.py b/ietf/api/urls.py index eeca2bdbd2..caf18b48e1 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -1,26 +1,30 @@ # Copyright The IETF Trust 2017-2024, All Rights Reserved +from drf_spectacular.views import SpectacularAPIView + from django.conf import settings -from django.urls import include +from django.urls import include, path from django.views.generic import TemplateView from ietf import api -from ietf.doc import views_ballot +from ietf.doc import views_ballot, api as doc_api from ietf.meeting import views as meeting_views from ietf.submit import views as submit_views from ietf.utils.urls import url from . import views as api_views +from .routers import PrefixedSimpleRouter # DRF API routing - disabled until we plan to use it -# from drf_spectacular.views import SpectacularAPIView -# from django.urls import path # from ietf.person import api as person_api -# from .routers import PrefixedSimpleRouter # core_router = PrefixedSimpleRouter(name_prefix="ietf.api.core_api") # core api router # core_router.register("email", person_api.EmailViewSet) # core_router.register("person", person_api.PersonViewSet) +# 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) + api.autodiscover() urlpatterns = [ @@ -32,7 +36,8 @@ url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()), # --- DRF API --- # path("core/", include(core_router.urls)), - # path("schema/", SpectacularAPIView.as_view()), + path("red/", include(red_router.urls)), + path("schema/", SpectacularAPIView.as_view()), # # --- Custom API endpoints, sorted alphabetically --- # Email alias information for drafts diff --git a/ietf/doc/api.py b/ietf/doc/api.py new file mode 100644 index 0000000000..9102dc028f --- /dev/null +++ b/ietf/doc/api.py @@ -0,0 +1,88 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +"""Doc API implementations""" +from django.db.models import OuterRef, Subquery, Prefetch +from django.db.models.functions import TruncDate +from django_filters import rest_framework as filters +from rest_framework import filters as drf_filters +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.viewsets import GenericViewSet + +from ietf.group.models import Group +from ietf.name.models import StreamName +from ietf.utils.timezone import RPC_TZINFO +from .models import Document, DocEvent, RelatedDocument +from .serializers import RfcMetadataSerializer, RfcStatus + + +class RfcLimitOffsetPagination(LimitOffsetPagination): + default_limit = 10 + max_limit = 500 + + +class RfcFilter(filters.FilterSet): + published = filters.DateFromToRangeFilter() + stream = filters.ModelMultipleChoiceFilter( + queryset=StreamName.objects.filter(used=True) + ) + group = filters.ModelMultipleChoiceFilter( + queryset=Group.objects.wgs(), + field_name="group__acronym", + to_field_name="acronym", + ) + area = filters.ModelMultipleChoiceFilter( + queryset=Group.objects.areas(), + field_name="group__parent__acronym", + to_field_name="acronym", + ) + status = filters.MultipleChoiceFilter( + choices=[(slug, slug) for slug in RfcStatus.status_slugs], + method=RfcStatus.filter, + ) + sort = filters.OrderingFilter( + fields=( + ("rfc_number", "number"), # ?sort=number / ?sort=-number + ("published", "published"), # ?sort=published / ?sort=-published + ), + ) + + +class RfcViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + permission_classes = [] + queryset = ( + Document.objects.filter(type_id="rfc") + .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") + .prefetch_related( + Prefetch( + "targets_related", # relationship to follow + queryset=RelatedDocument.objects.filter( + source__type_id="rfc", relationship_id="obs" + ), + to_attr="obsoleted_by", # attr to add to queryset instances + ), + Prefetch( + "targets_related", # relationship to follow + queryset=RelatedDocument.objects.filter( + source__type_id="rfc", relationship_id="updates" + ), + to_attr="updated_by", # attr to add to queryset instances + ), + ) + ) # default ordering - RfcFilter may override + + serializer_class = RfcMetadataSerializer + pagination_class = RfcLimitOffsetPagination + filter_backends = [filters.DjangoFilterBackend, drf_filters.SearchFilter] + filterset_class = RfcFilter + search_fields = ["title", "abstract"] diff --git a/ietf/doc/serializers.py b/ietf/doc/serializers.py new file mode 100644 index 0000000000..fee2e4be4b --- /dev/null +++ b/ietf/doc/serializers.py @@ -0,0 +1,153 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +"""django-rest-framework serializers""" +from dataclasses import dataclass +from typing import Literal, ClassVar + +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, RelatedDocument + + +class RfcAuthorSerializer(serializers.ModelSerializer): + """Serializer for a DocumentAuthor in a response""" + + name = fields.CharField(source="person.plain_name") + email = fields.EmailField(source="email.address", required=False) + + class Meta: + model = DocumentAuthor + fields = ["person", "name", "email", "affiliation", "country"] + + +@dataclass +class DocIdentifier: + type: Literal["doi", "issn"] + value: str + + +class DocIdentifierSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["doi", "issn"]) + value = serializers.CharField() + + +# This should become "type RfcStatusSlugT ..." when we drop pre-py3.12 support +# It should be "RfcStatusSlugT: TypeAlias ..." when we drop py3.9 support +RfcStatusSlugT = Literal[ + "standard", "bcp", "informational", "experimental", "historic", "unknown" +] + + +@dataclass +class RfcStatus: + """Helper to extract the 'Status' from an RFC document for serialization""" + + slug: RfcStatusSlugT + + # Names that aren't just the slug itself. ClassVar annotation prevents dataclass from treating this as a field. + fancy_names: ClassVar[dict[RfcStatusSlugT, str]] = { + "standard": "standards track", + "bcp": "best current practice", + } + + # ClassVar annotation prevents dataclass from treating this as a field + stdlevelname_slug_map: ClassVar[dict[str, RfcStatusSlugT]] = { + "bcp": "bcp", + "ds": "standard", # ds is obsolete + "exp": "experimental", + "hist": "historic", + "inf": "informational", + "std": "standard", + "ps": "standard", + "unkn": "unknown", + } + + # ClassVar annotation prevents dataclass from treating this as a field + status_slugs: ClassVar[list[RfcStatusSlugT]] = sorted( + set(stdlevelname_slug_map.values()) + ) + + @property + def name(self): + return RfcStatus.fancy_names.get(self.slug, self.slug) + + @classmethod + def from_document(cls, doc: Document): + """Decide the status that applies to a document""" + return cls( + slug=(cls.stdlevelname_slug_map.get(doc.std_level.slug, "unknown")), + ) + + @classmethod + def filter(cls, queryset, name, value: list[RfcStatusSlugT]): + """Filter a queryset by status + + This is basically the inverse of the from_document() method. Given a status name, filter + the queryset to those in that status. The queryset should be a Document queryset. + """ + interesting_slugs = [ + stdlevelname_slug + for stdlevelname_slug, status_slug in cls.stdlevelname_slug_map.items() + if status_slug in value + ] + if len(interesting_slugs) == 0: + return queryset.none() + return queryset.filter(std_level__slug__in=interesting_slugs) + + +class RfcStatusSerializer(serializers.Serializer): + """Status serializer for a Document instance""" + + slug = serializers.ChoiceField(choices=RfcStatus.status_slugs) + name = serializers.CharField() + + def to_representation(self, instance: Document): + return super().to_representation(instance=RfcStatus.from_document(instance)) + + +class RelatedRfcSerializer(serializers.Serializer): + id = serializers.IntegerField(source="source.id") + number = serializers.IntegerField(source="source.rfc_number") + title = serializers.CharField(source="source.title") + + +class RfcMetadataSerializer(serializers.ModelSerializer): + number = serializers.IntegerField(source="rfc_number") + published = serializers.DateField() + status = RfcStatusSerializer(source="*") + authors = RfcAuthorSerializer(many=True, source="documentauthor_set") + group = GroupSerializer() + area = GroupSerializer(source="group.area", required=False) + stream = StreamNameSerializer() + identifiers = fields.SerializerMethodField() + obsoleted_by = RelatedRfcSerializer(many=True, read_only=True) + updated_by = RelatedRfcSerializer(many=True, read_only=True) + + class Meta: + model = Document + fields = [ + "id", + "number", + "title", + "published", + "status", + "pages", + "authors", + "group", + "area", + "stream", + "identifiers", + "obsoleted_by", + "updated_by", + ] + + @extend_schema_field(DocIdentifierSerializer) + def get_identifiers(self, doc: Document): + identifiers = [] + if doc.rfc_number: + identifiers.append( + DocIdentifier(type="doi", value=f"10.17487/RFC{doc.rfc_number:04d}") + ) + return DocIdentifierSerializer(instance=identifiers, many=True).data diff --git a/ietf/group/models.py b/ietf/group/models.py index 52549e8cc1..5e0983579e 100644 --- a/ietf/group/models.py +++ b/ietf/group/models.py @@ -112,6 +112,9 @@ def active_wgs(self): def closed_wgs(self): return self.wgs().exclude(state__in=Group.ACTIVE_STATE_IDS) + def areas(self): + return self.get_queryset().filter(type="area") + def with_meetings(self): return self.get_queryset().filter(type__features__has_meetings=True) diff --git a/ietf/group/serializers.py b/ietf/group/serializers.py new file mode 100644 index 0000000000..b123e091e3 --- /dev/null +++ b/ietf/group/serializers.py @@ -0,0 +1,11 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +"""django-rest-framework serializers""" +from rest_framework import serializers + +from .models import Group + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ["acronym", "name"] diff --git a/ietf/name/serializers.py b/ietf/name/serializers.py new file mode 100644 index 0000000000..a764f56051 --- /dev/null +++ b/ietf/name/serializers.py @@ -0,0 +1,11 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +"""django-rest-framework serializers""" +from rest_framework import serializers + +from .models import StreamName + + +class StreamNameSerializer(serializers.ModelSerializer): + class Meta: + model = StreamName + fields = ["slug", "name", "desc"] diff --git a/ietf/settings.py b/ietf/settings.py index 6990037585..0788ab8c93 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -19,6 +19,7 @@ warnings.filterwarnings("ignore", module="tastypie", message="The django.utils.datetime_safe module is deprecated.") warnings.filterwarnings("ignore", module="oidc_provider", message="The django.utils.timezone.utc alias is deprecated.") warnings.filterwarnings("ignore", message="The USE_DEPRECATED_PYTZ setting,") # https://github.com/ietf-tools/datatracker/issues/5635 +warnings.filterwarnings("ignore", message="The is_dst argument to make_aware\\(\\)") # caused by django-filters when USE_DEPRECATED_PYTZ is true warnings.filterwarnings("ignore", message="The USE_L10N setting is deprecated.") # https://github.com/ietf-tools/datatracker/issues/5648 warnings.filterwarnings("ignore", message="django.contrib.auth.hashers.CryptPasswordHasher is deprecated.") # https://github.com/ietf-tools/datatracker/issues/5663 warnings.filterwarnings("ignore", message="'urllib3\\[secure\\]' extra is deprecated") @@ -454,6 +455,7 @@ def skip_unreadable_post(record): 'django_celery_beat', 'corsheaders', 'django_markup', + 'django_filters', 'oidc_provider', 'drf_spectacular', 'drf_standardized_errors', diff --git a/requirements.txt b/requirements.txt index f974113d8f..a78d2f2ec9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ django-celery-beat>=2.3.0 django-csp>=3.7 django-cors-headers>=3.11.0 django-debug-toolbar>=3.2.4 +django-filter>=24.3 django-markup>=1.5 # Limited use - need to reconcile against direct use of markdown django-oidc-provider>=0.8.1 # 0.8 dropped Django 2 support django-referrer-policy>=1.0