diff --git a/geonode/base/api/serializers.py b/geonode/base/api/serializers.py index 74d74e9a236..6108f90e307 100644 --- a/geonode/base/api/serializers.py +++ b/geonode/base/api/serializers.py @@ -53,7 +53,7 @@ SpatialRepresentationType, ThesaurusKeyword, ThesaurusKeywordLabel, - ExtraMetadata, + ExtraMetadata, LinkedResource, ) from geonode.documents.models import Document from geonode.geoapps.models import GeoApp @@ -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 diff --git a/geonode/base/api/views.py b/geonode/base/api/views.py index fbbd2faec29..278e72b2b01 100644 --- a/geonode/base/api/views.py +++ b/geonode/base/api/views.py @@ -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 @@ -109,7 +109,7 @@ TopicCategorySerializer, RegionSerializer, ThesaurusKeywordSerializer, - ExtraMetadataSerializer, + ExtraMetadataSerializer, LinkedResourceSerializer, ) from .pagination import GeoNodeApiPagination from geonode.base.utils import validate_extra_metadata @@ -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) diff --git a/geonode/base/forms.py b/geonode/base/forms.py index c888d816670..155413e5aee 100644 --- a/geonode/base/forms.py +++ b/geonode/base/forms.py @@ -46,6 +46,7 @@ from geonode.base.models import ( HierarchicalKeyword, License, + LinkedResource, Region, ResourceBase, Thesaurus, @@ -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." @@ -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""" @@ -638,3 +696,4 @@ class Meta: class ThesaurusImportForm(forms.Form): rdf_file = forms.FileField() + diff --git a/geonode/base/migrations/0086_linkedresource.py b/geonode/base/migrations/0086_linkedresource.py new file mode 100644 index 00000000000..cc5929e788d --- /dev/null +++ b/geonode/base/migrations/0086_linkedresource.py @@ -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)), + ], + ), + ] diff --git a/geonode/base/models.py b/geonode/base/models.py index 1a2c8a38abd..28486ff9613 100644 --- a/geonode/base/models.py +++ b/geonode/base/models.py @@ -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""" @@ -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. diff --git a/geonode/documents/api/views.py b/geonode/documents/api/views.py index dd15a23e363..6c65fa21b59 100644 --- a/geonode/documents/api/views.py +++ b/geonode/documents/api/views.py @@ -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 @@ -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) diff --git a/geonode/documents/forms.py b/geonode/documents/forms.py index 8da41217463..286f98e0a6b 100644 --- a/geonode/documents/forms.py +++ b/geonode/documents/forms.py @@ -18,7 +18,6 @@ ######################################################################### import os -import re import json import logging @@ -28,14 +27,10 @@ from django.conf import settings from django.forms import HiddenInput from django.utils.translation import ugettext as _ -from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import filesizeformat -from geonode.maps.models import Map -from geonode.layers.models import Dataset from geonode.base.forms import ResourceBaseForm, get_tree_data -from geonode.resource.utils import get_related_resources -from geonode.documents.models import Document, DocumentResourceLink +from geonode.documents.models import Document from geonode.upload.models import UploadSizeLimit from geonode.upload.api.exceptions import FileUploadLimitException @@ -82,54 +77,12 @@ def _get_max_size(self): return max_size_db_obj.max_size -class DocumentFormMixin: - def generate_link_choices(self, resources=None): - if resources is None: - resources = list(Dataset.objects.all()) - resources += list(Map.objects.all()) - resources.sort(key=lambda x: x.title) - - choices = [] - for obj in resources: - type_id = ContentType.objects.get_for_model(obj.__class__).id - choices.append([f"type:{type_id}-id:{obj.id}", f"{obj.title} ({obj.polymorphic_ctype.model})"]) - - return choices - - def generate_link_values(self, resources=None): - choices = self.generate_link_choices(resources=resources) - return [choice[0] for choice in choices] - - def save_many2many(self, links_field="links"): - # create and fetch desired links - instances = [] - for link in self.cleaned_data[links_field]: - 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() - - -class DocumentForm(ResourceBaseForm, DocumentFormMixin): +class DocumentForm(ResourceBaseForm): title = forms.CharField(required=False) - links = forms.MultipleChoiceField(label=_("Link to"), required=False) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["regions"].choices = get_tree_data() - self.fields["links"].choices = self.generate_link_choices() - self.fields["links"].initial = self.generate_link_values(resources=get_related_resources(self.instance)) for field in self.fields: help_text = self.fields[field].help_text self.fields[field].help_text = None @@ -163,7 +116,7 @@ class DocumentDescriptionForm(forms.Form): keywords = forms.CharField(max_length=500, required=False) -class DocumentCreateForm(TranslationModelForm, DocumentFormMixin): +class DocumentCreateForm(TranslationModelForm): """ The document upload form. @@ -173,8 +126,6 @@ class DocumentCreateForm(TranslationModelForm, DocumentFormMixin): widget=HiddenInput(attrs={"name": "permissions", "id": "permissions"}), required=False ) - links = forms.MultipleChoiceField(label=_("Link to"), required=False) - doc_file = SizeRestrictedFileField(label=_("File"), required=False, field_slug="document_upload_size") class Meta: @@ -186,7 +137,6 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["links"].choices = self.generate_link_choices() def clean_permissions(self): """ diff --git a/geonode/documents/models.py b/geonode/documents/models.py index 5f5f41ab218..08b003fb1a2 100644 --- a/geonode/documents/models.py +++ b/geonode/documents/models.py @@ -150,37 +150,15 @@ def download_url(self): return self.link_set.filter(resource=self.get_self_resource(), link_type="original").first().url return build_absolute_uri(reverse("document_download", args=(self.id,))) - @property - def linked_resources(self): - return ResourceBase.objects.filter(id__in=self.links.values_list("object_id", flat=True)) - class Meta(ResourceBase.Meta): pass class DocumentResourceLink(models.Model): # relation to the document model - document = models.ForeignKey(Document, null=True, blank=True, related_name="links", on_delete=models.CASCADE) + document = models.ForeignKey(Document, null=True, blank=True, related_name="links_TO_BE_REMOVED_WIP", on_delete=models.CASCADE) # relation to the resource model content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() resource = GenericForeignKey("content_type", "object_id") - - -def get_related_documents(resource): - if isinstance(resource, Dataset) or isinstance(resource, Map): - content_type = ContentType.objects.get_for_model(resource) - return Document.objects.filter(links__content_type=content_type, links__object_id=resource.pk) - else: - return None - - -def update_documents_extent(sender, **kwargs): - documents = get_related_documents(sender) - if documents: - for document in documents: - document.save() - - -map_changed_signal.connect(update_documents_extent) diff --git a/geonode/documents/templates/documents/document_metadata.html b/geonode/documents/templates/documents/document_metadata.html index 2f4361c9be8..a04a7323b50 100644 --- a/geonode/documents/templates/documents/document_metadata.html +++ b/geonode/documents/templates/documents/document_metadata.html @@ -88,13 +88,13 @@