diff --git a/sonar/config.py b/sonar/config.py
index ef9691a19..5dd503cb7 100644
--- a/sonar/config.py
+++ b/sonar/config.py
@@ -43,7 +43,8 @@
from sonar.modules.organisations.permissions import OrganisationPermission
from sonar.modules.permissions import record_permission_factory, \
wiki_edit_permission
-from sonar.modules.query import and_term_filter, missing_field_filter
+from sonar.modules.query import and_term_filter, collection_filter, \
+ missing_field_filter
from sonar.modules.users.api import UserRecord, UserSearch
from sonar.modules.users.permissions import UserPermission
from sonar.modules.utils import get_current_language
@@ -502,16 +503,41 @@ def _(x):
RECORDS_REST_FACETS = {
'documents':
dict(aggs=dict(
- sections=dict(terms=dict(field='sections',
- size=DEFAULT_AGGREGATION_SIZE)),
+ sections=dict(
+ terms=dict(field='sections', size=DEFAULT_AGGREGATION_SIZE)),
organisation=dict(terms=dict(field='organisation.pid',
size=DEFAULT_AGGREGATION_SIZE)),
language=dict(
terms=dict(field='language.value', size=DEFAULT_AGGREGATION_SIZE)),
subject=dict(
terms=dict(field='facet_subjects', size=DEFAULT_AGGREGATION_SIZE)),
- collection=dict(terms=dict(field='collections.pid',
- size=DEFAULT_AGGREGATION_SIZE)),
+ collection={
+ 'nested': {
+ 'path': 'collections'
+ },
+ 'aggs': {
+ 'collection': {
+ 'terms': {
+ 'field': 'collections.collection0.keyword'
+ },
+ 'aggs': {
+ 'collection1': {
+ 'terms': {
+ 'field': 'collections.collection1.keyword'
+ },
+ 'aggs': {
+ 'collection2': {
+ 'terms': {
+ 'field':
+ 'collections.collection2.keyword'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
document_type=dict(
terms=dict(field='documentType', size=DEFAULT_AGGREGATION_SIZE)),
controlled_affiliation=dict(
@@ -540,7 +566,11 @@ def _(x):
'subject':
and_term_filter('facet_subjects'),
'collection':
- and_term_filter('collections.pid'),
+ collection_filter('collections.collection0.keyword'),
+ 'collection1':
+ collection_filter('collections.collection1.keyword'),
+ 'collection2':
+ collection_filter('collections.collection2.keyword'),
'document_type':
and_term_filter('documentType'),
'controlled_affiliation':
diff --git a/sonar/ext.py b/sonar/ext.py
index d2a840965..66d7179a1 100644
--- a/sonar/ext.py
+++ b/sonar/ext.py
@@ -29,6 +29,7 @@
from invenio_indexer.signals import before_record_index
from werkzeug.datastructures import MIMEAccept
+from sonar.modules.collections.receivers import enrich_collection_data
from sonar.modules.permissions import has_admin_access, has_submitter_access, \
has_superuser_access
from sonar.modules.receivers import file_deleted_listener, \
@@ -103,6 +104,9 @@ def init_app(self, app):
# Add user's full name before record index
before_record_index.connect(add_full_name, weak=False)
+ # Enrich collection's data
+ before_record_index.connect(enrich_collection_data, weak=False)
+
def init_config(self, app):
"""Initialize configuration."""
for k in dir(config_sonar):
diff --git a/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json b/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json
index 690655016..4bdad8483 100644
--- a/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json
+++ b/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json
@@ -46,7 +46,6 @@
"language"
]
}
-
},
"description": {
"title": "Descriptions",
@@ -80,6 +79,12 @@
"value",
"language"
]
+ },
+ "form": {
+ "hide": true,
+ "navigation": {
+ "essential": true
+ }
}
},
"organisation": {
@@ -106,6 +111,33 @@
}
}
},
+ "parent": {
+ "title": "Parent collection",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "$ref": {
+ "type": "string",
+ "pattern": "^https://sonar.ch/api/collections/.*?$",
+ "form": {
+ "remoteTypeahead": {
+ "type": "collections",
+ "field": "name.value.suggest",
+ "label": "label"
+ }
+ }
+ }
+ },
+ "required": [
+ "$ref"
+ ],
+ "form": {
+ "hide": true,
+ "navigation": {
+ "essential": true
+ }
+ }
+ },
"_bucket": {
"title": "Bucket UUID",
"type": "string",
@@ -169,7 +201,8 @@
},
"propertiesOrder": [
"name",
- "description"
+ "description",
+ "parent"
],
"required": [
"name"
diff --git a/sonar/modules/collections/mappings/v7/collections/collection-v1.0.0.json b/sonar/modules/collections/mappings/v7/collections/collection-v1.0.0.json
index b55961db2..4e6d306a6 100644
--- a/sonar/modules/collections/mappings/v7/collections/collection-v1.0.0.json
+++ b/sonar/modules/collections/mappings/v7/collections/collection-v1.0.0.json
@@ -54,6 +54,28 @@
}
}
},
+ "parent": {
+ "type": "object",
+ "properties": {
+ "pid": {
+ "type": "keyword"
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "text"
+ },
+ "language": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "path": {
+ "type": "text"
+ },
"_created": {
"type": "date"
},
diff --git a/sonar/modules/collections/permissions.py b/sonar/modules/collections/permissions.py
index 79f205fa1..89c322393 100644
--- a/sonar/modules/collections/permissions.py
+++ b/sonar/modules/collections/permissions.py
@@ -17,6 +17,8 @@
"""Record permissions."""
+from elasticsearch_dsl import Q
+
from sonar.modules.documents.api import DocumentSearch
from sonar.modules.organisations.api import current_organisation
from sonar.modules.permissions import RecordPermission as BaseRecordPermission
@@ -92,8 +94,12 @@ def delete(cls, user, record):
:return: True if action can be done
:rtype: bool
"""
- results = DocumentSearch().filter(
- 'term', collections__pid=record['pid']).source(includes=['pid'])
+ results = DocumentSearch().query(
+ Q('nested',
+ path='collections',
+ query=Q('bool', must=Q(
+ 'term',
+ collections__pid=record['pid'])))).source(includes=['pid'])
# Cannot remove collection associated to a record
if results.count():
diff --git a/sonar/modules/collections/query.py b/sonar/modules/collections/query.py
index 1b915fe5b..a3d68db35 100644
--- a/sonar/modules/collections/query.py
+++ b/sonar/modules/collections/query.py
@@ -17,7 +17,8 @@
"""Query."""
-from flask import current_app
+from elasticsearch_dsl import Q
+from flask import current_app, request
from sonar.modules.organisations.api import current_organisation
from sonar.modules.query import default_search_factory
@@ -36,6 +37,15 @@ def search_factory(self, search):
if current_app.config.get('SONAR_APP_DISABLE_PERMISSION_CHECKS'):
return (search, urlkwargs)
+ # Cannot suggest a record which is the current collection or the current
+ # collection is one of the parents.
+ if '.suggest' in request.args.get('q',
+ '') and request.args.get('currentPid'):
+ search = search.query(
+ Q('bool',
+ must_not=[Q('match',
+ path='/' + request.args.get('currentPid'))]))
+
# Records are not filtered for superusers.
if current_user_record.is_superuser:
return (search, urlkwargs)
diff --git a/sonar/modules/collections/receivers.py b/sonar/modules/collections/receivers.py
new file mode 100644
index 000000000..ba167742d
--- /dev/null
+++ b/sonar/modules/collections/receivers.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+#
+# Swiss Open Access Repository
+# Copyright (C) 2021 RERO
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Signal receivers for collections."""
+
+
+def enrich_collection_data(sender=None,
+ record=None,
+ json=None,
+ index=None,
+ **kwargs):
+ """Receive a signal before record is indexed, to enrich the data.
+
+ :param sender: Sender of the signal.
+ :param Record record: Record to index.
+ :param dict json: JSON that will be indexed.
+ :param str index: Name of the index in which record will be sent.
+ """
+ if not index.startswith('collections'):
+ return
+
+ def _build_path(parent, path):
+ """Recursive call to build the path from parent node to leaf.
+
+ :param parent: Parent item.
+ :param path: List of PIDs.
+ """
+ if not parent:
+ return
+
+ path.insert(0, parent['pid'])
+ _build_path(parent.get('parent'), path)
+
+ # Make the pass
+ path = [json['pid']]
+ _build_path(json.get('parent'), path)
+ path.insert(0, '')
+ json['path'] = '/'.join(path)
diff --git a/sonar/modules/collections/schemas.py b/sonar/modules/collections/schemas.py
index cbab54fbb..20a0257fb 100644
--- a/sonar/modules/collections/schemas.py
+++ b/sonar/modules/collections/schemas.py
@@ -41,6 +41,7 @@ class RecordMetadataSchema(StrictKeysMixin):
name = fields.List(fields.Dict(), required=True)
description = fields.List(fields.Dict())
organisation = fields.Dict()
+ parent = fields.Dict()
permissions = fields.Dict(dump_only=True)
label = fields.Method('get_label')
# When loading, if $schema is not provided, it's retrieved by
diff --git a/sonar/modules/collections/templates/collections/index.html b/sonar/modules/collections/templates/collections/index.html
index 5843e9894..f9ffe7de0 100644
--- a/sonar/modules/collections/templates/collections/index.html
+++ b/sonar/modules/collections/templates/collections/index.html
@@ -18,7 +18,7 @@
{%- extends config.RECORDS_UI_BASE_TEMPLATE %}
{%- block body %}
-
{{ _('Collections') }}
+{{ _('Collections') }}
{% if records | length %}
{% for record in records %}
diff --git a/sonar/modules/collections/templates/collections/item.html b/sonar/modules/collections/templates/collections/item.html
index 826931718..75f8ebc6f 100644
--- a/sonar/modules/collections/templates/collections/item.html
+++ b/sonar/modules/collections/templates/collections/item.html
@@ -30,9 +30,6 @@ {{ name }}
{{ collection.description | language_value | markdown_filter | safe }}
{% endif %}
-
-
-
diff --git a/sonar/modules/collections/views.py b/sonar/modules/collections/views.py
index b0dd115e0..1f391e8a1 100644
--- a/sonar/modules/collections/views.py
+++ b/sonar/modules/collections/views.py
@@ -17,10 +17,12 @@
"""Collections views."""
+from elasticsearch_dsl import Q
from flask import Blueprint, abort, current_app, redirect, render_template, \
url_for
from sonar.modules.collections.api import RecordSearch
+from sonar.modules.documents.api import DocumentSearch
blueprint = Blueprint('collections',
__name__,
@@ -43,7 +45,17 @@ def index(**kwargs):
records = RecordSearch().filter('term',
organisation__pid=kwargs['view']).scan()
- return render_template('collections/index.html', records=list(records))
+ def filter_result(item):
+ """Keep only collections that have documents attached."""
+ documents = DocumentSearch().query(
+ Q('nested',
+ path='collections',
+ query=Q('bool', must=Q('term', collections__pid=item['pid']))))
+
+ return documents.count()
+
+ return render_template('collections/index.html',
+ records=list(filter(filter_result, records)))
def detail(pid, record, **kwargs):
diff --git a/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json b/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json
index 0b00d884a..993924aad 100644
--- a/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json
+++ b/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json
@@ -313,7 +313,7 @@
}
},
"collections": {
- "type": "object",
+ "type": "nested",
"properties": {
"pid": {
"type": "keyword"
diff --git a/sonar/modules/documents/query.py b/sonar/modules/documents/query.py
index e9969b6d8..481494992 100644
--- a/sonar/modules/documents/query.py
+++ b/sonar/modules/documents/query.py
@@ -90,8 +90,14 @@ def search_factory(self, search, query_parser=None):
# Filter collection
if request.args.get('collection_view'):
- search = search.filter(
- 'term', collections__pid=request.args['collection_view'])
+ search = search.query(
+ Q('nested',
+ path='collections',
+ query=Q(
+ 'bool',
+ must=Q(
+ 'term',
+ collections__pid=request.args['collection_view']))))
# Admin
else:
# Filters records by user's organisation
diff --git a/sonar/modules/documents/receivers.py b/sonar/modules/documents/receivers.py
index a3f1572ad..27e1a497a 100644
--- a/sonar/modules/documents/receivers.py
+++ b/sonar/modules/documents/receivers.py
@@ -109,6 +109,27 @@ def enrich_document_data(sender=None,
# Check if record is open access.
json['isOpenAccess'] = record.is_open_access()
+ # Adds collections for building aggregations.
+ def _get_collections_pids(collection):
+ """Builds the list of collections PIDs from parent to leaf.
+
+ :param dict collection: Collection dictionary.
+ :returns: List of PIDs.
+ :rtype: list
+ """
+ pids = [collection['pid']]
+ if collection.get('parent'):
+ pids = _get_collections_pids(collection['parent']) + pids
+
+ return pids
+
+ for collection in json.get('collections', []):
+ pids = _get_collections_pids(collection)
+ i = 0
+ while i < len(pids):
+ collection[f'collection{i}'] = pids[i]
+ i += 1
+
# No files are present in record
if not record.files:
return
diff --git a/sonar/modules/documents/serializers/__init__.py b/sonar/modules/documents/serializers/__init__.py
index 347a74ce5..ea7b75ac5 100644
--- a/sonar/modules/documents/serializers/__init__.py
+++ b/sonar/modules/documents/serializers/__init__.py
@@ -102,13 +102,29 @@ def post_process_serialize_search(self, results, pid_fetcher):
if request.args.get('collection_view'):
results['aggregations'].pop('collection', None)
- # Add collection name
- for org_term in results.get('aggregations',
- {}).get('collection',
- {}).get('buckets', []):
- collection = CollectionRecord.get_record_by_pid(org_term['key'])
- if collection:
- org_term['name'] = collection['name'][0]['value']
+ if results['aggregations'].get('collection'):
+ # Normalize collections as it is a nested aggregation.
+ results['aggregations']['collection'] = results['aggregations'][
+ 'collection']['collection']
+
+ # Add collection name
+ def _process_collection_level(buckets, level=1):
+ """Recursively add collection names for each buckets."""
+ for bucket in buckets:
+ collection = CollectionRecord.get_record_by_pid(
+ bucket['key'])
+ if collection:
+ bucket['name'] = collection['name'][0]['value']
+
+ facet_name = f'collection{level}'
+ if bucket.get(facet_name):
+ level += 1
+ _process_collection_level(
+ bucket[facet_name]['buckets'], level)
+
+ _process_collection_level(
+ results.get('aggregations', {}).get('collection',
+ {}).get('buckets', []))
return super(JSONSerializer,
self).post_process_serialize_search(results, pid_fetcher)
diff --git a/sonar/modules/documents/templates/documents/record.html b/sonar/modules/documents/templates/documents/record.html
index 77fda1035..83d03d947 100644
--- a/sonar/modules/documents/templates/documents/record.html
+++ b/sonar/modules/documents/templates/documents/record.html
@@ -295,7 +295,7 @@
{% for collection in record.collections %}
-
-
+
{{ collection.name | language_value }}
diff --git a/sonar/modules/query.py b/sonar/modules/query.py
index 170fe0273..c95e5fa52 100644
--- a/sonar/modules/query.py
+++ b/sonar/modules/query.py
@@ -125,3 +125,17 @@ def missing_field_filter(field):
def inner(values):
return Q('bool', must_not=[Q('exists', field=field)])
return inner
+
+
+def collection_filter(field):
+ """Filter for collections."""
+ def inner(values):
+ must = []
+ for value in values:
+ must.append(
+ Q('nested',
+ path='collections',
+ query=Q('bool', must=Q('term', **{field: value}))))
+ return Q('bool', must=must)
+
+ return inner
diff --git a/tests/api/collections/test_collections_documents_facets.py b/tests/api/collections/test_collections_documents_facets.py
index 9fba56462..9e065c3bb 100644
--- a/tests/api/collections/test_collections_documents_facets.py
+++ b/tests/api/collections/test_collections_documents_facets.py
@@ -39,6 +39,34 @@ def test_list(app, db, client, document, collection, superuser):
'2',
'doc_count':
1,
+ 'collection1': {
+ 'doc_count_error_upper_bound':
+ 0,
+ 'sum_other_doc_count':
+ 0,
+ 'buckets': [{
+ 'key': '3',
+ 'doc_count': 1,
+ 'collection2': {
+ 'doc_count_error_upper_bound': 0,
+ 'sum_other_doc_count': 0,
+ 'buckets': []
+ },
+ 'name': 'Collection name'
+ }]
+ },
'name':
- 'Collection name'
+ 'Parent collection'
}]
+
+ # Test with collection filter
+ login_user_via_session(client, email=superuser['email'])
+ res = client.get(url_for('invenio_records_rest.doc_list', collection='2'))
+ assert res.status_code == 200
+ assert res.json['hits']['total']['value'] == 1
+
+ # Test with sub collection filter
+ login_user_via_session(client, email=superuser['email'])
+ res = client.get(url_for('invenio_records_rest.doc_list', collection1='3'))
+ assert res.status_code == 200
+ assert res.json['hits']['total']['value'] == 1
diff --git a/tests/api/collections/test_collections_suggestions.py b/tests/api/collections/test_collections_suggestions.py
new file mode 100644
index 000000000..37d22cb07
--- /dev/null
+++ b/tests/api/collections/test_collections_suggestions.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+#
+# Swiss Open Access Repository
+# Copyright (C) 2021 RERO
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Test collections suggestions."""
+
+from flask import url_for
+from invenio_accounts.testutils import login_user_via_session
+
+
+def test_suggestions(client, admin, collection, make_collection):
+ """Test list collections permissions."""
+ make_collection('org')
+
+ login_user_via_session(client, email=admin['email'])
+
+ # 2 results
+ res = client.get(url_for('invenio_records_rest.coll_list'))
+ assert res.status_code == 200
+ assert res.json['hits']['total']['value'] == 2
+
+ # Suggestions does not return the collection corresponding to current PID
+ res = client.get(
+ url_for('invenio_records_rest.coll_list',
+ q='name.value.suggest:Collection name',
+ currentPid=collection['pid']))
+ assert res.status_code == 200
+ assert res.json['hits']['total']['value'] == 1
diff --git a/tests/conftest.py b/tests/conftest.py
index e6e51e00a..939a33c9f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -766,12 +766,30 @@ def _make_collection(organisation=None):
@pytest.fixture()
def collection(app, db, es, admin, organisation, collection_json):
"""Collection fixture."""
+ parent_collection_json = {
+ 'name': [{
+ 'language': 'eng',
+ 'value': 'Parent collection'
+ }]
+ }
+ parent_collection = CollectionRecord.create(parent_collection_json,
+ dbcommit=True,
+ with_bucket=True)
+ parent_collection.commit()
+ parent_collection.reindex()
+ db.session.commit()
+
json = copy.deepcopy(collection_json)
json['organisation'] = {
'$ref':
'https://sonar.ch/api/organisations/{pid}'.format(
pid=organisation['pid'])
}
+ json['parent'] = {
+ '$ref':
+ 'https://sonar.ch/api/collections/{pid}'.format(
+ pid=parent_collection['pid'])
+ }
collection = CollectionRecord.create(json, dbcommit=True, with_bucket=True)
collection.commit()
diff --git a/tests/ui/collections/test_collections_views.py b/tests/ui/collections/test_collections_views.py
index 0147b7544..6a3a4e44d 100644
--- a/tests/ui/collections/test_collections_views.py
+++ b/tests/ui/collections/test_collections_views.py
@@ -20,15 +20,29 @@
from flask import url_for
-def test_index(app, client, organisation):
+def test_index(app, db, client, document, organisation, collection):
"""Test list of collections."""
# No collection index for global view
assert client.get(url_for('collections.index',
view='global')).status_code == 404
- # OK in organisation context
- assert client.get(url_for('collections.index',
- view='org')).status_code == 200
+ # OK in organisation context but no collection listed because there's no
+ # document linked.
+ result = client.get(url_for('collections.index', view='org'))
+ assert result.status_code == 200
+ assert b'No collection found' in result.data
+
+ # OK in organisation context and the collection has a document linked.
+ document['collections'] = [{
+ '$ref':
+ 'https://sonar.ch/api/collections/{pid}'.format(pid=collection['pid'])
+ }]
+ document.commit()
+ db.session.commit()
+ document.reindex()
+ result = client.get(url_for('collections.index', view='org'))
+ assert result.status_code == 200
+ assert b'Collection name
' in result.data
def test_detail(app, client, organisation, collection):