From 733f90b1a42ae267e76bef4e88fdf9900dbb6a50 Mon Sep 17 00:00:00 2001 From: mutantsan Date: Sun, 28 Jan 2024 22:28:49 +0200 Subject: [PATCH] feature: implement file manager, WIP --- ckanext/file_manager/__init__.py | 0 ckanext/file_manager/collection.py | 139 ++++++++++++++++++ ckanext/file_manager/plugin.py | 49 ++++++ .../templates/file_manager/list.html | 38 +++++ .../templates/file_manager/record.html | 30 ++++ .../file_manager/upload_file_modal.html | 22 +++ .../file_manager/upload_file_modal_form.html | 5 + ckanext/file_manager/views.py | 126 ++++++++++++++++ ckanext/files/model/file.py | 4 + setup.cfg | 1 + 10 files changed, 414 insertions(+) create mode 100644 ckanext/file_manager/__init__.py create mode 100644 ckanext/file_manager/collection.py create mode 100644 ckanext/file_manager/plugin.py create mode 100644 ckanext/file_manager/templates/file_manager/list.html create mode 100644 ckanext/file_manager/templates/file_manager/record.html create mode 100644 ckanext/file_manager/templates/file_manager/upload_file_modal.html create mode 100644 ckanext/file_manager/templates/file_manager/upload_file_modal_form.html create mode 100644 ckanext/file_manager/views.py diff --git a/ckanext/file_manager/__init__.py b/ckanext/file_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ckanext/file_manager/collection.py b/ckanext/file_manager/collection.py new file mode 100644 index 0000000..8d517d9 --- /dev/null +++ b/ckanext/file_manager/collection.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from typing import Any + +from dominate import tags + +import ckan.plugins.toolkit as tk +import sqlalchemy as sa + +from ckanext.collection.types import InputFilter, LinkFilter +from ckanext.collection.utils import Filters, StatementSaData + +from ckanext.ap_main.collection.base import ( + ApCollection, + ApColumns, + BulkAction, + RowAction, + ApHtmxTableSerializer, +) + +from ckanext.files.model import File + + +class FileManagerCollection(ApCollection[Any]): + SerializerFactory = ApHtmxTableSerializer.with_attributes( + record_template="file_manager/record.html" + ) + + ColumnsFactory = ApColumns.with_attributes( + names=[ + "bulk-action", + "name", + "path", + "kind", + "uploaded_at", + "extras", + "row_actions", + ], + sortable={"name", "kind", "uploaded_at"}, + searchable={"name"}, + labels={ + "bulk-action": tk.literal( + tags.input_( + type="checkbox", + name="bulk_check", + id="bulk_check", + data_module="ap-bulk-check", + data_module_selector='input[name="id"]', + ) + ), + "name": "Name", + "path": "Path", + "kind": "Type", + "uploaded_at": "Uploaded At", + "extras": "Extras", + "row_actions": "Actions", + }, + width={"name": "20%", "path": "20%"}, + serializers={ + "uploaded_at": [("date", {})], + "extras": [("json_display", {})], + }, + ) + + DataFactory = StatementSaData.with_attributes( + model=File, + use_naive_filters=True, + use_naive_search=True, + statement=sa.select( + File.id.label("bulk-action"), + File.id.label("id"), + File.name.label("name"), + File.path.label("path"), + File.kind.label("kind"), + File.uploaded_at.label("uploaded_at"), + File.extras.label("extras"), + ), + ) + + FiltersFactory = Filters.with_attributes( + static_actions=[ + BulkAction( + name="bulk-action", + type="bulk_action", + options={ + "label": "Action", + "options": [ + {"value": "1", "text": "Remove selected files"}, + ], + }, + ), + RowAction( + name="edit", + type="row_action", + options={ + "endpoint": "", + "label": "", + "icon": "fa fa-pencil", + "params": { + "data-module-path": "$id", + "entity_type": "$type", + "view": "edit", + }, + }, + ), + RowAction( + name="view", + type="row_action", + options={ + "endpoint": "ap_content.entity_proxy", + "label": "View", + "params": { + "entity_id": "$id", + "entity_type": "$type", + "view": "read", + }, + }, + ), + ], + static_filters=[ + InputFilter( + name="q", + type="input", + options={ + "label": "Search", + "placeholder": "Search", + }, + ), + LinkFilter( + name="clear", + type="link", + options={ + "label": "Clear", + "endpoint": "file_manager.list", + "kwargs": {}, + }, + ), + ], + ) diff --git a/ckanext/file_manager/plugin.py b/ckanext/file_manager/plugin.py new file mode 100644 index 0000000..6abf7d9 --- /dev/null +++ b/ckanext/file_manager/plugin.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import ckan.plugins as p +import ckan.plugins.toolkit as tk + +import ckanext.ap_main.types as ap_types +from ckanext.ap_main.interfaces import IAdminPanel + +from ckanext.collection.interfaces import ICollection, CollectionFactory + +from ckanext.file_manager.collection import FileManagerCollection + + +@tk.blanket.blueprints +class FileManagerPlugin(p.SingletonPlugin): + p.implements(p.IConfigurer) + p.implements(IAdminPanel, inherit=True) + p.implements(ICollection, inherit=True) + + # IConfigurer + + def update_config(self, config_): + tk.add_template_directory(config_, "templates") + tk.add_public_directory(config_, "public") + tk.add_resource("assets", "file_manager") + + # IAdminPanel + + def register_config_sections( + self, config_list: list[ap_types.SectionConfig] + ) -> list[ap_types.SectionConfig]: + config_list.append( + ap_types.SectionConfig( + name="Files", + configs=[ + ap_types.ConfigurationItem( + name="File manager", + blueprint="file_manager.list", + info="Manage uploaded files", + ) + ], + ) + ) + return config_list + + # ICollection + + def get_collection_factories(self) -> dict[str, CollectionFactory]: + return {"file-manager": FileManagerCollection} diff --git a/ckanext/file_manager/templates/file_manager/list.html b/ckanext/file_manager/templates/file_manager/list.html new file mode 100644 index 0000000..72665c8 --- /dev/null +++ b/ckanext/file_manager/templates/file_manager/list.html @@ -0,0 +1,38 @@ +{% extends 'admin_panel/base.html' %} + +{% import 'macros/autoform.html' as autoform %} +{% import 'macros/form.html' as form %} +{% import 'admin_panel/macros/form.html' as ap_form %} + +{% block ap_main_class %} ap-log-list {% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for _("File manager"), request.endpoint %}
  • +{% endblock breadcrumb_content %} + +{% block ap_content %} +
    + +
    + +
    + {% snippet 'file_manager/upload_file_modal.html' %} +
    + +
    + {% if collection.data.total %} + {{ collection.serializer.render() | safe }} + {% else %} +

    + {{ _("No files found") }} + {{ _("Clear the search") }} +

    + {% endif %} +
    +{% endblock ap_content %} diff --git a/ckanext/file_manager/templates/file_manager/record.html b/ckanext/file_manager/templates/file_manager/record.html new file mode 100644 index 0000000..3b4c914 --- /dev/null +++ b/ckanext/file_manager/templates/file_manager/record.html @@ -0,0 +1,30 @@ +{% extends "collection/serialize/ap_htmx_table/record.html" %} + +{% block value %} + {% if column == "row_actions" %} + + + + + + + + + + + + + {% else %} + {{ super() }} + {% endif %} +{% endblock value %} diff --git a/ckanext/file_manager/templates/file_manager/upload_file_modal.html b/ckanext/file_manager/templates/file_manager/upload_file_modal.html new file mode 100644 index 0000000..142a136 --- /dev/null +++ b/ckanext/file_manager/templates/file_manager/upload_file_modal.html @@ -0,0 +1,22 @@ +{% import 'macros/form.html' as form %} + + + diff --git a/ckanext/file_manager/templates/file_manager/upload_file_modal_form.html b/ckanext/file_manager/templates/file_manager/upload_file_modal_form.html new file mode 100644 index 0000000..a2bade9 --- /dev/null +++ b/ckanext/file_manager/templates/file_manager/upload_file_modal_form.html @@ -0,0 +1,5 @@ + diff --git a/ckanext/file_manager/views.py b/ckanext/file_manager/views.py new file mode 100644 index 0000000..7fe2cbe --- /dev/null +++ b/ckanext/file_manager/views.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import logging +from typing import Union, Callable + +from flask import Blueprint, Response, jsonify +from flask.views import MethodView + +import ckan.plugins.toolkit as tk +from ckan.logic import parse_params + +from ckanext.collection.shared import get_collection + +from ckanext.ap_main.utils import ap_before_request +from ckanext.ap_main.views.generics import ApConfigurationPageView + + +log = logging.getLogger(__name__) +file_manager = Blueprint( + "file_manager", __name__, url_prefix="/admin-panel/file_manager" +) +file_manager.before_request(ap_before_request) + + +# file_manager.add_url_rule( +# "/file_manager", +# view_func=ApConfigurationPageView.as_view( +# "list", +# "file_manager_config", +# page_title=tk._("File manager"), +# ), +# ) + + +class FileManagerView(MethodView): + def get(self) -> Union[str, Response]: + return tk.render( + "file_manager/list.html", + extra_vars={ + "collection": get_collection( + "file-manager", parse_params(tk.request.args) + ), + }, + ) + + def post(self) -> Response: + bulk_action = tk.request.form.get("bulk-action") + file_ids = tk.request.form.getlist("file_id") + + action_func = ( + self._get_bulk_action(bulk_action) if bulk_action else None + ) + + if not action_func: + tk.h.flash_error(tk._("The bulk action is not implemented")) + return tk.redirect_to("file_manager.list") + + for file_id in file_ids: + try: + action_func(file_id) + except tk.ValidationError as e: + tk.h.flash_error(str(e)) + + return tk.redirect_to("file_manager.list") + + def _get_bulk_action(self, value: str) -> Callable[[str], None] | None: + return { + "1": self._remove_file, + }.get(value) + + def _remove_file(self, file_id: str) -> None: + tk.get_action("files_file_delete")( + {"ignore_auth": True}, + {"id": file_id}, + ) + + +class FileManagerUploadView(MethodView): + def post(self): + file = tk.request.files.get("upload") + + if not file: + tk.h.flash_error(tk._("Missing file object")) + return tk.redirect_to("file_manager.list") + + try: + tk.get_action("files_file_create")( + {"ignore_auth": True}, + { + "name": file.filename, + "upload": file, + }, + ) + except (tk.ValidationError, OSError) as e: + tk.h.flash_error(str(e.error_summary)) + return tk.redirect_to("file_manager.list") + + tk.h.flash_success(tk._("File has been uploaded!")) + return tk.redirect_to("file_manager.list") + + +class FileManagerDeleteView(MethodView): + def post(self, file_id: str): + try: + tk.get_action("files_file_delete")( + {"ignore_auth": True}, {"id": file_id} + ) + except (tk.ValidationError, OSError) as e: + tk.h.flash_error(str(e.error_summary)) + return tk.redirect_to("file_manager.list") + + tk.h.flash_success(tk._("File has been deleted!")) + return tk.redirect_to("file_manager.list") + + +file_manager.delete + +file_manager.add_url_rule("/manage", view_func=FileManagerView.as_view("list")) +file_manager.add_url_rule( + "/upload", view_func=FileManagerUploadView.as_view("upload") +) +file_manager.add_url_rule( + "/delete/", view_func=FileManagerDeleteView.as_view("delete") +) + +blueprints = [file_manager] diff --git a/ckanext/files/model/file.py b/ckanext/files/model/file.py index 92151ce..bebd2ca 100644 --- a/ckanext/files/model/file.py +++ b/ckanext/files/model/file.py @@ -27,3 +27,7 @@ def dictize(self, context): result = table_dictize(self, context) result["url"] = tk.h.url_for_static(result["path"], qualified=True) return result + + @property + def url(self): + return tk.h.url_for_static(self.path, qualified=True) diff --git a/setup.cfg b/setup.cfg index f26ec30..c945eb1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ include_package_data = True [options.entry_points] ckan.plugins = files = ckanext.files.plugin:FilesPlugin + file_manager = ckanext.file_manager.plugin:FileManagerPlugin babel.extractors = ckan = ckan.lib.extract:extract_ckan [extract_messages]