Skip to content

Commit b24c8e8

Browse files
[3.3.x][Fixes #8689] Extend the ResourceBase metadata model with an opaque JSONField (#8727)
* [Fixes #8689] Extend the ResourceBase metadata model with an opaque JSONField * [Fixes #8689] Fix missing resource_type for new form instances * [Fixes #8689] Add test and UI fix for doc, maps and geoapps * [Fixes #8689] Fix flakee8 formatting * [Fixes #8689] Extra metadata json saved with format * [Fixes #8689] Refactor validation def, start defining endpoint for API * [Fixes #8689] Definition of extra-metadata endpoints for resources * [Fixes #8689] Converting metadata from jsonfield to manytomany relation * [Fixes #8689] Fix views with new relation and prettify json on UI * [Fixes #8689] Fix serializer * [Fixes #8689] Fix custom metadata endpoint, update metadata schema * [Fixes #8689] Fix flake8 issues * [Fixes #8689] Remove endpoint from each resorce, keep it only on base resource * [Fixes #8689] Fix broken tests * [Fixes #8689] Add metadata filtering in API v1 * [Fixes #8689] Add test for metadata filtering in API v1 * [Fixes #8689] Fix some of broken tests * [Fixes #8689] fix flake8 * [Fixes #8689] fix tests * [Fixes #8689] removed typo on settings.py * [Fixes #8689] fix broken build * [Fixes #8689] fix minor error on filter convertion * [Fixes #8689] fix flake8 * [Fixes #8689] Update default schema structure
1 parent 410b2c0 commit b24c8e8

29 files changed

+756
-32
lines changed

geonode/api/resourcebase_api.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ class CommonMetaApi:
9595
'date': ALL,
9696
'purpose': ALL,
9797
'uuid': ALL_WITH_RELATIONS,
98-
'abstract': ALL
98+
'abstract': ALL,
99+
'metadata': ALL_WITH_RELATIONS
99100
}
100101
ordering = ['date', 'title', 'popular_count']
101102
max_limit = None
@@ -168,6 +169,9 @@ def build_filters(self, filters=None, ignore_bad_filters=False, **kwargs):
168169
orm_filters.update({'polymorphic_ctype__model__in': [filt.lower() for filt in filters.getlist('app_type__in')]})
169170
if 'extent' in filters:
170171
orm_filters.update({'extent': filters['extent']})
172+
_metadata = {f"metadata__{_k}": _v for _k, _v in filters.items() if _k.startswith('metadata__')}
173+
if _metadata:
174+
orm_filters.update({"metadata_filters": _metadata})
171175
orm_filters['f_method'] = filters['f_method'] if 'f_method' in filters else 'and'
172176
if not settings.SEARCH_RESOURCES_EXTENDED:
173177
return self._remove_additional_filters(orm_filters)
@@ -186,6 +190,9 @@ def apply_filters(self, request, applicable_filters):
186190
metadata_only = applicable_filters.pop('metadata_only', False)
187191
filtering_method = applicable_filters.pop('f_method', 'and')
188192
polyphormic_model = applicable_filters.pop('polymorphic_ctype__model__in', None)
193+
194+
metadata_filters = applicable_filters.pop('metadata_filters', None)
195+
189196
if filtering_method == 'or':
190197
filters = Q()
191198
for f in applicable_filters.items():
@@ -234,6 +241,9 @@ def apply_filters(self, request, applicable_filters):
234241
if keywords:
235242
filtered = self.filter_h_keywords(filtered, keywords)
236243

244+
if metadata_filters:
245+
filtered = filtered.filter(**metadata_filters)
246+
237247
# return filtered
238248
return get_visible_resources(
239249
filtered,
@@ -589,6 +599,9 @@ def format_objects(self, objects):
589599
except Exception as e:
590600
logger.exception(e)
591601

602+
if formatted_obj.get('metadata', None):
603+
formatted_obj['metadata'] = [model_to_dict(_m) for _m in formatted_obj['metadata']]
604+
592605
formatted_objects.append(formatted_obj)
593606

594607
return formatted_objects

geonode/api/tests.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from guardian.shortcuts import get_anonymous_user
3131

3232
from geonode import geoserver
33+
from geonode.base.models import ExtraMetadata
3334
from geonode.maps.models import Map
3435
from geonode.layers.models import Layer
3536
from geonode.utils import check_ogc_backend
@@ -360,6 +361,35 @@ def test_category_filters(self):
360361
self.assertValidJSONResponse(resp)
361362
self.assertEqual(len(self.deserialize(resp)['objects']), 5)
362363

364+
def test_metadata_filters(self):
365+
"""Test category filtering"""
366+
_r = Layer.objects.first()
367+
_m = ExtraMetadata.objects.create(
368+
resource=_r,
369+
metadata={
370+
"name": "metadata-updated",
371+
"slug": "metadata-slug-updated",
372+
"help_text": "this is the help text-updated",
373+
"field_type": "str-updated",
374+
"value": "my value-updated",
375+
"category": "category"
376+
}
377+
)
378+
_r.metadata.add(_m)
379+
# check we get the correct layers number returnered filtering on one
380+
# and then two different categories
381+
filter_url = f"{self.list_url}?metadata__category=category"
382+
383+
resp = self.api_client.get(filter_url)
384+
self.assertValidJSONResponse(resp)
385+
self.assertEqual(len(self.deserialize(resp)['objects']), 1)
386+
387+
filter_url = f"{self.list_url}?metadata__category=not-existing-category"
388+
389+
resp = self.api_client.get(filter_url)
390+
self.assertValidJSONResponse(resp)
391+
self.assertEqual(len(self.deserialize(resp)['objects']), 0)
392+
363393
def test_tag_filters(self):
364394
"""Test keywords filtering"""
365395

geonode/base/api/serializers.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
#
1818
#########################################################################
19+
from django.db.models.query import QuerySet
1920
from slugify import slugify
2021
from urllib.parse import urljoin
2122

@@ -35,15 +36,16 @@
3536

3637
from geonode.favorite.models import Favorite
3738
from geonode.base.models import (
38-
ResourceBase,
39+
ExtraMetadata,
3940
HierarchicalKeyword,
41+
License,
4042
Region,
43+
ResourceBase,
4144
RestrictionCodeType,
42-
License,
43-
TopicCategory,
4445
SpatialRepresentationType,
4546
ThesaurusKeyword,
46-
ThesaurusKeywordLabel
47+
ThesaurusKeywordLabel,
48+
TopicCategory,
4749
)
4850
from geonode.groups.models import (
4951
GroupCategory,
@@ -263,6 +265,24 @@ def get_attribute(self, instance):
263265
return build_absolute_uri(instance.detail_url)
264266

265267

268+
class ExtraMetadataSerializer(DynamicModelSerializer):
269+
class Meta:
270+
model = ExtraMetadata
271+
name = 'ExtraMetadata'
272+
fields = ('pk', 'metadata')
273+
274+
def to_representation(self, obj):
275+
276+
if isinstance(obj, QuerySet):
277+
out = []
278+
for el in obj:
279+
out.append({**{"id": el.id}, **el.metadata})
280+
return out
281+
elif isinstance(obj, list):
282+
return obj
283+
return {**{"id": obj.id}, **obj.metadata}
284+
285+
266286
class ThumbnailUrlField(DynamicComputedField):
267287

268288
def __init__(self, **kwargs):
@@ -381,6 +401,8 @@ def __init__(self, *args, **kwargs):
381401
self.fields['spatial_representation_type'] = DynamicRelationField(
382402
SpatialRepresentationTypeSerializer, embed=True, many=False)
383403

404+
metadata = DynamicRelationField(ExtraMetadataSerializer, embed=False, many=True, deferred=True)
405+
384406
class Meta:
385407
model = ResourceBase
386408
name = 'resource'
@@ -397,7 +419,7 @@ class Meta:
397419
'popular_count', 'share_count', 'rating', 'featured', 'is_published', 'is_approved',
398420
'detail_url', 'embed_url', 'created', 'last_updated',
399421
'raw_abstract', 'raw_purpose', 'raw_constraints_other',
400-
'raw_supplemental_information', 'raw_data_quality_statement', 'metadata_only', 'processed'
422+
'raw_supplemental_information', 'raw_data_quality_statement', 'metadata_only', 'processed', "metadata"
401423
# TODO
402424
# csw_typename, csw_schema, csw_mdsource, csw_insert_date, csw_type, csw_anytext, csw_wkt_geometry,
403425
# metadata_uploaded, metadata_uploaded_preserve, metadata_xml,

geonode/base/api/tests.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,18 @@
3636

3737
from geonode import geoserver
3838
from geonode.layers.models import Layer
39+
from geonode.tests.base import GeoNodeBaseTestSupport
3940
from geonode.utils import check_ogc_backend, set_resource_default_links
4041
from geonode.favorite.models import Favorite
4142
from geonode.documents.models import Document
4243
from geonode.base.utils import build_absolute_uri
4344
from geonode.thumbs.exceptions import ThumbnailError
44-
from geonode.base.populate_test_data import create_models
45+
from geonode.base.populate_test_data import create_models, create_single_layer
4546
from geonode.security.utils import get_resources_with_perms
4647

4748
from geonode.base.models import (
4849
CuratedThumbnail,
50+
ExtraMetadata,
4951
HierarchicalKeyword,
5052
Region,
5153
ResourceBase,
@@ -1021,3 +1023,64 @@ def test_set_thumbnail_from_bbox_from_logged_user_for_existing_dataset_raise_exp
10211023
}
10221024
self.assertEqual(response.status_code, 500)
10231025
self.assertEqual(expected, response.json())
1026+
1027+
1028+
class TestExtraMetadataBaseApi(GeoNodeBaseTestSupport):
1029+
def setUp(self):
1030+
self.layer = create_single_layer('single_layer')
1031+
self.metadata = {
1032+
"filter_header": "Foo Filter header",
1033+
"field_name": "metadata-name",
1034+
"field_label": "this is the help text",
1035+
"field_value": "foo"
1036+
}
1037+
m = ExtraMetadata.objects.create(
1038+
resource=self.layer,
1039+
metadata=self.metadata
1040+
)
1041+
self.layer.metadata.add(m)
1042+
self.mdata = ExtraMetadata.objects.first()
1043+
1044+
def test_get_will_return_the_list_of_extra_metadata(self):
1045+
self.client.login(username="admin", password="admin")
1046+
url = reverse('base-resources-extra-metadata', args=[self.layer.id])
1047+
response = self.client.get(url, content_type='application/json')
1048+
self.assertTrue(200, response.status_code)
1049+
expected = [
1050+
{**{"id": self.mdata.id}, **self.metadata}
1051+
]
1052+
self.assertEqual(expected, response.json())
1053+
1054+
def test_put_will_update_the_whole_metadata(self):
1055+
self.client.login(username="admin", password="admin")
1056+
url = reverse('base-resources-extra-metadata', args=[self.layer.id])
1057+
input_metadata = {
1058+
"id": self.mdata.id,
1059+
"filter_header": "Foo Filter header",
1060+
"field_name": "metadata-updated",
1061+
"field_label": "this is the help text",
1062+
"field_value": "foo"
1063+
}
1064+
response = self.client.put(url, data=[input_metadata], content_type='application/json')
1065+
self.assertTrue(200, response.status_code)
1066+
self.assertEqual([input_metadata], response.json())
1067+
1068+
def test_post_will_add_new_metadata(self):
1069+
self.client.login(username="admin", password="admin")
1070+
url = reverse('base-resources-extra-metadata', args=[self.layer.id])
1071+
input_metadata = {
1072+
"filter_header": "Foo Filter header",
1073+
"field_name": "metadata-updated",
1074+
"field_label": "this is the help text",
1075+
"field_value": "foo"
1076+
}
1077+
response = self.client.post(url, data=[input_metadata], content_type='application/json')
1078+
self.assertTrue(201, response.status_code)
1079+
self.assertEqual(2, len(response.json()))
1080+
1081+
def test_delete_will_delete_single_metadata(self):
1082+
self.client.login(username="admin", password="admin")
1083+
url = reverse('base-resources-extra-metadata', args=[self.layer.id])
1084+
response = self.client.delete(url, data=[self.mdata.id], content_type='application/json')
1085+
self.assertTrue(200, response.status_code)
1086+
self.assertEqual([], response.json())

geonode/base/api/views.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from geonode.favorite.models import Favorite
3939
from geonode.thumbs.exceptions import ThumbnailError
4040
from geonode.thumbs.thumbnails import create_thumbnail
41-
from geonode.base.models import HierarchicalKeyword, Region, ResourceBase, TopicCategory, ThesaurusKeyword
41+
from geonode.base.models import ExtraMetadata, HierarchicalKeyword, Region, ResourceBase, TopicCategory, ThesaurusKeyword
4242
from geonode.base.api.filters import DynamicSearchFilter, ExtentFilter, FavoriteFilter
4343
from geonode.groups.models import GroupProfile, GroupMember
4444
from geonode.layers.models import Layer
@@ -70,6 +70,8 @@
7070
ThesaurusKeywordSerializer,
7171
)
7272
from .pagination import GeoNodeApiPagination
73+
from geonode.base.api.serializers import ExtraMetadataSerializer
74+
from geonode.base.utils import validate_extra_metadata
7375

7476
import logging
7577

@@ -466,3 +468,84 @@ def set_thumbnail_from_bbox(self, request, resource_id):
466468
traceback.print_exc()
467469
logger.error(e)
468470
return Response(data={"message": e.args[0], "success": False}, status=500, exception=True)
471+
472+
@extend_schema(
473+
methods=["get", "put", "delete", "post"], description="Get/Update/Delete/Add extra metadata for resource"
474+
)
475+
@action(
476+
detail=True,
477+
methods=["get", "put", "delete", "post"],
478+
permission_classes=[
479+
IsOwnerOrAdmin,
480+
],
481+
url_path=r"extra_metadata", # noqa
482+
url_name="extra-metadata",
483+
)
484+
def extra_metadata(self, request, pk=None):
485+
_obj = self.get_object()
486+
if request.method == "GET":
487+
# get list of available metadata
488+
queryset = _obj.metadata.all()
489+
_filters = [{f"metadata__{key}": value} for key, value in request.query_params.items()]
490+
if _filters:
491+
queryset = queryset.filter(**_filters[0])
492+
return Response(ExtraMetadataSerializer().to_representation(queryset))
493+
if not request.method == "DELETE":
494+
try:
495+
extra_metadata = validate_extra_metadata(request.data, _obj)
496+
except Exception as e:
497+
return Response(status=500, data=e.args[0])
498+
499+
if request.method == "PUT":
500+
'''
501+
update specific metadata. The ID of the metadata is required to perform the update
502+
[
503+
{
504+
"id": 1,
505+
"name": "foo_name",
506+
"slug": "foo_sug",
507+
"help_text": "object",
508+
"field_type": "int",
509+
"value": "object",
510+
"category": "object"
511+
}
512+
]
513+
'''
514+
for _m in extra_metadata:
515+
_id = _m.pop('id')
516+
ResourceBase.objects.filter(id=_obj.id).first().metadata.filter(id=_id).update(metadata=_m)
517+
logger.info("metadata updated for the selected resource")
518+
_obj.refresh_from_db()
519+
return Response(ExtraMetadataSerializer().to_representation(_obj.metadata.all()))
520+
elif request.method == "DELETE":
521+
# delete single metadata
522+
'''
523+
Expect a payload with the IDs of the metadata that should be deleted. Payload be like:
524+
[4, 3]
525+
'''
526+
ResourceBase.objects.filter(id=_obj.id).first().metadata.filter(id__in=request.data).delete()
527+
_obj.refresh_from_db()
528+
return Response(ExtraMetadataSerializer().to_representation(_obj.metadata.all()))
529+
elif request.method == "POST":
530+
# add new metadata
531+
'''
532+
[
533+
{
534+
"name": "foo_name",
535+
"slug": "foo_sug",
536+
"help_text": "object",
537+
"field_type": "int",
538+
"value": "object",
539+
"category": "object"
540+
}
541+
]
542+
'''
543+
for _m in extra_metadata:
544+
new_m = ExtraMetadata.objects.create(
545+
resource=_obj,
546+
metadata=_m
547+
)
548+
new_m.save()
549+
_obj.metadata.add(new_m)
550+
_obj.refresh_from_db()
551+
return Response(ExtraMetadataSerializer().to_representation(_obj.metadata.all()), status=201)

0 commit comments

Comments
 (0)