Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fixes #10270] Document creation via API v2 #10271

Merged
merged 12 commits into from
Nov 14, 2022
Merged
19 changes: 19 additions & 0 deletions geonode/base/migrations/0085_alter_resourcebase_uuid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.2.16 on 2022-11-08 10:52

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

dependencies = [
('base', '0084_remove_comments_from_actions'),
]

operations = [
migrations.AlterField(
model_name='resourcebase',
name='uuid',
field=models.CharField(default=uuid.uuid4, max_length=36, unique=True),
),
]
6 changes: 4 additions & 2 deletions geonode/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ class ResourceBase(PolymorphicModel, PermissionLevelMixin, ItemBase):
extra_metadata_help_text = _(
'Additional metadata, must be in format [ {"metadata_key": "metadata_value"}, {"metadata_key": "metadata_value"} ]')
# internal fields
uuid = models.CharField(max_length=36, unique=True, default=str(uuid.uuid4))
uuid = models.CharField(max_length=36, unique=True, default=uuid.uuid4)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@afabiani this fix is needed since the last migration available for the UUID, is marked as a string of the function itself and not a result.

field=models.CharField(max_length=36, unique=True, default=str(uuid.uuid4)),

This end-up up having an error because the system tries to save the UUID as a "function of...."

This fix, will let geonode decide the UUID (as object) and the fix in the save_method below, helps it to convert it to string if needed

