Skip to content

Commit

Permalink
[Fixes #12124] GNIP 100: Assets
Browse files Browse the repository at this point in the history
  • Loading branch information
etj committed Apr 23, 2024
1 parent b6ccbdf commit c04e13a
Show file tree
Hide file tree
Showing 28 changed files with 783 additions and 86 deletions.
Empty file added geonode/assets/__init__.py
Empty file.
88 changes: 88 additions & 0 deletions geonode/assets/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import logging

from django.conf import settings
from django.http import HttpResponse
from django.utils.module_loading import import_string

from geonode.base.models import Asset

logger = logging.getLogger(__name__)


class AssetHandlerInterface:

def handled_asset_class(self):
raise NotImplementedError()

def create(self, title, description, type, owner, *args, **kwargs):
raise NotImplementedError()

def remove_data(self, asset: Asset):
raise NotImplementedError()

def clone(self, asset: Asset) -> Asset:
"""
Creates a copy in the DB and copies the underlying data as well
"""
raise NotImplementedError()

def create_link_url(self, asset: Asset) -> str:
raise NotImplementedError()

def get_download_handler(self, asset: Asset):
raise NotImplementedError()

def get_storage_manager(self, asset):
raise NotImplementedError()


class AssetDownloadHandlerInterface:

def create_response(self, asset: Asset, attachment: bool = False, basename=None) -> HttpResponse:
raise NotImplementedError()


class AssetHandlerRegistry:
_registry = {}
_default_handler = None

def init_registry(self):
self.register_asset_handlers()
self.set_default_handler()

def register_asset_handlers(self):
for module_path in settings.ASSET_HANDLERS:
handler = import_string(module_path)
self.register(handler)
logger.info(f"Registered Asset handlers: {', '.join(settings.ASSET_HANDLERS)}")

def set_default_handler(self):
# check if declared class is registered
for handler in self._registry.values():
if ".".join([handler.__class__.__module__, handler.__class__.__name__]) == settings.DEFAULT_ASSET_HANDLER:
self._default_handler = handler
break

if self._default_handler is None:
logger.error(f"Could not set default asset handler class {settings.DEFAULT_ASSET_HANDLER}")
else:
logger.info(f"Default Asset handler {settings.DEFAULT_ASSET_HANDLER}")

def register(self, asset_handler_class):
self._registry[asset_handler_class.handled_asset_class()] = asset_handler_class()

def get_default_handler(self) -> AssetHandlerInterface:
return self._default_handler

def get_handler(self, asset):
asset_cls = asset if isinstance(asset, type) else asset.__class__
ret = self._registry.get(asset_cls, None)
if not ret:
logger.warning(f"Could not find handler for asset {asset_cls}::{asset.__class__}")
logger.warning("available asset types:")
for k, v in self._registry.items():
logger.warning(f"{k} --> {v}")
return ret


asset_handler_registry = AssetHandlerRegistry()
106 changes: 106 additions & 0 deletions geonode/assets/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import datetime
import logging
import os

from django.conf import settings
from django.http import HttpResponse
from django.urls import reverse
from django_downloadview import DownloadResponse

from geonode.assets.handlers import asset_handler_registry, AssetHandlerInterface, AssetDownloadHandlerInterface
from geonode.base.models import LocalAsset
from geonode.storage.manager import storage_manager
from geonode.utils import build_absolute_uri


logger = logging.getLogger(__name__)


class LocalAssetHandler(AssetHandlerInterface):
@staticmethod
def handled_asset_class():
return LocalAsset

def get_download_handler(self, asset):
return LocalAssetDownloadHandler()

def get_storage_manager(self, asset):
return storage_manager

def create(self, title, description, type, owner, files=None, clone_files=False, *args, **kwargs):
if not files:
raise ValueError("File(s) expected")

if clone_files:
prefix = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
files = storage_manager.copy_files_list(files, dir=settings.ASSETS_ROOT, dir_prefix=prefix)
# TODO: please note the copy_files_list will make flat any directory structure

asset = LocalAsset(
title=title,
description=description,
type=type,
owner=owner,
created=datetime.datetime.now(),
location=files,
)
asset.save()
return asset

def remove_data(self, asset: LocalAsset):
removed_dir = set()
for file in asset.location:
if file.startswith(settings.ASSETS_ROOT):
logger.info(f"Removing asset file {file}")
storage_manager.delete(file)
removed_dir.add(os.path.dirname(file))
else:
logger.info(f"Not removing asset file outside asset directory {file}")

for dir in removed_dir:
if not os.listdir(dir):
logger.info(f"Removing empty asset directory {dir}")
os.remove(dir)

def clone(self, asset: LocalAsset) -> LocalAsset:
prefix = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
asset.location = storage_manager.copy_files_list(asset.location, dir=settings.ASSETS_ROOT, dir_prefix=prefix)
asset.pk = None
asset.save()
return asset

def create_download_url(self, asset) -> str:
return build_absolute_uri(reverse("assets-download", args=(asset.pk,)))

def create_link_url(self, asset) -> str:
return build_absolute_uri(reverse("assets-link", args=(asset.pk,)))


class LocalAssetDownloadHandler(AssetDownloadHandlerInterface):

def create_response(self, asset: LocalAsset, attachment: bool = False, basename=None) -> HttpResponse:
if not asset.location:
return HttpResponse("Asset does not contain any data", status=500)

if len(asset.location) > 1:
logger.warning("TODO: Asset contains more than one file. Download needs to be implemented")

file0 = asset.location[0]
filename = os.path.basename(file0)
orig_base, ext = os.path.splitext(filename)
outname = f"{basename or orig_base}{ext}"

if storage_manager.exists(file0):
logger.info(f"Returning file {file0} with name {outname}")

return DownloadResponse(
storage_manager.open(file0).file,
basename=f"{outname}",
attachment=attachment,
)
else:
logger.warning(f"Internal file {file0} not found for asset {asset.id}")
return HttpResponse(f"Internal file not found for asset {asset.id}", status=500)


asset_handler_registry.register(LocalAssetHandler)
120 changes: 120 additions & 0 deletions geonode/assets/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import logging
import os.path

from django.http import HttpResponse

from geonode.assets.handlers import asset_handler_registry
from geonode.base.models import ResourceBase, Asset, Link
from geonode.security.utils import get_visible_resources


logger = logging.getLogger(__name__)


def get_perms_response(request, asset: Asset):
user = request.user

# quick check
is_admin = user.is_superuser if user and user.is_authenticated else False
if is_admin or user == asset.owner:
logger.debug("Asset: access allowed by user")
return None

visibile_res = get_visible_resources(queryset=ResourceBase.objects.filter(link__asset=asset), user=request.user)

logger.warning("TODO: implement permission check")
if visibile_res.exists():
logger.debug("Asset: access allowed by Resource")
return None
elif user and user.is_authenticated:
return HttpResponse(status=403)
else:
return HttpResponse(status=401)


def get_default_asset(resource: ResourceBase) -> Asset or None:
"""
Get the default asset for a ResourceBase.
In this first implementation we select the first one --
in the future there may be further flags to identify the preferred one
"""

return Asset.objects.filter(link__resource=resource).first()


DEFAULT_TYPES = {"image": ["jpg", "jpeg", "gif", "png", "bmp", "svg"]}


def find_type(ext):
return next((datatype for datatype, extensions in DEFAULT_TYPES.items() if ext.lower() in extensions), None)


def create_asset_and_link(
resource,
owner,
files: list,
handler=None,
title=None,
description=None,
link_type=None,
extension=None,
asset_type=None,
mime=None,
clone_files: bool = True,
) -> tuple[Asset, Link]:

asset_handler = handler or asset_handler_registry.get_default_handler()
asset = link = None
try:
default_title, default_ext = os.path.splitext(files[0]) if len(files) == 1 else (None, None)
link_type = link_type or find_type(default_ext[1:]) if default_ext else None

asset = asset_handler.create(
title=title or default_title or "Unknown",
description=description or asset_type or "Unknown",
type=asset_type or "Unknown",
owner=owner,
files=files,
clone_files=clone_files,
)

link = Link(
resource=resource,
asset=asset,
url=asset_handler.create_link_url(asset),
extension=extension or default_ext or "Unknown",
link_type=link_type or "data",
name=title or asset.title,
mime=mime or "",
)
link.save()
return asset, link
except Exception as e:
logger.error(f"Error creating Asset for resource {resource}: {e}", exc_info=e)
rollback_asset_and_link(asset, link)
raise Exception(f"Error creating asset: {e}")


def create_asset_and_link_dict(resource, values: dict, clone_files=True):
return create_asset_and_link(
resource,
values["owner"],
values["files"],
title=values.get("data_title", None),
description=values.get("description", None),
link_type=values.get("link_type", None),
extension=values.get("extension", None),
asset_type=values.get("data_type", None),
clone_files=clone_files,
)


def rollback_asset_and_link(asset, link):
try:
if link:
link.delete()
if asset:
asset.delete() # TODO: make sure we are only deleting from DB and not also the stored data
except Exception as e:
logger.error(f"Could not rollback asset[{asset}] and link[{link}]", exc_info=e)
49 changes: 49 additions & 0 deletions geonode/base/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@

from geonode.favorite.models import Favorite
from geonode.base.models import (
Asset,
LocalAsset,
Link,
ResourceBase,
HierarchicalKeyword,
Expand Down Expand Up @@ -839,3 +841,50 @@ def to_representation(self, instance: LinkedResource):
}
)
return data


class ClassTypeField(DynamicComputedField):

def get_attribute(self, instance):
return type(instance).__name__


class SimpleUserSerializer(DynamicModelSerializer):
class Meta:
model = get_user_model()
name = "user"
fields = ("pk", "username")


class AssetSubclassField(DynamicComputedField):
"""
Just an ugly hack.
TODO: We need a way to automatically use a proper serializer for each Asset subclass
in order to render different instances in a list
"""

def get_attribute(self, instance):
if type(instance).__name__ == "LocalAsset":
return {"locations": instance.location}

return None


class AssetSerializer(DynamicModelSerializer):

owner = SimpleUserSerializer(embed=False)
asset_type = ClassTypeField()
subinfo = AssetSubclassField()

class Meta:
model = Asset
name = "asset"
# fields = ("pk", "title", "description", "type", "owner", "created")
fields = ("pk", "title", "description", "type", "owner", "created", "asset_type", "subinfo")


class LocalAssetSerializer(AssetSerializer):
class Meta(AssetSerializer.Meta):
model = LocalAsset
name = "local_asset"
fields = AssetSerializer.Meta.fields + ("location",)
Loading

0 comments on commit c04e13a

Please sign in to comment.