diff --git a/search/elastic.py b/search/elastic.py index 4607abd7..e7e60f6a 100644 --- a/search/elastic.py +++ b/search/elastic.py @@ -211,7 +211,6 @@ def _process_filters(filter_dictionary): }, } - def _process_exclude_dictionary(exclude_dictionary): """ Build a list of term fields which will be excluded from result set. diff --git a/search/filter_generator.py b/search/filter_generator.py index 5adacb86..5d716a9f 100644 --- a/search/filter_generator.py +++ b/search/filter_generator.py @@ -6,6 +6,7 @@ from .utils import _load_class, DateRange +from typing import Any, Dict, List, Optional, Union class SearchFilterGenerator: @@ -13,10 +14,44 @@ class SearchFilterGenerator: Class to provide a set of filters for the search. Users of this search app will override this class and update setting for SEARCH_FILTER_GENERATOR """ + @staticmethod + def _normalise_to_list(value: Any) -> List[Any]: + """ + Return *value* as a list without mutating it if it already is one. + """ + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + def filter_dictionary(self, *, field_filters=None, **_kwargs): + """ + Build search filters by adding a default start-date range and converting + provided field values into term or terms filters, depending on whether + each field has one or multiple values. + + Parameters + ---------- + field_filters : dict, optional + A mapping of field names to raw filter values. Each value may be + singular or an iterable; values are normalized to lists. - def filter_dictionary(self, **kwargs): - """ base implementation which filters via start_date """ - return {"start_date": DateRange(None, datetime.utcnow())} + Returns + ------- + dict + A dictionary containing date and field-based filters suitable for use + in search queries. + """ + filters = { + "start_date": DateRange(None, datetime.utcnow()) + } + if field_filters: + for field, raw in field_filters.items(): + values = self._normalise_to_list(raw) + if len(values) == 1: + filters[field] = {"term": {f"{field}.keyword": values[0]}} + else: + filters[field] = {"terms": {f"{field}.keyword": values}} + return filters def field_dictionary(self, **kwargs): """ base implementation which add course if provided """ diff --git a/search/views.py b/search/views.py index 4d0499e9..5b5d920c 100644 --- a/search/views.py +++ b/search/views.py @@ -1,48 +1,68 @@ """ handle requests for courseware search http requests """ # This contains just the url entry points to use if desired, which currently has only one +import json import logging from django.conf import settings -from django.http import JsonResponse +from django.http import JsonResponse, QueryDict from django.utils.translation import gettext as _ from django.views.decorators.http import require_POST from eventtracking import tracker as track from .api import perform_search, course_discovery_search, course_discovery_filter_fields from .initializer import SearchInitializer - # log appears to be standard name used for logger log = logging.getLogger(__name__) - -def _process_pagination_values(request): - """ process pagination requests from request parameter """ - size = 20 - page = 0 - from_ = 0 - if "page_size" in request.POST: - size = int(request.POST["page_size"]) - max_page_size = getattr(settings, "SEARCH_MAX_PAGE_SIZE", 100) - # The parens below are superfluous, but make it much clearer to the reader what is going on - if not (0 < size <= max_page_size): # pylint: disable=superfluous-parens - raise ValueError(_('Invalid page size of {page_size}').format(page_size=size)) - - if "page_index" in request.POST: - page = int(request.POST["page_index"]) - from_ = page * size +def parse_post_data(request): + """Support both JSON and form-encoded input.""" + if request.content_type == 'application/json': + try: + body = json.loads(request.body.decode('utf-8')) + except json.JSONDecodeError: + log.warning("⚠️ Malformed JSON received") + return QueryDict('', mutable=True) + + qdict = QueryDict('', mutable=True) + for key, value in body.items(): + if isinstance(value, list): + for item in value: + qdict.appendlist(key, item) + else: + qdict.update({key: value}) + return qdict + return request.POST + +def _process_pagination_values(data): + """Extract pagination info from data.""" + DEFAULT_PAGE_SIZE = 20 + DEFAULT_PAGE_INDEX = 0 + DEFAULT_MAX_PAGE_SIZE = 100 + max_page_size = getattr(settings, "SEARCH_MAX_PAGE_SIZE", DEFAULT_MAX_PAGE_SIZE) + + size = int(data.get("page_size", DEFAULT_PAGE_SIZE)) + page = int(data.get("page_index", DEFAULT_PAGE_INDEX)) + + if not (0 < size <= max_page_size): + raise ValueError(_('Invalid page size of {page_size}').format(page_size=size)) + + from_ = page * size return size, from_, page - -def _process_field_values(request, is_multivalue=False): - """ Create separate dictionary of supported filter values provided """ - get_value = request.POST.getlist if is_multivalue else request.POST.get - return { - field_key: get_value(field_key) - for field_key in request.POST - if field_key in course_discovery_filter_fields() - } - +# ----to support multiple values per key as QueryDict and regular dict like "org=astu&org=openedx" ---- +def _process_field_values(data): + filters = {} + for key in course_discovery_filter_fields(): + if isinstance(data, QueryDict): + values = data.getlist(key) + else: + values = data.get(key, []) + if not isinstance(values, list): + values = [values] + if values: + filters[key] = values + return filters @require_POST def do_search(request, course_id=None): @@ -179,14 +199,15 @@ def _course_discovery(request, is_multivalue=False): "error": _("Nothing to search") } status_code = 500 - - search_term = request.POST.get("search_string", None) - enable_course_sorting_by_start_date = request.POST.get("enable_course_sorting_by_start_date", False) - + # search_term = request.POST.get("search_string", None) + post_data = parse_post_data(request) + search_term = post_data.get("search_string", "").strip() try: - size, from_, page = _process_pagination_values(request) - field_dictionary = _process_field_values(request, is_multivalue=is_multivalue) - + size, from_, page = _process_pagination_values(post_data) + field_dictionary = _process_field_values(post_data) + # ✅ Allow searches even if search_string is empty, as long as filters are applied + if not search_term and not field_dictionary: + search_term = None # Analytics - log search request track.emit( 'edx.course_discovery.search.initiated', @@ -194,6 +215,7 @@ def _course_discovery(request, is_multivalue=False): "search_term": search_term, "page_size": size, "page_number": page, + "filters": field_dictionary, #track filters information } ) @@ -202,7 +224,6 @@ def _course_discovery(request, is_multivalue=False): size=size, from_=from_, field_dictionary=field_dictionary, - enable_course_sorting_by_start_date=enable_course_sorting_by_start_date, is_multivalue=is_multivalue, )