From 43ddfba967db5074873825e3dc042bf13f50306b Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 25 Jun 2025 13:28:23 -0300 Subject: [PATCH 1/5] feat: person search endpoint --- ietf/api/urls_rpc.py | 1 + ietf/api/views_rpc.py | 32 +++++++++++++++++++++ ietf/person/models.py | 2 +- rpcapi.yaml | 67 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/ietf/api/urls_rpc.py b/ietf/api/urls_rpc.py index cfdb2793072..b69755f9dc4 100644 --- a/ietf/api/urls_rpc.py +++ b/ietf/api/urls_rpc.py @@ -16,6 +16,7 @@ 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"^persons/search/", views_rpc.RpcPersonSearch.as_view()), url(r"^subject/(?P[0-9]+)/person/$", views_rpc.rpc_subject_person), ] diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index f80374a6623..d0b2c1cbbbd 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -12,6 +12,9 @@ from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.models import User +from rest_framework import generics, serializers +from rest_framework.filters import SearchFilter +from rest_framework.pagination import LimitOffsetPagination from ietf.api.ietf_utils import requires_api_token from ietf.doc.models import Document, DocHistory, RelatedDocument @@ -63,6 +66,35 @@ def rpc_persons(request): return JsonResponse(response) +class RpcLimitOffsetPagination(LimitOffsetPagination): + default_limit = 10 + max_limit = 100 + + +class RpcPersonSerializer(serializers.ModelSerializer): + picture = serializers.CharField(source="cdn_photo_url", read_only=True) + + class Meta: + model = Person + fields = ["id", "plain_name", "picture"] + read_only_fields = ["id", "plain_name", "picture"] + + +class RpcPersonSearch(generics.ListAPIView): + # n.b. the OpenAPI schema for this can be generated by running + # ietf/manage.py spectacular --file spectacular.yaml + # and extracting / touching up the rpc_person_search_list operation + api_key_endpoint = "ietf.api.views_rpc" + queryset = Person.objects.all() + serializer_class = RpcPersonSerializer + pagination_class = RpcLimitOffsetPagination + + # Searchable on all name-like fields or email addresses + filter_backends = [SearchFilter] + search_fields = ["name", "plain", "ascii", "ascii_short", "email__address"] + + + def _document_source_format(doc): submission = doc.submission() if submission is None: diff --git a/ietf/person/models.py b/ietf/person/models.py index f703cf0ad83..2be3c1cc822 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -88,7 +88,7 @@ def short(self): else: prefix, first, middle, last, suffix = self.ascii_parts() return (first and first[0]+"." or "")+(middle or "")+" "+last+(suffix and " "+suffix or "") - def plain_name(self): + def plain_name(self) -> str: if not hasattr(self, '_cached_plain_name'): if self.plain: self._cached_plain_name = self.plain diff --git a/rpcapi.yaml b/rpcapi.yaml index ef2e5559a0d..ece10962aa4 100644 --- a/rpcapi.yaml +++ b/rpcapi.yaml @@ -85,6 +85,36 @@ paths: type: object additionalProperties: type: string + + /persons/search/: + get: + operationId: rpc_person_search_list + 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/PaginatedRpcPersonList' /person/create_demo_person/: post: @@ -457,6 +487,43 @@ components: stream: type: string + PaginatedRpcPersonList: + 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/RpcPerson' + + RpcPerson: + type: object + properties: + id: + type: integer + readOnly: true + plain_name: + type: string + readOnly: true + picture: + type: string + readOnly: true + securitySchemes: ApiKeyAuth: type: apiKey From 35c469187fa564c00bb97009ce20578a41dc971d Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 26 Jun 2025 12:18:39 -0300 Subject: [PATCH 2/5] refactor: address review comments * improved naming of operation/components in API schema * reused Person schema component * added serializers_rpc.py --- ietf/api/serializers_rpc.py | 13 +++++++++++++ ietf/api/views_rpc.py | 28 +++++++++++++--------------- rpcapi.yaml | 22 +++++----------------- 3 files changed, 31 insertions(+), 32 deletions(-) create mode 100644 ietf/api/serializers_rpc.py diff --git a/ietf/api/serializers_rpc.py b/ietf/api/serializers_rpc.py new file mode 100644 index 00000000000..f6d97175f30 --- /dev/null +++ b/ietf/api/serializers_rpc.py @@ -0,0 +1,13 @@ +# Copyright The IETF Trust 2025, All Rights Reserved +from rest_framework import serializers + +from ietf.person.models import Person + + +class RpcPersonSerializer(serializers.ModelSerializer): + picture = serializers.CharField(source="cdn_photo_url", read_only=True) + + class Meta: + model = Person + fields = ["id", "plain_name", "picture"] + read_only_fields = ["id", "plain_name", "picture"] diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index d0b2c1cbbbd..d2a2c42804e 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2023-2024, All Rights Reserved +# Copyright The IETF Trust 2023-2025, All Rights Reserved import json @@ -12,11 +12,13 @@ from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.models import User -from rest_framework import generics, serializers +from drf_spectacular.utils import extend_schema_view, extend_schema +from rest_framework import generics 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 RpcPersonSerializer from ietf.doc.models import Document, DocHistory, RelatedDocument from ietf.person.models import Email, Person @@ -71,29 +73,25 @@ class RpcLimitOffsetPagination(LimitOffsetPagination): max_limit = 100 -class RpcPersonSerializer(serializers.ModelSerializer): - picture = serializers.CharField(source="cdn_photo_url", read_only=True) - - class Meta: - model = Person - fields = ["id", "plain_name", "picture"] - read_only_fields = ["id", "plain_name", "picture"] - - +@extend_schema_view( + get=extend_schema( + operation_id="search_person", + description="Get a list of persons, matching by partial name or email", + ), +) class RpcPersonSearch(generics.ListAPIView): - # n.b. the OpenAPI schema for this can be generated by running - # ietf/manage.py spectacular --file spectacular.yaml + # n.b. the OpenAPI schema for this can be generated by running + # ietf/manage.py spectacular --file spectacular.yaml # and extracting / touching up the rpc_person_search_list operation api_key_endpoint = "ietf.api.views_rpc" queryset = Person.objects.all() serializer_class = RpcPersonSerializer pagination_class = RpcLimitOffsetPagination - + # Searchable on all name-like fields or email addresses filter_backends = [SearchFilter] search_fields = ["name", "plain", "ascii", "ascii_short", "email__address"] - def _document_source_format(doc): submission = doc.submission() diff --git a/rpcapi.yaml b/rpcapi.yaml index ece10962aa4..6e6e316ca08 100644 --- a/rpcapi.yaml +++ b/rpcapi.yaml @@ -88,7 +88,8 @@ paths: /persons/search/: get: - operationId: rpc_person_search_list + operationId: search_person + description: Get a list of persons, matching by partial name or email parameters: - name: limit required: false @@ -114,7 +115,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedRpcPersonList' + $ref: '#/components/schemas/PaginatedPersonList' /person/create_demo_person/: post: @@ -487,7 +488,7 @@ components: stream: type: string - PaginatedRpcPersonList: + PaginatedPersonList: type: object required: - count @@ -509,20 +510,7 @@ components: results: type: array items: - $ref: '#/components/schemas/RpcPerson' - - RpcPerson: - type: object - properties: - id: - type: integer - readOnly: true - plain_name: - type: string - readOnly: true - picture: - type: string - readOnly: true + $ref: '#/components/schemas/Person' securitySchemes: ApiKeyAuth: From 0f107ceb37d6e774c8cf77dd1f3d76a59f91d0c2 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 26 Jun 2025 12:46:54 -0300 Subject: [PATCH 3/5] chore: RpcPersonSerializer -> PersonSerializer Better matches the hand-written schema. --- ietf/api/serializers_rpc.py | 4 ++-- ietf/api/views_rpc.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ietf/api/serializers_rpc.py b/ietf/api/serializers_rpc.py index f6d97175f30..2793690b067 100644 --- a/ietf/api/serializers_rpc.py +++ b/ietf/api/serializers_rpc.py @@ -4,8 +4,8 @@ from ietf.person.models import Person -class RpcPersonSerializer(serializers.ModelSerializer): - picture = serializers.CharField(source="cdn_photo_url", read_only=True) +class PersonSerializer(serializers.ModelSerializer): + picture = serializers.URLField(source="cdn_photo_url", read_only=True) class Meta: model = Person diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index d2a2c42804e..ed826a29ef8 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -18,7 +18,7 @@ from rest_framework.pagination import LimitOffsetPagination from ietf.api.ietf_utils import requires_api_token -from ietf.api.serializers_rpc import RpcPersonSerializer +from ietf.api.serializers_rpc import PersonSerializer from ietf.doc.models import Document, DocHistory, RelatedDocument from ietf.person.models import Email, Person @@ -85,7 +85,7 @@ class RpcPersonSearch(generics.ListAPIView): # and extracting / touching up the rpc_person_search_list operation api_key_endpoint = "ietf.api.views_rpc" queryset = Person.objects.all() - serializer_class = RpcPersonSerializer + serializer_class = PersonSerializer pagination_class = RpcLimitOffsetPagination # Searchable on all name-like fields or email addresses From f9992850e14369e519188f4f45d5fe2160134ecb Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 26 Jun 2025 13:01:01 -0300 Subject: [PATCH 4/5] fix: search for entire term, not word-by-word --- ietf/api/views_rpc.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index ed826a29ef8..f2a838f8894 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -14,6 +14,7 @@ from django.contrib.auth.models import User 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 @@ -73,6 +74,20 @@ class RpcLimitOffsetPagination(LimitOffsetPagination): max_limit = 100 +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, '') + field = CharField(trim_whitespace=False, allow_blank=True) + cleaned_value = field.run_validation(value) + return [cleaned_value] + + @extend_schema_view( get=extend_schema( operation_id="search_person", @@ -89,7 +104,7 @@ class RpcPersonSearch(generics.ListAPIView): pagination_class = RpcLimitOffsetPagination # Searchable on all name-like fields or email addresses - filter_backends = [SearchFilter] + filter_backends = [SingleTermSearchFilter] search_fields = ["name", "plain", "ascii", "ascii_short", "email__address"] From e8e8069e689cc71985f36e1fccc22a9895884864 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 26 Jun 2025 13:06:22 -0300 Subject: [PATCH 5/5] fix: only look at name/plain in search Including ascii / ascii_short might be useful eventually, but since we only show plain_name in the response it can cause confusing results. By the same reasoning we could remove email__address as well, but that's useful and I expect we'll include email addresses in our response soon anyway. --- ietf/api/views_rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index f2a838f8894..b4ea0c240ce 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -105,7 +105,7 @@ class RpcPersonSearch(generics.ListAPIView): # Searchable on all name-like fields or email addresses filter_backends = [SingleTermSearchFilter] - search_fields = ["name", "plain", "ascii", "ascii_short", "email__address"] + search_fields = ["name", "plain", "email__address"] def _document_source_format(doc):