From 478c1265251963507e1f4e722262475e1d4c5de7 Mon Sep 17 00:00:00 2001 From: Bertrand Zuchuat Date: Tue, 10 Oct 2023 17:51:09 +0200 Subject: [PATCH] document: advanced search config and endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adds configuration for enabled/disabled advanced search. * Adds a new field in the document schema to group call number for holdings and items. * Adds a new endpoint for advanced search config. ⚠️ update ES mapping and reindex ES documents Co-Authored-by: Bertrand Zuchuat --- rero_ils/config.py | 184 ++++++++++++++++++ rero_ils/modules/documents/api_views.py | 61 +++++- .../v7/documents/document-v0.0.1.json | 19 +- rero_ils/modules/patrons/views.py | 2 + tests/api/documents/test_documents_rest.py | 68 +++++++ 5 files changed, 327 insertions(+), 7 deletions(-) diff --git a/rero_ils/config.py b/rero_ils/config.py index a6d80957e2..4f58286b9e 100644 --- a/rero_ils/config.py +++ b/rero_ils/config.py @@ -159,6 +159,9 @@ def _(x): RERO_ILS_ILL_REQUEST_ON_GLOBAL_VIEW = True RERO_ILS_ILL_DEFAULT_SOURCE = 'RERO +' +# DOCUMENT ADVANCED SEARCH +RERO_ILS_APP_DOCUMENT_ADVANCED_SEARCH = True + # Rate limiting # ============= #: Storage for ratelimiter. @@ -3639,3 +3642,184 @@ def _(x): RERO_ILS_PASSWORD_SPECIAL_CHAR = False RERO_ILS_PASSWORD_GENERATOR = 'rero_ils.modules.utils:password_generator' RERO_ILS_PASSWORD_VALIDATOR = 'rero_ils.modules.utils:password_validator' + +# ADVANCED SEARCH CONFIG +# ====================== +def search_type(field): + """Search type options. + + :param: field: the field key. + :return: a list of options. + """ + output = []; + if field not in [ + 'canton', 'country', 'rdaCarrierType', 'rdaContentType', + 'rdaMediaType' + ]: + output.append({'label': _('contains'), 'value': 'contains'}) + if field not in ['identifiedBy', 'isbn', 'issn']: + output.append({'label': _('phrase'), 'value': 'phrase'}) + return output + +RERO_ILS_APP_ADVANCED_SEARCH_CONFIG = [ + { + 'label': 'Title', + 'value': 'title', + 'field': 'title.*', + 'options': { + 'search_type': search_type('title') + } + }, + { + 'label': 'Responsibility statement', + 'value': 'responsibilityStatement', + 'field': 'responsibilityStatement.value', + 'options': { + 'search_type': search_type('responsibilityStatement') + } + }, + { + 'label': 'Contribution', + 'value': 'contribution', + 'field': 'contribution.entity.*', + 'options': { + 'search_type': search_type('contribution') + } + }, + { + 'label': 'Country', + 'value': 'country', + 'field': 'provisionActivity.place.country', + 'options': { + 'search_type': search_type('country') + } + }, + { + 'label': 'Canton', + 'value': 'canton', + 'field': 'provisionActivity.place.canton', + 'options': { + 'search_type': search_type('canton') + } + }, + { + 'label': 'Provision activity statement', + 'value': 'provisionActivityStatement', + 'field': 'provisionActivity._text.value', + 'options': { + 'search_type': search_type('provisionActivityStatement') + } + }, + { + 'label': 'Series statement', + 'value': 'seriesStatement', + 'field': 'seriesStatement.*', + 'options': { + 'search_type': search_type('seriesStatement') + } + }, + { + 'label': 'Identifier', + 'value': 'identifiedBy', + 'field': 'identifiedBy.value', + 'options': { + 'search_type': search_type('identifiedBy') + } + }, + { + 'label': 'ISBN', + 'value': 'isbn', + 'field': 'isbn', + 'options': { + 'search_type': search_type('isbn') + } + }, + { + 'label': 'ISSN', + 'value': 'issn', + 'field': 'issn', + 'options': { + 'search_type': search_type('issn') + } + }, + { + 'label': 'Genre, form', + 'value': 'genreForm', + 'field': 'genreForm.entity.*', + 'options': { + 'search_type': search_type('genreForm') + } + }, + { + 'label': 'Subject', + 'value': 'subjects', + 'field': 'subjects.entity.*', + 'options': { + 'search_type': search_type('subjects') + } + }, + { + 'label': 'Call number', + 'value': 'callNumber', + 'field': 'call_numbers', + 'options': { + 'search_type': search_type('callNumber') + } + }, + { + 'label': 'Local fields (document)', + 'value': 'documentLocalFields', + 'field': 'local_fields.*', + 'options': { + 'search_type': search_type('documentLocalFields') + } + }, + { + 'label': 'Local fields (holdings)', + 'value': 'holdingsLocalFields', + 'field': 'holdings.local_fields.*', + 'options': { + 'search_type': search_type('holdingsLocalFields') + } + }, + { + 'label': 'Local fields (items)', + 'value': 'itemLocalFields', + 'field': 'holdings.items.local_fields.*', + 'options': { + 'search_type': search_type('itemLocalFields') + } + }, + { + 'label': 'Classification', + 'value': 'classification', + 'field': 'classification.*', + 'options': { + 'search_type': search_type('classification') + } + }, + { + 'label': 'RDA content type', + 'value': 'rdaContentType', + 'field': 'contentMediaCarrier.contentType', + 'options': { + 'search_type': search_type('rdaContentType') + } + }, + { + 'label': 'RDA media type', + 'value': 'rdaMediaType', + 'field': 'contentMediaCarrier.mediaType', + 'options': { + 'search_type': search_type('rdaMediaType') + } + }, + { + 'label': 'RDA carrier type', + 'value': 'rdaCarrierType', + 'field': 'contentMediaCarrier.carrierType', + 'options': { + 'search_type': search_type('rdaCarrierType') + } + }, +] diff --git a/rero_ils/modules/documents/api_views.py b/rero_ils/modules/documents/api_views.py index d1a6b2eed9..dc5982512a 100644 --- a/rero_ils/modules/documents/api_views.py +++ b/rero_ils/modules/documents/api_views.py @@ -18,8 +18,14 @@ """Blueprint for document api.""" -from flask import Blueprint, abort, jsonify +from functools import cmp_to_key + +from flask import Blueprint, abort, current_app, jsonify from flask import request as flask_request +from invenio_jsonschemas import current_jsonschemas +from invenio_jsonschemas.errors import JSONSchemaNotFound + +from rero_ils.modules.decorators import check_logged_as_librarian from .api import Document from .utils import get_remote_cover @@ -50,3 +56,56 @@ def document_availability(pid): return jsonify({ 'available': Document.is_available(pid, view_code) }) + + +@api_blueprint.route('/advanced-search-config') +@cached(timeout=300, query_string=True) +@check_logged_as_librarian +def advanced_search_config(): + """Advanced search config.""" + + def sort_medias(a, b): + """Sort only media start with rda in label.""" + a, b = a['label'], b['label'] + if a.startswith('rda') and b.startswith('rda'): + return a > b + elif a.startswith('rda'): + return -1 + elif b.startswith('rda'): + return 1 + else: + return a > b + + try: + cantons = current_jsonschemas.get_schema('common/cantons-v0.0.1.json') + countries = current_jsonschemas.get_schema( + 'common/countries-v0.0.1.json') + medias = current_jsonschemas.get_schema( + 'documents/document_content_media_carrier-v0.0.1.json') + except JSONSchemaNotFound: + abort(404) + + media_items = medias['contentMediaCarrier']['items']['oneOf'] + media_types = [] + carrier_types = [] + for item in media_items: + if rda_type := item.get('properties', {}).get('mediaType', {}): + data = rda_type.get('title') + media_types.append({'label': data, 'value': data}) + if rda_type := item.get('properties', {}).get('carrierType', {}): + for option in rda_type.get('form', {}).get('options'): + if option not in carrier_types: + carrier_types.append(option) + return jsonify({ + 'fieldsConfig': current_app.config.get( + 'RERO_ILS_APP_ADVANCED_SEARCH_CONFIG', []), + 'fieldsData': { + 'country': countries['country']['form']['options'], + 'canton': cantons['canton']['form']['options'], + 'rdaContentType': medias['definitions']['contentType']['items'] + ['form']['options'], + 'rdaMediaType': sorted(media_types, key=cmp_to_key(sort_medias)), + 'rdaCarrierType': sorted( + carrier_types, key=cmp_to_key(sort_medias)) + } + }) diff --git a/rero_ils/modules/documents/mappings/v7/documents/document-v0.0.1.json b/rero_ils/modules/documents/mappings/v7/documents/document-v0.0.1.json index d85f87ee35..cd710c0ab1 100644 --- a/rero_ils/modules/documents/mappings/v7/documents/document-v0.0.1.json +++ b/rero_ils/modules/documents/mappings/v7/documents/document-v0.0.1.json @@ -526,10 +526,10 @@ "type": "object", "properties": { "canton": { - "type": "text" + "type": "keyword" }, "country": { - "type": "text" + "type": "keyword" }, "identifiedBy": { "type": "object", @@ -1139,10 +1139,12 @@ } }, "call_number": { - "type": "text" + "type": "text", + "copy_to": "call_numbers" }, "second_call_number": { - "type": "text" + "type": "text", + "copy_to": "call_numbers" }, "index": { "type": "text" @@ -1166,10 +1168,12 @@ "type": "keyword" }, "call_number": { - "type": "text" + "type": "text", + "copy_to": "call_numbers" }, "second_call_number": { - "type": "text" + "type": "text", + "copy_to": "call_numbers" }, "status": { "type": "keyword" @@ -2135,6 +2139,9 @@ } } }, + "call_numbers": { + "type": "text" + }, "_draft": { "type": "boolean" }, diff --git a/rero_ils/modules/patrons/views.py b/rero_ils/modules/patrons/views.py index 621321d1b8..23e9ae6a7e 100644 --- a/rero_ils/modules/patrons/views.py +++ b/rero_ils/modules/patrons/views.py @@ -134,6 +134,8 @@ def logged_user(): 'agentLabelOrder': config.get('RERO_ILS_AGENTS_LABEL_ORDER', {}), 'agentSources': config.get('RERO_ILS_AGENTS_SOURCES', []), 'operationLogs': config.get('RERO_ILS_ENABLE_OPERATION_LOG', []), + 'documentAdvancedSearch': config.get( + 'RERO_ILS_APP_DOCUMENT_ADVANCED_SEARCH', False), 'userProfile': { 'readOnly': config.get( 'RERO_PUBLIC_USERPROFILES_READONLY', False), diff --git a/tests/api/documents/test_documents_rest.py b/tests/api/documents/test_documents_rest.py index fe15918cae..3c4a2cd477 100644 --- a/tests/api/documents/test_documents_rest.py +++ b/tests/api/documents/test_documents_rest.py @@ -728,3 +728,71 @@ def test_document_current_library_on_request_parameter( .scan()) assert oplg.library.value == lib_martigny_bourg.pid db.session.rollback() + + +def test_document_advanced_search_config(app, db, client, + system_librarian_martigny, document): + """Test for advanced search config.""" + def check_field_data(key, field_data, data): + """Check content of the field data.""" + field_data = field_data.get(key, []) + assert 0 < len(field_data) + assert data == field_data[0] + + config_url = url_for('api_documents.advanced_search_config') + + res = client.get(config_url) + assert res.status_code == 401 + + login_user_via_session(client, system_librarian_martigny.user) + + res = client.get(config_url) + assert res.status_code == 200 + + json = res.json + assert 'fieldsConfig' in json + assert 'fieldsData' in json + + fields_config_data = json.get('fieldsConfig') + assert 0 < len(fields_config_data) + assert { + 'field': 'title.*', + 'label': 'Title', + 'value': 'title', + 'options': { + 'search_type': [ + {'label': 'contains', 'value': 'contains'}, + {'label': 'exact', 'value': 'phrase'}, + ] + }} \ + == fields_config_data[0] + + # Country: Only Phrase on search type options. + assert { + 'field': 'provisionActivity.place.country', + 'label': 'Country', + 'value': 'country', + 'options': { + 'search_type': [ + {'label': 'exact', 'value': 'phrase'}, + ] + }} \ + == fields_config_data[3] + + field_data = json.get('fieldsData') + data_keys = [ + 'canton', 'country', 'rdaCarrierType', + 'rdaContentType', 'rdaMediaType' + ] + assert data_keys == list(field_data.keys()) + + check_field_data('canton', field_data, + {'label': 'canton_ag', 'value': 'ag'}) + check_field_data('country', field_data, + {'label': 'country_aa', 'value': 'aa'}) + check_field_data('rdaCarrierType', field_data, + {'label': 'rdact:1002', 'value': 'rdact:1002'}) + check_field_data('rdaContentType', field_data, + {'label': 'rdaco:1002', 'value': 'rdaco:1002'}) + check_field_data('rdaMediaType', field_data, + {'label': 'rdamt:1001', 'value': 'rdamt:1001'})