Skip to content

Commit

Permalink
[Fixes #11494] Implement relations between resources
Browse files Browse the repository at this point in the history
  • Loading branch information
etj committed Oct 10, 2023
1 parent 8913d8e commit 49547ab
Show file tree
Hide file tree
Showing 24 changed files with 342 additions and 188 deletions.
35 changes: 34 additions & 1 deletion geonode/base/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
SpatialRepresentationType,
ThesaurusKeyword,
ThesaurusKeywordLabel,
ExtraMetadata,
ExtraMetadata, LinkedResource,
)
from geonode.documents.models import Document
from geonode.geoapps.models import GeoApp
Expand Down Expand Up @@ -787,7 +787,40 @@ class Meta:


class SimpleResourceSerializer(DynamicModelSerializer):

class Meta:
name = "linked_resources"
model = ResourceBase
fields = ("pk", "title", "resource_type", "detail_url", "thumbnail_url")

def to_representation(self, instance: LinkedResource):
return {
"pk": instance.pk,
"title": f"{'>>> ' if instance.is_target else '<<< '} {instance.title}",
"resource_type": instance.resource_type,
"detail_url": instance.detail_url,
"thumbnail_url": instance.thumbnail_url,
}


class LinkedResourceSerializer(DynamicModelSerializer):
def __init__(self, *kargs, serialize_source: bool = False, **kwargs):
super().__init__(*kargs, **kwargs)
self.serialize_target = not serialize_source

class Meta:
name = "linked_resources"
model = LinkedResource
fields = ("internal",)

def to_representation(self, instance: LinkedResource):
data = super().to_representation(instance)
item: ResourceBase = instance.target if self.serialize_target else instance.source
data.update({
"pk": item.pk,
"title": item.title,
"resource_type": item.resource_type,
"detail_url": item.detail_url,
"thumbnail_url": item.thumbnail_url,
})
return data
105 changes: 58 additions & 47 deletions geonode/base/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
from geonode.maps.models import Map
from geonode.layers.models import Dataset
from geonode.favorite.models import Favorite
from geonode.base.models import Configuration, ExtraMetadata
from geonode.base.models import Configuration, ExtraMetadata, LinkedResource
from geonode.thumbs.exceptions import ThumbnailError
from geonode.thumbs.thumbnails import create_thumbnail
from geonode.thumbs.utils import _decode_base64, BASE64_PATTERN
Expand Down Expand Up @@ -109,7 +109,7 @@
TopicCategorySerializer,
RegionSerializer,
ThesaurusKeywordSerializer,
ExtraMetadataSerializer,
ExtraMetadataSerializer, LinkedResourceSerializer,
)
from .pagination import GeoNodeApiPagination
from geonode.base.utils import validate_extra_metadata
Expand Down Expand Up @@ -1489,48 +1489,59 @@ def _get_request_params(self, request, encode=False):
url_name="linked_resources",
)
def linked_resources(self, request, pk, *args, **kwargs):
try:
"""
To let the API be able to filter the linked result, we cannot rely on the DynamicFilterBackend
works on the resource and not on the linked one.
So if we want to filter the linked resource by "resource_type"
we have to search in the query params like in the following code:
_filters = {
x: y
for x, y
in request.query_params.items()
if x not in ["page_size", "page"]
}
We have to exclude the paging code or will raise the:
"Cannot resolve keyword into the field..."
"""
_obj = self.get_object().get_real_instance()
if issubclass(_obj.get_real_concrete_instance_class(), GeoApp):
raise NotImplementedError("Not implemented: this endpoint is not available for GeoApps")
# getting the resource dynamically list based on the above mapping
resources = _obj.linked_resources

if request.query_params:
_filters = {x: y for x, y in request.query_params.items() if x not in ["page_size", "page"]}
if _filters:
resources = resources.filter(**_filters)

resources = get_visible_resources(
resources,
user=request.user,
admin_approval_required=settings.ADMIN_MODERATE_UPLOADS,
unpublished_not_visible=settings.RESOURCE_PUBLISHING,
private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES,
).order_by("-pk")

paginator = GeoNodeApiPagination()
paginator.page_size = request.GET.get("page_size", 10)
result_page = paginator.paginate_queryset(resources, request)
serializer = SimpleResourceSerializer(result_page, embed=True, many=True)
return paginator.get_paginated_response({"resources": serializer.data})
except NotImplementedError as e:
logger.error(e)
return Response(data={"message": e.args[0], "success": False}, status=501, exception=True)
except Exception as e:
logger.error(e)
return Response(data={"message": e.args[0], "success": False}, status=500, exception=True)
return base_linked_resources(self.get_object().get_real_instance(), request.user, request.GET)