title = models.CharField(_('title'), max_length=255, help_text=_(
'name by which the cited resource is known'))
abstract = models.TextField(
Expand Down Expand Up @@ -1233,7 +1233,9 @@ def save(self, notify=False, *args, **kwargs):

self.pk = self.id = _next_value

if not self.uuid or len(self.uuid) == 0 or callable(self.uuid):
if isinstance(self.uuid, uuid.UUID):
self.uuid = str(self.uuid)
elif not self.uuid or callable(self.uuid) or len(self.uuid) == 0:
self.uuid = str(uuid.uuid4())
super().save(*args, **kwargs)

Expand Down
26 changes: 26 additions & 0 deletions geonode/documents/api/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#########################################################################
#
# Copyright (C) 2022 OSGeo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#########################################################################
from rest_framework.exceptions import APIException


class DocumentException(APIException):
status_code = 400
default_detail = "invalid document"
default_code = "document_exception"
category = "document_api"
26 changes: 15 additions & 11 deletions geonode/documents/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,30 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#########################################################################
from geonode.documents.models import Document
from geonode.base.api.serializers import ResourceBaseSerializer

import logging

from dynamic_rest.fields.fields import DynamicComputedField
from geonode.base.api.serializers import ResourceBaseSerializer
from geonode.documents.models import Document

logger = logging.getLogger(__name__)


class DocumentSerializer(ResourceBaseSerializer):
class GeonodeFilePathField(DynamicComputedField):

def get_attribute(self, instance):
return instance.files


class DocumentSerializer(ResourceBaseSerializer):
def __init__(self, *args, **kwargs):
# Instantiate the superclass normally
super().__init__(*args, **kwargs)

file_path = GeonodeFilePathField()

class Meta:
model = Document
name = 'document'
view_name = 'documents-list'
fields = (
'pk', 'uuid', 'name', 'href',
'subtype', 'extension', 'mime_type',
'executions'
)
name = "document"
view_name = "documents-list"
fields = ("pk", "uuid", "name", "href", "subtype", "extension", "mime_type", "executions", "file_path")
77 changes: 77 additions & 0 deletions geonode/documents/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#########################################################################
import os
from django.contrib.auth import get_user_model
import logging

from urllib.parse import urljoin
Expand All @@ -24,6 +26,7 @@
from rest_framework.test import APITestCase

from guardian.shortcuts import assign_perm, get_anonymous_user
from geonode import settings
from geonode.documents.models import Document
from geonode.base.populate_test_data import create_models

Expand All @@ -42,6 +45,10 @@ def setUp(self):
create_models(b'document')
create_models(b'map')
create_models(b'dataset')
self.admin = get_user_model().objects.get(username="admin")
self.url = reverse('documents-list')
self.invalid_file_path = f"{settings.PROJECT_ROOT}/tests/data/thesaurus.rdf"
self.valid_file_path = f"{settings.PROJECT_ROOT}/base/fixtures/test_xml.xml"

def test_documents(self):
"""
Expand Down Expand Up @@ -72,3 +79,73 @@ def test_documents(self):

# import json
# logger.error(f"{json.dumps(layers_data)}")

def test_creation_return_error_if_file_is_not_passed(self):
'''
If file_path is not available, should raise error
'''
self.client.force_login(self.admin)
payload = {
"document": {
"title": "New document",
"metadata_only": True
}
}
expected = {'success': False, 'errors': ['This field is required.'], 'code': 'invalid'}
actual = self.client.post(self.url, data=payload, format="json")
self.assertEqual(400, actual.status_code)
self.assertDictEqual(expected, actual.json())

def test_creation_return_error_if_file_is_none(self):
'''
If file_path is not available, should raise error
'''
self.client.force_login(self.admin)
payload = {
"document": {
"title": "New document",
"metadata_only": True,
"file_path": None
}
}
expected = {'success': False, 'errors': ['This field may not be null.'], 'code': 'invalid'}
actual = self.client.post(self.url, data=payload, format="json")
self.assertEqual(400, actual.status_code)
self.assertDictEqual(expected, actual.json())

def test_creation_should_rase_exec_for_unsupported_files(self):
self.client.force_login(self.admin)
payload = {
"document": {
"title": "New document",
"metadata_only": True,
"file_path": self.invalid_file_path
}
}
expected = {'success': False, 'errors': ['The file provided is not in the supported extension file list'], 'code': 'document_exception'}
actual = self.client.post(self.url, data=payload, format="json")
self.assertEqual(400, actual.status_code)
self.assertDictEqual(expected, actual.json())

def test_creation_should_create_the_doc(self):
'''
If file_path is not available, should raise error
'''
self.client.force_login(self.admin)
payload = {
"document": {
"title": "New document for testing",
"metadata_only": True,
"file_path": self.valid_file_path
}
}
actual = self.client.post(self.url, data=payload, format="json")
self.assertEqual(201, actual.status_code)
cloned_path = actual.json().get("document", {}).get("file_path", "")[0]
extension = actual.json().get("document", {}).get("extension", "")
self.assertTrue(os.path.exists(cloned_path))
self.assertEqual('xml', extension)
self.assertTrue(Document.objects.filter(title="New document for testing").exists())

if cloned_path:
os.remove(cloned_path)
54 changes: 51 additions & 3 deletions geonode/documents/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,28 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#########################################################################
from drf_spectacular.utils import extend_schema

from drf_spectacular.utils import extend_schema
from pathlib import Path
from dynamic_rest.viewsets import DynamicModelViewSet
from dynamic_rest.filters import DynamicFilterBackend, DynamicSortingFilter

from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from geonode import settings

from geonode.base.api.filters import DynamicSearchFilter, ExtentFilter
from geonode.base.api.pagination import GeoNodeApiPagination
from geonode.base.api.permissions import UserHasPerms
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.storage.manager import StorageManager
from geonode.resource.manager import resource_manager

from .serializers import DocumentSerializer
from .permissions import DocumentPermissionsFilter
Expand All @@ -46,9 +51,9 @@ class DocumentViewSet(DynamicModelViewSet):
"""
API endpoint that allows documents to be viewed or edited.
"""
http_method_names = ['get', 'patch', 'put']
http_method_names = ['get', 'patch', 'put', 'post']
authentication_classes = [SessionAuthentication, BasicAuthentication, OAuth2Authentication]
permission_classes = [IsAuthenticatedOrReadOnly, UserHasPerms]
permission_classes = [IsAuthenticatedOrReadOnly, UserHasPerms(perms_dict={"default": {"POST": ["base.add_resourcebase"]}})]
filter_backends = [
DynamicFilterBackend, DynamicSortingFilter, DynamicSearchFilter,
ExtentFilter, DocumentPermissionsFilter
Expand All @@ -57,6 +62,49 @@ class DocumentViewSet(DynamicModelViewSet):
serializer_class = DocumentSerializer
pagination_class = GeoNodeApiPagination

def perform_create(self, serializer):
'''
Function to create document via API v2.
The API expect this kind of JSON:
{
"document": {
"title": "New document",
"metadata_only": true,
"file_path": "/home/mattia/example.json"
}
}
File path rappresent the filepath where the file to upload is saved.
Is going to be cloned by the storage manager
'''
manager = None
serializer.is_valid(raise_exception=True)
_has_file = serializer.validated_data.pop("file_path", None)
extension = serializer.validated_data.pop("extension", None)

if not _has_file:
raise DocumentException(detail="A filepath must be speficied")

if not extension:
extension = Path(_has_file).suffix.replace(".", "")

if extension not in settings.ALLOWED_DOCUMENT_TYPES:
raise DocumentException("The file provided is not in the supported extension file list")

try:
manager = StorageManager(remote_files={"base_file": _has_file})
manager.clone_remote_files()
files = manager.get_retrieved_paths()

resource = serializer.save(**{"owner": self.request.user, "extension": extension, "files": [files.get("base_file")]})

resource.handle_moderated_uploads()
resource_manager.set_thumbnail(resource.uuid, instance=resource, overwrite=False)
return resource
except Exception as e:
if manager:
manager.delete_retrieved_paths()
raise e

@extend_schema(methods=['get'], responses={200: ResourceBaseSerializer(many=True)},
description="API endpoint allowing to retrieve the DocumentResourceLink(s).")
@action(detail=True, methods=['get'])
Expand Down