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 #12124] GNIP 100: Assets #12276

Merged
merged 6 commits into from
May 28, 2024
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
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ SECRET_KEY='{secret_key}'

STATIC_ROOT=/mnt/volumes/statics/static/
MEDIA_ROOT=/mnt/volumes/statics/uploaded/
ASSETS_ROOT=/mnt/volumes/statics/assets/
GEOIP_PATH=/mnt/volumes/statics/geoip.db

CACHE_BUSTING_STATIC_ENABLED=False
Expand Down
Empty file added geonode/assets/__init__.py
Empty file.
35 changes: 35 additions & 0 deletions geonode/assets/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#########################################################################
#
# Copyright (C) 2016 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 django.apps import AppConfig

from geonode.notifications_helper import NotificationsAppConfigBase


class BaseAppConfig(NotificationsAppConfigBase, AppConfig):
name = "geonode.assets"

def ready(self):
super().ready()
run_setup_hooks()


def run_setup_hooks(*args, **kwargs):
from geonode.assets.handlers import asset_handler_registry

asset_handler_registry.init_registry()
91 changes: 91 additions & 0 deletions geonode/assets/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import logging

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

from geonode.assets.models import Asset

logger = logging.getLogger(__name__)


class AssetHandlerInterface:

def handled_asset_class(self):
raise NotImplementedError()

Check warning on line 15 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L15

Added line #L15 was not covered by tests

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

Check warning on line 18 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L18

Added line #L18 was not covered by tests

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

Check warning on line 21 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L21

Added line #L21 was not covered by tests

def replace_data(self, asset: Asset, files: list):
raise NotImplementedError()

Check warning on line 24 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L24

Added line #L24 was not covered by tests

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

Check warning on line 30 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L30

Added line #L30 was not covered by tests

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

Check warning on line 33 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L33

Added line #L33 was not covered by tests

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

Check warning on line 36 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L36

Added line #L36 was not covered by tests

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

Check warning on line 39 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L39

Added line #L39 was not covered by tests


class AssetDownloadHandlerInterface:

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

Check warning on line 45 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L45

Added line #L45 was not covered by tests


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}")

Check warning on line 70 in geonode/assets/handlers.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/handlers.py#L70

Added line #L70 was not covered by tests
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 asset handler for {asset_cls}::{asset.__class__}")
logger.warning("Available asset types:")
for k, v in self._registry.items():
logger.warning(f"{k} --> {v.__class__.__name__}")
return ret


asset_handler_registry = AssetHandlerRegistry()
159 changes: 159 additions & 0 deletions geonode/assets/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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.assets.models import LocalAsset
from geonode.storage.manager import DefaultStorageManager, StorageManager
from geonode.utils import build_absolute_uri, mkdtemp

logger = logging.getLogger(__name__)

_asset_storage_manager = StorageManager(
concrete_storage_manager=DefaultStorageManager(location=os.path.dirname(settings.ASSETS_ROOT))
)


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 _asset_storage_manager

def _create_asset_dir(self):
return os.path.normpath(
mkdtemp(dir=settings.ASSETS_ROOT, prefix=datetime.datetime.now().strftime("%Y%m%d%H%M%S"))
)

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

Check warning on line 40 in geonode/assets/local.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/local.py#L40

Added line #L40 was not covered by tests

if clone_files:
prefix = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
files = _asset_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):
"""
Removes the files related to an Asset.
Only files within the Assets directory are removed
"""
removed_dir = set()
for file in asset.location:
is_managed = self._is_file_managed(file)
if is_managed:
logger.info(f"Removing asset file {file}")
_asset_storage_manager.delete(file)
removed_dir.add(os.path.dirname(file))
else:
logger.info(f"Not removing asset file outside asset directory {file}")

# TODO: in case of subdirs, make sure that all the tree is removed in the proper order
for dir in removed_dir:
if not os.path.exists(dir):
logger.warning(f"Trying to remove not existing asset directory {dir}")
continue
if not os.listdir(dir):
logger.info(f"Removing empty asset directory {dir}")
os.rmdir(dir)

def replace_data(self, asset: LocalAsset, files: list):
self.remove_data(asset)
asset.location = files
asset.save()

def clone(self, source: LocalAsset) -> LocalAsset:
# get a new asset instance to be edited and stored back
asset = LocalAsset.objects.get(pk=source.pk)
# only copy files if they are managed
if self._are_files_managed(asset.location):
asset.location = _asset_storage_manager.copy_files_list(
asset.location, dir=settings.ASSETS_ROOT, dir_prefix=datetime.datetime.now().strftime("%Y%m%d%H%M%S")
)
# it's a polymorphic object, we need to null both IDs
# https://django-polymorphic.readthedocs.io/en/stable/advanced.html#copying-polymorphic-objects
asset.pk = None
asset.id = None
asset.save()
asset.refresh_from_db()
return asset

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

Check warning on line 104 in geonode/assets/local.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/local.py#L104

Added line #L104 was not covered by tests

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

def _is_file_managed(self, file) -> bool:
assets_root = os.path.normpath(settings.ASSETS_ROOT)
return file.startswith(assets_root)

def _are_files_managed(self, files: list) -> bool:
"""
:param files: files to be checked
:return: True if all files are managed, False is no file is managed
:raise: ValueError if both managed and unmanaged files are in the list
"""
managed = unmanaged = None
for file in files:
if self._is_file_managed(file):
managed = True
else:
unmanaged = True
if managed and unmanaged:
logger.error(f"Both managed and unmanaged files are present: {files}")
raise ValueError("Both managed and unmanaged files are present")

return bool(managed)


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)

Check warning on line 136 in geonode/assets/local.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/local.py#L136

Added line #L136 was not covered by tests

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

Check warning on line 139 in geonode/assets/local.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/local.py#L139

Added line #L139 was not covered by tests

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

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

return DownloadResponse(
_asset_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)

Check warning on line 156 in geonode/assets/local.py

View check run for this annotation

Codecov / codecov/patch

geonode/assets/local.py#L155-L156

Added lines #L155 - L156 were not covered by tests


asset_handler_registry.register(LocalAssetHandler)
63 changes: 63 additions & 0 deletions geonode/assets/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Generated by Django 4.2.9 on 2024-04-24 10:02

from django.conf import settings
from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("base", "0091_alter_hierarchicalkeyword_slug"),
]

operations = [
migrations.CreateModel(
name="Asset",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
("type", models.CharField(max_length=255)),
("created", models.DateTimeField(auto_now_add=True)),
("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"polymorphic_ctype",
models.ForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="polymorphic_%(app_label)s.%(class)s_set+",
to="contenttypes.contenttype",
),
),
],
options={
"verbose_name_plural": "Assets",
},
),
migrations.CreateModel(
name="LocalAsset",
fields=[
(
"asset_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="assets.asset",
),
),
("location", models.JSONField(blank=True, default=list)),
],
options={
"verbose_name_plural": "Local assets",
},
bases=("assets.asset",),
),
]
Empty file.
Loading
Loading