def base_linked_resources(instance, user, params):
try:
visibile_resources = get_visible_resources(
ResourceBase.objects,
user=user,
admin_approval_required=settings.ADMIN_MODERATE_UPLOADS,
unpublished_not_visible=settings.RESOURCE_PUBLISHING,
private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES,
).order_by("-pk")
visible_ids = [res.id for res in visibile_resources]

# linked_resources = LinkedResource.get_linked_resources(source=instance).filter(target__in=resources)
# linked_by = LinkedResource.get_linked_resources(target=instance).filter(source__in=resources)

linked_resources = [lres for lres in instance.get_linked_resources()
if lres.target.id in visible_ids]
linked_by = [lres for lres in instance.get_linked_resources(as_target=True)
if lres.source.id in visible_ids]

warnings = {
'DEPRECATION': "'resources' field is deprecated, please use 'linked_to'",
}

if "page_size" in params or "page" in params:
warnings['PAGINATION'] = "Pagination is not supported on this call"

# "resources" will be deprecated, so next block is temporary
# "resources" at the moment it's the only element rendered, so we want to add there both the linked_resources and the linked_by
# we want to tell them apart, so we're adding an attr to store this info, that will be used in the SimpleResourceSerializer
resources = []
for lres in linked_resources:
res = lres.target
setattr(res, 'is_target', True)
resources.append(res)
for lres in linked_by:
res = lres.source
setattr(res, 'is_target', False)
resources.append(res)

ret = {
"WARNINGS": warnings,
"resources": SimpleResourceSerializer(resources, embed=True, many=True).data, # deprecated
"linked_to": LinkedResourceSerializer(linked_resources, embed=True, many=True).data,
"linked_by": LinkedResourceSerializer(instance=linked_by, serialize_source=True, embed=True, many=True).data,
}

return Response(ret)
except NotImplementedError as e:
logger.exception(e)
return Response(data={"message": e.args[0], "success": False}, status=501, exception=True)
except Exception as e:
logger.exception(e)
return Response(data={"message": e.args[0], "success": False}, status=500, exception=True)
61 changes: 60 additions & 1 deletion geonode/base/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from geonode.base.models import (
HierarchicalKeyword,
License,
LinkedResource,
Region,
ResourceBase,
Thesaurus,
Expand Down Expand Up @@ -345,6 +346,63 @@ def _get_thesauro_title_label(item, lang):
return tname.first()


class LinkedResourceForm(forms.ModelForm):

linked_resources = forms.MultipleChoiceField(label=_("Link to"), required=False)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["linked_resources"].choices = LinkedResourceForm.generate_link_choices()
self.fields["linked_resources"].initial = LinkedResourceForm.generate_link_values(resources=LinkedResource.get_targets(self.instance))

@staticmethod
def generate_link_choices(resources=None):
if resources is None:
resources = ResourceBase.objects.all().order_by('title')

choices = []
for obj in resources:
choices.append([obj.id, f"{obj.title} ({obj.polymorphic_ctype.model})"])

return choices

@staticmethod
def generate_link_values(resources=None):
choices = LinkedResourceForm.generate_link_choices(resources=resources)
return [choice[0] for choice in choices]

def save_linked_resources(self, links_field="linked_resources"):
# create and fetch desired links
target_ids = []
for res_id in self.cleaned_data[links_field]:
linked, _ = LinkedResource.objects.get_or_create(
source=self.instance,
target_id=res_id,
internal=False
)
target_ids.append(res_id)

# matches = re.match(r"type:(\d+)-id:(\d+)", link)
# if matches:
# content_type = ContentType.objects.get(id=matches.group(1))
# instance, _ = DocumentResourceLink.objects.get_or_create(
# document=self.instance,
# content_type=content_type,
# object_id=matches.group(2),
# )
# instances.append(instance)

# delete remaining links
# DocumentResourceLink.objects.filter(document_id=self.instance.id).exclude(
# pk__in=[i.pk for i in instances]
# ).delete()
(LinkedResource.objects
.filter(source_id=self.instance.id)
.exclude(target_id__in=target_ids)
.delete()
)


class ResourceBaseDateTimePicker(DateTimePicker):
def build_attrs(self, base_attrs=None, extra_attrs=None, **kwargs):
"Helper function for building an attribute dictionary."
Expand All @@ -355,7 +413,7 @@ def build_attrs(self, base_attrs=None, extra_attrs=None, **kwargs):
# return base_attrs


class ResourceBaseForm(TranslationModelForm):
class ResourceBaseForm(TranslationModelForm, LinkedResourceForm):

"""Base form for metadata, should be inherited by childres classes of ResourceBase"""

Expand Down Expand Up @@ -638,3 +696,4 @@ class Meta:

class ThesaurusImportForm(forms.Form):
rdf_file = forms.FileField()

23 changes: 23 additions & 0 deletions geonode/base/migrations/0086_linkedresource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.21 on 2023-10-05 14:29

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('base', '0085_alter_resourcebase_uuid'),
]

