Skip to content

Commit 5862e72

Browse files
committed
[Fixes #11494] Implement relations between resources
1 parent 80e2bbf commit 5862e72

File tree

24 files changed

+342
-188
lines changed

24 files changed

+342
-188
lines changed

geonode/base/api/serializers.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
SpatialRepresentationType,
5454
ThesaurusKeyword,
5555
ThesaurusKeywordLabel,
56-
ExtraMetadata,
56+
ExtraMetadata, LinkedResource,
5757
)
5858
from geonode.documents.models import Document
5959
from geonode.geoapps.models import GeoApp
@@ -787,7 +787,40 @@ class Meta:
787787

788788

789789
class SimpleResourceSerializer(DynamicModelSerializer):
790+
790791
class Meta:
791792
name = "linked_resources"
792793
model = ResourceBase
793794
fields = ("pk", "title", "resource_type", "detail_url", "thumbnail_url")
795+
796+
def to_representation(self, instance: LinkedResource):
797+
return {
798+
"pk": instance.pk,
799+
"title": f"{'>>> ' if instance.is_target else '<<< '} {instance.title}",
800+
"resource_type": instance.resource_type,
801+
"detail_url": instance.detail_url,
802+
"thumbnail_url": instance.thumbnail_url,
803+
}
804+
805+
806+
class LinkedResourceSerializer(DynamicModelSerializer):
807+
def __init__(self, *kargs, serialize_source: bool = False, **kwargs):
808+
super().__init__(*kargs, **kwargs)
809+
self.serialize_target = not serialize_source
810+
811+
class Meta:
812+
name = "linked_resources"
813+
model = LinkedResource
814+
fields = ("internal",)
815+
816+
def to_representation(self, instance: LinkedResource):
817+
data = super().to_representation(instance)
818+
item: ResourceBase = instance.target if self.serialize_target else instance.source
819+
data.update({
820+
"pk": item.pk,
821+
"title": item.title,
822+
"resource_type": item.resource_type,
823+
"detail_url": item.detail_url,
824+
"thumbnail_url": item.thumbnail_url,
825+
})
826+
return data

geonode/base/api/views.py

