diff --git a/ietf/api/serializers_rpc.py b/ietf/api/serializers_rpc.py new file mode 100644 index 0000000000..2793690b06 --- /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 PersonSerializer(serializers.ModelSerializer): + picture = serializers.URLField(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/urls_rpc.py b/ietf/api/urls_rpc.py index cfdb279307..b69755f9dc 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 f80374a662..b4ea0c240c 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,8 +12,14 @@ 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 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.person.models import Email, Person @@ -63,6 +69,45 @@ def rpc_persons(request): return JsonResponse(response) +class RpcLimitOffsetPagination(LimitOffsetPagination): + default_limit = 10 + 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", + 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 + # and extracting / touching up the rpc_person_search_list operation + api_key_endpoint = "ietf.api.views_rpc" + queryset = Person.objects.all() + serializer_class = PersonSerializer + pagination_class = RpcLimitOffsetPagination + + # Searchable on all name-like fields or email addresses + filter_backends = [SingleTermSearchFilter] + search_fields = ["name", "plain", "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 f703cf0ad8..2be3c1cc82 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 ef2e5559a0..6e6e316ca0 100644 --- a/rpcapi.yaml +++ b/rpcapi.yaml @@ -85,6 +85,37 @@ paths: 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: @@ -457,6 +488,30 @@ components: 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