operations = [
migrations.CreateModel(
name='LinkedResource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linked_to', to='base.resourcebase')),
('target', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='linked_by', to='base.resourcebase')),
('internal', models.BooleanField(default=False)),
],
),
]
45 changes: 45 additions & 0 deletions geonode/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1726,6 +1726,15 @@ def add_missing_metadata_author_or_poc(self):

metadata_author = property(_get_metadata_author, _set_metadata_author)

def get_linked_resources(self, as_target: bool = False):
"""
Get all the linked resources to this ResourceBase instance.
This is implemented as a method so that derived classes can override it (for instance, Maps may add
related datasets)
"""
return LinkedResource.get_linked_resources(target=self) if as_target \
else LinkedResource.get_linked_resources(source=self)


class LinkManager(models.Manager):
"""Helper class to access links grouped by type"""
Expand All @@ -1749,6 +1758,42 @@ def ows(self):
return self.get_queryset().filter(link_type__in=["OGC:WMS", "OGC:WFS", "OGC:WCS"])


class LinkedResource(models.Model):
source = models.ForeignKey(ResourceBase, related_name="linked_to",
blank=False, null=False, on_delete=models.CASCADE)
target = models.ForeignKey(ResourceBase, related_name="linked_by",
blank=True, null=False, on_delete=models.CASCADE)
internal = models.BooleanField(null=False, default=False)

@classmethod
def get_linked_resources(cls, source: ResourceBase = None, target: ResourceBase = None, is_internal: bool = None):
if source is None and target is None:
raise ValueError('Both source and target filters missing')

qs = LinkedResource.objects
if source:
qs = qs.filter(source=source).select_related('target')
if target:
qs = qs.filter(target=target).select_related('source')
if is_internal is not None:
qs = qs.filter(internal=is_internal)
return qs

@classmethod
def get_targets(cls, source: ResourceBase, is_internal: bool = None):
sub = LinkedResource.objects.filter(source=source).values('target_id')
if is_internal is not None:
sub = sub.filter(internal=is_internal)
return ResourceBase.objects.filter(id__in=sub)

@classmethod
def resolve_targets(cls, linked_resources):
return (lr.target for lr in linked_resources)

def resolve_sources(cls, linked_resources):
return (lr.source for lr in linked_resources)


class Link(models.Model):
"""Auxiliary model for storing links for resources.
Expand Down
18 changes: 2 additions & 16 deletions geonode/documents/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
from geonode.base.api.filters import DynamicSearchFilter, ExtentFilter
from geonode.base.api.pagination import GeoNodeApiPagination
from geonode.base.api.permissions import UserHasPerms
from geonode.base.api.views import base_linked_resources
from geonode.base import enumerations
from geonode.documents.api.exceptions import DocumentException
from geonode.documents.models import Document

from geonode.base.models import ResourceBase
from geonode.base.api.serializers import ResourceBaseSerializer
from geonode.resource.utils import resourcebase_post_save
from geonode.storage.manager import StorageManager
Expand Down Expand Up @@ -146,18 +146,4 @@ def perform_create(self, serializer):
)
@action(detail=True, methods=["get"])
def linked_resources(self, request, pk=None, *args, **kwargs):
document = self.get_object()
resources_id = document.links.all().values("object_id")
resources = ResourceBase.objects.filter(id__in=resources_id)
exclude = []
for resource in resources:
if not request.user.is_superuser and not request.user.has_perm(
"view_resourcebase", resource.get_self_resource()
):
exclude.append(resource.id)
resources = resources.exclude(id__in=exclude)
paginator = GeoNodeApiPagination()
paginator.page_size = request.GET.get("page_size", 10)
result_page = paginator.paginate_queryset(resources, request)
serializer = ResourceBaseSerializer(result_page, embed=True, many=True)
return paginator.get_paginated_response({"resources": serializer.data})
return base_linked_resources(self.get_object().get_real_instance(), request.user, request.GET)
Loading

0 comments on commit 49547ab

Please sign in to comment.