+58-47
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
from geonode.maps.models import Map
6262
from geonode.layers.models import Dataset
6363
from geonode.favorite.models import Favorite
64-
from geonode.base.models import Configuration, ExtraMetadata
64+
from geonode.base.models import Configuration, ExtraMetadata, LinkedResource
6565
from geonode.thumbs.exceptions import ThumbnailError
6666
from geonode.thumbs.thumbnails import create_thumbnail
6767
from geonode.thumbs.utils import _decode_base64, BASE64_PATTERN
@@ -109,7 +109,7 @@
109109
TopicCategorySerializer,
110110
RegionSerializer,
111111
ThesaurusKeywordSerializer,
112-
ExtraMetadataSerializer,
112+
ExtraMetadataSerializer, LinkedResourceSerializer,
113113
)
114114
from .pagination import GeoNodeApiPagination
115115
from geonode.base.utils import validate_extra_metadata
@@ -1489,48 +1489,59 @@ def _get_request_params(self, request, encode=False):
14891489
url_name="linked_resources",
14901490
)
14911491
def linked_resources(self, request, pk, *args, **kwargs):
1492-
try:
1493-
"""
1494-
To let the API be able to filter the linked result, we cannot rely on the DynamicFilterBackend
1495-
works on the resource and not on the linked one.
1496-
So if we want to filter the linked resource by "resource_type"
1497-
we have to search in the query params like in the following code:
1498-
_filters = {
1499-
x: y
1500-
for x, y
1501-
in request.query_params.items()
1502-
if x not in ["page_size", "page"]
1503-
}
1504-
We have to exclude the paging code or will raise the:
1505-
"Cannot resolve keyword into the field..."
1506-
"""
1507-
_obj = self.get_object().get_real_instance()
1508-
if issubclass(_obj.get_real_concrete_instance_class(), GeoApp):
1509-
raise NotImplementedError("Not implemented: this endpoint is not available for GeoApps")
1510-
# getting the resource dynamically list based on the above mapping
1511-
resources = _obj.linked_resources
1512-
1513-
if request.query_params:
1514-
_filters = {x: y for x, y in request.query_params.items() if x not in ["page_size", "page"]}
1515-
if _filters:
1516-
resources = resources.filter(**_filters)
1517-
1518-
resources = get_visible_resources(
1519-
resources,
1520-
user=request.user,
1521-
admin_approval_required=settings.ADMIN_MODERATE_UPLOADS,
1522-
unpublished_not_visible=settings.RESOURCE_PUBLISHING,
1523-
private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES,
1524-
).order_by("-pk")
1525-
1526-
paginator = GeoNodeApiPagination()
1527-
paginator.page_size = request.GET.get("page_size", 10)
1528-
result_page = paginator.paginate_queryset(resources, request)
1529-
serializer = SimpleResourceSerializer(result_page, embed=True, many=True)
1530-
return paginator.get_paginated_response({"resources": serializer.data})
1531-
except NotImplementedError as e:
1532-
logger.error(e)
1533-
return Response(data={"message": e.args[0], "success": False}, status=501, exception=True)
1534-
except Exception as e:
1535-
logger.error(e)
1536-
return Response(data={"message": e.args[0], "success": False}, status=500, exception=True)
1492+
return base_linked_resources(self.get_object().get_real_instance(), request.user, request.GET)
1493+
1494+
1495+
def base_linked_resources(instance, user, params):
1496+
try:
1497+
visibile_resources = get_visible_resources(
1498+
ResourceBase.objects,
1499+
user=user,
1500+
admin_approval_required=settings.ADMIN_MODERATE_UPLOADS,
1501+
unpublished_not_visible=settings.RESOURCE_PUBLISHING,
1502+
private_groups_not_visibile=settings.GROUP_PRIVATE_RESOURCES,
1503+
).order_by("-pk")
1504+
visible_ids = [res.id for res in visibile_resources]
1505+
1506+
# linked_resources = LinkedResource.get_linked_resources(source=instance).filter(target__in=resources)
1507+
# linked_by = LinkedResource.get_linked_resources(target=instance).filter(source__in=resources)
1508+
1509+
linked_resources = [lres for lres in instance.get_linked_resources()
1510+
if lres.target.id in visible_ids]
1511+
linked_by = [lres for lres in instance.get_linked_resources(as_target=True)
1512+
if lres.source.id in visible_ids]
1513+
1514+
warnings = {
1515+
'DEPRECATION': "'resources' field is deprecated, please use 'linked_to'",
1516+
}
1517+
1518+
if "page_size" in params or "page" in params:
1519+
warnings['PAGINATION'] = "Pagination is not supported on this call"
1520+
1521+
# "resources" will be deprecated, so next block is temporary
1522+
# "resources" at the moment it's the only element rendered, so we want to add there both the linked_resources and the linked_by
1523+
# we want to tell them apart, so we're adding an attr to store this info, that will be used in the SimpleResourceSerializer
1524+
resources = []
1525+
for lres in linked_resources:
1526+
res = lres.target
1527+
setattr(res, 'is_target', True)
1528+
resources.append(res)
1529+
for lres in linked_by:
1530+
res = lres.source
1531+
setattr(res, 'is_target', False)
1532+
resources.append(res)
1533+
1534+
ret = {
1535+
"WARNINGS": warnings,
1536+
"resources": SimpleResourceSerializer(resources, embed=True, many=True).data, # deprecated
1537+
"linked_to": LinkedResourceSerializer(linked_resources, embed=True, many=True).data,
1538+
"linked_by": LinkedResourceSerializer(instance=linked_by, serialize_source=True, embed=True, many=True).data,
1539+
}
1540+
1541+
return Response(ret)
1542+
except NotImplementedError as e:
1543+
logger.exception(e)
1544+
return Response(data={"message": e.args[0], "success": False}, status=501, exception=True)
1545+
except Exception as e:
1546+
logger.exception(e)
1547+
return Response(data={"message": e.args[0], "success": False}, status=500, exception=True)

