170 lines
7.1 KiB
Python
170 lines
7.1 KiB
Python
|
|
"""Request validation and response shaping for the search endpoints."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import asdict
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from django.conf import settings
|
||
|
|
from rest_framework import serializers
|
||
|
|
|
||
|
|
from apps.search.types import SORT_OPTIONS, SORT_RELEVANCE, SearchRequest, SearchResult
|
||
|
|
|
||
|
|
MAX_QUERY_LENGTH = 200
|
||
|
|
MAX_FILTER_VALUES = 20
|
||
|
|
|
||
|
|
|
||
|
|
class CommaSeparatedField(serializers.CharField):
|
||
|
|
"""
|
||
|
|
Accepts ``?skills=python,django`` **and** repeated ``?skills=python&skills=django``.
|
||
|
|
|
||
|
|
Both spellings are common in the wild; normalising here keeps the views and
|
||
|
|
both backends free of query-string trivia.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, **kwargs: Any) -> None:
|
||
|
|
kwargs.setdefault("required", False)
|
||
|
|
kwargs.setdefault("allow_blank", True)
|
||
|
|
super().__init__(**kwargs)
|
||
|
|
|
||
|
|
def get_value(self, dictionary: Any) -> Any:
|
||
|
|
# ``QueryDict.get`` silently keeps only the last occurrence, which would
|
||
|
|
# turn ``?skills=a&skills=b`` into ``b``. ``getlist`` keeps both.
|
||
|
|
if hasattr(dictionary, "getlist"):
|
||
|
|
values = dictionary.getlist(self.field_name)
|
||
|
|
if values:
|
||
|
|
return values
|
||
|
|
return super().get_value(dictionary)
|
||
|
|
|
||
|
|
def to_internal_value(self, data: Any) -> tuple[str, ...]:
|
||
|
|
raw: list[str] = []
|
||
|
|
if isinstance(data, list | tuple):
|
||
|
|
raw = [str(item) for item in data]
|
||
|
|
elif data:
|
||
|
|
raw = [str(data)]
|
||
|
|
|
||
|
|
values: list[str] = []
|
||
|
|
for chunk in raw:
|
||
|
|
for item in chunk.split(","):
|
||
|
|
cleaned = item.strip().lower()[:128]
|
||
|
|
if cleaned and cleaned not in values:
|
||
|
|
values.append(cleaned)
|
||
|
|
if len(values) > MAX_FILTER_VALUES:
|
||
|
|
raise serializers.ValidationError(
|
||
|
|
f"At most {MAX_FILTER_VALUES} values are allowed per filter."
|
||
|
|
)
|
||
|
|
return tuple(values)
|
||
|
|
|
||
|
|
|
||
|
|
class SearchRequestSerializer(serializers.Serializer):
|
||
|
|
"""Validates and normalises the query string of ``GET /api/v1/search/``."""
|
||
|
|
|
||
|
|
q = serializers.CharField(
|
||
|
|
required=False, allow_blank=True, max_length=MAX_QUERY_LENGTH, trim_whitespace=True,
|
||
|
|
help_text="Free-text keyword. Supports quoted phrases and -exclusions.",
|
||
|
|
) # fmt: skip
|
||
|
|
skills = CommaSeparatedField(help_text="Skill names, OR-combined.")
|
||
|
|
roles = CommaSeparatedField(help_text="Job family, e.g. engineering, human_resources.")
|
||
|
|
seniority = CommaSeparatedField(help_text="Seniority level, e.g. senior, manager, cxo.")
|
||
|
|
countries = CommaSeparatedField(help_text="Country of residence.")
|
||
|
|
industries = CommaSeparatedField(help_text="Industry of the current employer.")
|
||
|
|
companies = CommaSeparatedField(help_text="Current employer name.")
|
||
|
|
min_experience = serializers.IntegerField(required=False, min_value=0, max_value=70)
|
||
|
|
max_experience = serializers.IntegerField(required=False, min_value=0, max_value=70)
|
||
|
|
sort = serializers.ChoiceField(choices=SORT_OPTIONS, required=False, default=SORT_RELEVANCE)
|
||
|
|
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||
|
|
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20)
|
||
|
|
facets = serializers.BooleanField(required=False, default=True)
|
||
|
|
|
||
|
|
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
low, high = attrs.get("min_experience"), attrs.get("max_experience")
|
||
|
|
if low is not None and high is not None and low > high:
|
||
|
|
raise serializers.ValidationError(
|
||
|
|
{"min_experience": "min_experience cannot be greater than max_experience."}
|
||
|
|
)
|
||
|
|
|
||
|
|
page, page_size = attrs.get("page", 1), attrs.get("page_size", 20)
|
||
|
|
# Deep pagination is both useless to a human and expensive for the
|
||
|
|
# engine, so it is rejected instead of silently truncated.
|
||
|
|
if page * page_size > settings.SEARCH["MAX_RESULT_WINDOW"]:
|
||
|
|
raise serializers.ValidationError(
|
||
|
|
{"page": f"Cannot page beyond {settings.SEARCH['MAX_RESULT_WINDOW']} results."}
|
||
|
|
)
|
||
|
|
return attrs
|
||
|
|
|
||
|
|
def to_search_request(self) -> SearchRequest:
|
||
|
|
data = self.validated_data
|
||
|
|
return SearchRequest(
|
||
|
|
q=data.get("q", "").strip(),
|
||
|
|
skills=data.get("skills", ()),
|
||
|
|
roles=data.get("roles", ()),
|
||
|
|
seniority=data.get("seniority", ()),
|
||
|
|
countries=data.get("countries", ()),
|
||
|
|
industries=data.get("industries", ()),
|
||
|
|
companies=data.get("companies", ()),
|
||
|
|
min_experience=data.get("min_experience"),
|
||
|
|
max_experience=data.get("max_experience"),
|
||
|
|
sort=data.get("sort", SORT_RELEVANCE),
|
||
|
|
page=data.get("page", 1),
|
||
|
|
page_size=data.get("page_size", 20),
|
||
|
|
include_facets=data.get("facets", True),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Response serializers (declared explicitly so the OpenAPI schema is accurate)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
class SearchHitSerializer(serializers.Serializer):
|
||
|
|
id = serializers.IntegerField()
|
||
|
|
score = serializers.FloatField()
|
||
|
|
full_name = serializers.CharField()
|
||
|
|
headline = serializers.CharField()
|
||
|
|
job_title = serializers.CharField()
|
||
|
|
job_title_role = serializers.CharField()
|
||
|
|
seniority_levels = serializers.ListField(child=serializers.CharField())
|
||
|
|
company = serializers.CharField()
|
||
|
|
industry = serializers.CharField()
|
||
|
|
location_name = serializers.CharField()
|
||
|
|
location_country = serializers.CharField()
|
||
|
|
years_experience = serializers.IntegerField(allow_null=True)
|
||
|
|
linkedin_url = serializers.CharField()
|
||
|
|
skills = serializers.ListField(child=serializers.CharField())
|
||
|
|
highlight = serializers.DictField(child=serializers.ListField(child=serializers.CharField()))
|
||
|
|
|
||
|
|
|
||
|
|
class FacetBucketSerializer(serializers.Serializer):
|
||
|
|
key = serializers.CharField()
|
||
|
|
count = serializers.IntegerField()
|
||
|
|
|
||
|
|
|
||
|
|
class SearchResponseSerializer(serializers.Serializer):
|
||
|
|
count = serializers.IntegerField()
|
||
|
|
page = serializers.IntegerField()
|
||
|
|
page_size = serializers.IntegerField()
|
||
|
|
total_pages = serializers.IntegerField()
|
||
|
|
took_ms = serializers.IntegerField()
|
||
|
|
backend = serializers.CharField()
|
||
|
|
results = SearchHitSerializer(many=True)
|
||
|
|
facets = serializers.DictField(child=FacetBucketSerializer(many=True))
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def from_result(result: SearchResult, request: SearchRequest) -> dict[str, Any]:
|
||
|
|
total_pages = max(1, -(-result.total // request.page_size))
|
||
|
|
return {
|
||
|
|
"count": result.total,
|
||
|
|
"page": request.page,
|
||
|
|
"page_size": request.page_size,
|
||
|
|
"total_pages": total_pages,
|
||
|
|
"took_ms": result.took_ms,
|
||
|
|
"backend": result.backend,
|
||
|
|
"results": [asdict(hit) for hit in result.hits],
|
||
|
|
"facets": {
|
||
|
|
name: [asdict(bucket) for bucket in buckets]
|
||
|
|
for name, buckets in result.facets.items()
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class SuggestResponseSerializer(serializers.Serializer):
|
||
|
|
suggestions = serializers.ListField(child=serializers.CharField())
|