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

[Backport 4.0.x] [Fixes #10270] Document creation via API v2 #10298

Merged
merged 1 commit into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
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"
33 changes: 22 additions & 11 deletions geonode/documents/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,37 @@
# 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 DocumentFieldField(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(required=False)
doc_file = DocumentFieldField(required=False)

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", "doc_file")
78 changes: 78 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,74 @@ 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': ['A file path or a file must be speficied'], '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_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,
"doc_file": None
}
}
expected = {'success': False, 'errors': ['A file path or a file must be speficied'], '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_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)
74 changes: 71 additions & 3 deletions geonode/documents/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,29 @@
# 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.resource.utils import resourcebase_post_save
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 +52,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 +63,68 @@ class DocumentViewSet(DynamicModelViewSet):
serializer_class = DocumentSerializer
pagination_class = GeoNodeApiPagination

def perform_create(self, serializer):
'''
Function to create document via API v2.
file_path: path to the file
doc_file: the open file

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.

or can be also a form-data:
curl --location --request POST 'http://localhost:8000/api/v2/documents' \
--form 'title="Super Title2"' \
--form 'doc_file=@"/C:/Users/user/Pictures/BcMc-a6T9IM.jpg"' \
--form 'metadata_only="False"'
'''
manager = None
serializer.is_valid(raise_exception=True)
_has_file = serializer.validated_data.pop("file_path", None) or serializer.validated_data.pop("doc_file", None)
extension = serializer.validated_data.pop("extension", None)

if not _has_file:
raise DocumentException(detail="A file path or a file must be speficied")

if not extension:
filename = _has_file if isinstance(_has_file, str) else _has_file.name
extension = Path(filename).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_type": "document"
}
)

resource.set_missing_info()
resourcebase_post_save(resource.get_real_instance())
resource_manager.set_permissions(None, instance=resource, permissions=None, created=True)
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