geonode/base/forms.py

+60-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from geonode.base.models import (
4747
HierarchicalKeyword,
4848
License,
49+
LinkedResource,
4950
Region,
5051
ResourceBase,
5152
Thesaurus,
@@ -345,6 +346,63 @@ def _get_thesauro_title_label(item, lang):
345346
return tname.first()
346347

347348

349+
class LinkedResourceForm(forms.ModelForm):
350+
351+
linked_resources = forms.MultipleChoiceField(label=_("Link to"), required=False)
352+
353+
def __init__(self, *args, **kwargs):
354+
super().__init__(*args, **kwargs)
355+
self.fields["linked_resources"].choices = LinkedResourceForm.generate_link_choices()
356+
self.fields["linked_resources"].initial = LinkedResourceForm.generate_link_values(resources=LinkedResource.get_targets(self.instance))
357+
358+
@staticmethod
359+
def generate_link_choices(resources=None):
360+
if resources is None:
361+
resources = ResourceBase.objects.all().order_by('title')
362+
363+
choices = []
364+
for obj in resources:
365+
choices.append([obj.id, f"{obj.title} ({obj.polymorphic_ctype.model})"])
366+
367+
return choices
368+
369+
@staticmethod
370+
def generate_link_values(resources=None):
371+
choices = LinkedResourceForm.generate_link_choices(resources=resources)
372+
return [choice[0] for choice in choices]
373+
374+
def save_linked_resources(self, links_field="linked_resources"):
375+
# create and fetch desired links
376+
target_ids = []
377+
for res_id in self.cleaned_data[links_field]:
378+
linked, _ = LinkedResource.objects.get_or_create(
379+
source=self.instance,
380+
target_id=res_id,
381+
internal=False
382+
)
383+
target_ids.append(res_id)
384+
385+
# matches = re.match(r"type:(\d+)-id:(\d+)", link)
386+
# if matches:
387+
# content_type = ContentType.objects.get(id=matches.group(1))
388+
# instance, _ = DocumentResourceLink.objects.get_or_create(
389+
# document=self.instance,
390+
# content_type=content_type,
391+
# object_id=matches.group(2),
392+
# )
393+
# instances.append(instance)
394+
395+
# delete remaining links
396+
# DocumentResourceLink.objects.filter(document_id=self.instance.id).exclude(
397+
# pk__in=[i.pk for i in instances]
398+
# ).delete()
399+
(LinkedResource.objects
400+
.filter(source_id=self.instance.id)
401+
.exclude(target_id__in=target_ids)
402+
.delete()
403+
)
404+
405+
348406
class ResourceBaseDateTimePicker(DateTimePicker):
349407
def build_attrs(self, base_attrs=None, extra_attrs=None, **kwargs):
350408
"Helper function for building an attribute dictionary."
@@ -355,7 +413,7 @@ def build_attrs(self, base_attrs=None, extra_attrs=None, **kwargs):
355413
# return base_attrs
356414

357415

358-
class ResourceBaseForm(TranslationModelForm):
416+
class ResourceBaseForm(TranslationModelForm, LinkedResourceForm):
359417

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

@@ -638,3 +696,4 @@ class Meta:
638696

639697
class ThesaurusImportForm(forms.Form):
640698
rdf_file = forms.FileField()
699+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 3.2.21 on 2023-10-05 14:29
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('base', '0085_alter_resourcebase_uuid'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='LinkedResource',
16+
fields=[
17+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linked_to', to='base.resourcebase')),
19+
('target', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='linked_by', to='base.resourcebase')),
20+
('internal', models.BooleanField(default=False)),
21+
],
22+
),
23+
]

geonode/base/models.py

+45
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,15 @@ def add_missing_metadata_author_or_poc(self):
17261726

17271727
metadata_author = property(_get_metadata_author, _set_metadata_author)
17281728

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

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

17511760

1761+
class LinkedResource(models.Model):
1762+
source = models.ForeignKey(ResourceBase, related_name="linked_to",
1763+
blank=False, null=False, on_delete=models.CASCADE)
1764+
target = models.ForeignKey(ResourceBase, related_name="linked_by",
1765+
blank=True, null=False, on_delete=models.CASCADE)
1766+
internal = models.BooleanField(null=False, default=False)
1767+
1768+
@classmethod
1769+
def get_linked_resources(cls, source: ResourceBase = None, target: ResourceBase = None, is_internal: bool = None):
1770+
if source is None and target is None:
1771+
raise ValueError('Both source and target filters missing')
1772+
1773+
qs = LinkedResource.objects
1774+
if source:
1775+
qs = qs.filter(source=source).select_related('target')
1776+
if target:
1777+
qs = qs.filter(target=target).select_related('source')
1778+
if is_internal is not None:
1779+
qs = qs.filter(internal=is_internal)
1780+
return qs
1781+
1782+
@classmethod
1783+
def get_targets(cls, source: ResourceBase, is_internal: bool = None):
1784+
sub = LinkedResource.objects.filter(source=source).values('target_id')
1785+
if is_internal is not None:
1786+
sub = sub.filter(internal=is_internal)
1787+
return ResourceBase.objects.filter(id__in=sub)
1788+
1789+
@classmethod
1790+
def resolve_targets(cls, linked_resources):
1791+
return (lr.target for lr in linked_resources)
1792+
1793+
def resolve_sources(cls, linked_resources):
1794+
return (lr.source for lr in linked_resources)
1795+
1796+
17521797
class Link(models.Model):
17531798
"""Auxiliary model for storing links for resources.
17541799

geonode/documents/api/views.py

+2-16
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@
3131
from geonode.base.api.filters import DynamicSearchFilter, ExtentFilter
3232
from geonode.base.api.pagination import GeoNodeApiPagination
3333
from geonode.base.api.permissions import UserHasPerms
34+
from geonode.base.api.views import base_linked_resources
3435
from geonode.base import enumerations
3536
from geonode.documents.api.exceptions import DocumentException
3637
from geonode.documents.models import Document
3738

38-
from geonode.base.models import ResourceBase
3939
from geonode.base.api.serializers import ResourceBaseSerializer
4040
from geonode.resource.utils import resourcebase_post_save
4141
from geonode.storage.manager import StorageManager
@@ -146,18 +146,4 @@ def perform_create(self, serializer):
146146
)
147147
@action(detail=True, methods=["get"])
148148
def linked_resources(self, request, pk=None, *args, **kwargs):
149-
document = self.get_object()
150-
resources_id = document.links.all().values("object_id")
151-
resources = ResourceBase.objects.filter(id__in=resources_id)
152-
exclude = []
153-
for resource in resources:
154-
if not request.user.is_superuser and not request.user.has_perm(
155-
"view_resourcebase", resource.get_self_resource()
156-
):
157-
exclude.append(resource.id)
158-
resources = resources.exclude(id__in=exclude)
159-
paginator = GeoNodeApiPagination()
160-
paginator.page_size = request.GET.get("page_size", 10)
161-
result_page = paginator.paginate_queryset(resources, request)
162-
serializer = ResourceBaseSerializer(result_page, embed=True, many=True)
163-
return paginator.get_paginated_response({"resources": serializer.data})
149+
return base_linked_resources(self.get_object().get_real_instance(), request.user, request.GET)

0 commit comments

Comments
 (0)