From 37ea4944f54aaaed314ab3ea9fe31e0564de2993 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Dec 2023 18:19:31 +0100 Subject: [PATCH 1/9] Introduced UIResourceComponent, clarified its API --- oarepo_ui/config.py | 13 ++ oarepo_ui/ext.py | 3 + oarepo_ui/resources/components.py | 252 +++++++++++++++++++++++++----- oarepo_ui/resources/config.py | 14 +- oarepo_ui/resources/resource.py | 92 +++++------ 5 files changed, 267 insertions(+), 107 deletions(-) diff --git a/oarepo_ui/config.py b/oarepo_ui/config.py index aaa93e80..2748ff4a 100644 --- a/oarepo_ui/config.py +++ b/oarepo_ui/config.py @@ -8,3 +8,16 @@ THEME_HEADER_LOGIN_TEMPLATE = "oarepo_ui/header_login.html" OAREPO_UI_JINJAX_FILTERS = {} + +OAREPO_UI_RECORD_ACTIONS = [ + "edit", + "new_version", + "manage", + "update_draft", + "read_files", + "review", + "view", + "delete_draft", + "manage_files", + "manage_record_access", +] diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py index 578617b3..8e8d895b 100644 --- a/oarepo_ui/ext.py +++ b/oarepo_ui/ext.py @@ -144,6 +144,9 @@ def development_after_request(self, response: Response): return add_vite_tags(response) + @property + def record_actions(self): + return self.app.config["OAREPO_UI_RECORD_ACTIONS"] class OARepoUIExtension: def __init__(self, app=None): diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py index 3e02a0c1..c679c352 100644 --- a/oarepo_ui/resources/components.py +++ b/oarepo_ui/resources/components.py @@ -1,12 +1,180 @@ from flask import current_app +from flask_principal import Identity from invenio_i18n.ext import current_i18n -from invenio_records_resources.services.records.components import ServiceComponent +from invenio_records_resources.services.records.results import RecordItem from oarepo_runtime.datastreams.utils import get_file_service_for_record_service +from typing import Dict, TYPE_CHECKING +from ..proxies import current_oarepo_ui -class BabelComponent(ServiceComponent): +if TYPE_CHECKING: + from .resource import UIResource + + +class UIResourceComponent: + """ + Only the currently used methods and their parameters are in this interface. + Custom resources can add their own methods/parameters. + + You are free to base your implementation on this class or base it directly on ServiceComponent. + + Component gets the resource instance as a parameter in the constructor and can use .config property to access + the resource configuration. + + Naming convention for parameters: + * record - the record being displayed, always is an instance of RecordItem + * data - data serialized by the API service serializer. A dictionary + * ui_data - UI serialization of the record as comes from the ui serializer. A dictionary + * empty_data - empty record data, compatible with the API service serializer. A dictionary + """ + + def __init__(self, resource: "UIResource"): + """ + :param resource: the resource instance + """ + self.resource = resource + + @property + def config(self): + """The UI configuration.""" + return self.resource.config + + def empty_record(self, *, resource_requestctx, empty_data: Dict, **kwargs): + """ + Called before an empty record data are returned. + + :param resource_requestctx: invenio request context (see https://github.com/inveniosoftware/flask-resources/blob/master/flask_resources/context.py) + :param empty_data: empty record data + """ + + def fill_jinja_context(self, *, context: Dict, **kwargs): + """This method is called from flask/jinja context processor before the template starts rendering. + You can add your own variables to the context here. + + :param context: the context dictionary that will be merged into the template's context + """ + + def before_ui_detail( + self, + *, + record: RecordItem, + ui_data: Dict, + identity: Identity, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs, + ): + """ + Called before the detail page is rendered. + + :param record: the record being displayed + :param ui_data: UI serialization of the record + :param identity: the current user identity + :param args: query parameters + :param view_args: view arguments + :param ui_links: UI links for the record, a dictionary of link name -> link url + :param extra_context: will be passed to the template as the "extra_context" variable + """ + def before_ui_search(self, *, + identity: Identity, + search_options: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs): + """ + Called before the search page is rendered. + Note: search results are fetched via AJAX, so are not available in this method. + This method just provides the context for the jinjax template of the search page. + + :param identity: the current user identity + :param search_options: dictionary of search options, containing api_config, identity, overrides. + It is fed to self.config.search_app_config as **search_options + :param args: query parameters + :param view_args: view arguments + :param ui_links: UI links for the search page, a dictionary of link name -> link url + :param extra_context: will be passed to the template as the "extra_context" variable + """ + + def form_config(self, *, + record: RecordItem, + data: Dict, + identity: Identity, + form_config: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs): + """ + Called to fill form_config for the create/edit page. + + :param record: the record being edited. Can be None if creating a new record. + :param data: data serialized by the API service serializer. If a record is being edited, + this is the serialized record data. If a new record is being created, this is empty_data + after being processed by the empty_record method on registered UI components. + :param identity: the current user identity + :param form_config: form configuration dictionary + :param args: query parameters + :param view_args: view arguments + :param ui_links: UI links for the create/edit page, a dictionary of link name -> link url + :param extra_context: will be passed to the template as the "extra_context" variable + """ + + def before_ui_edit(self, *, + record: RecordItem, + data: Dict, + ui_data: Dict, + identity: Identity, + form_config: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs): + """ + Called before the edit page is rendered, after form_config has been filled. + + :param record: the record being edited + :param data: data serialized by the API service serializer. This is the serialized record data. + :param ui_data: UI serialization of the record (localized). The ui data can be used in the edit + template to display, for example, the localized record title. + :param identity: the current user identity + :param form_config: form configuration dictionary + :param args: query parameters + :param view_args: view arguments + :param ui_links: UI links for the edit page, a dictionary of link name -> link url + :param extra_context: will be passed to the template as the "extra_context" variable + """ + + def before_ui_create(self, *, + data: Dict, + identity: Identity, + form_config: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs): + """ + Called before the create page is rendered, after form_config has been filled + + :param data: A dictionary with empty data (show just the structure of the record, with values replaced by None) + :param identity: the current user identity + :param form_config: form configuration dictionary + :param args: query parameters + :param view_args: view arguments + :param ui_links: UI links for the create page, a dictionary of link name -> link url + :param extra_context: will be passed to the template as the "extra_context" variable + """ + + +class BabelComponent(UIResourceComponent): def form_config( - self, *, form_config, resource, record, view_args, identity, **kwargs + self, *, form_config, **kwargs ): conf = current_app.config locales = [] @@ -23,60 +191,62 @@ def form_config( form_config.setdefault("locales", locales) -class PermissionsComponent(ServiceComponent): - def get_record_permissions(self, actions, service, identity, record=None): - """Helper for generating (default) record action permissions.""" - return { - f"can_{action}": service.check_permission(identity, action, record=record) - for action in actions - } - - def before_ui_detail(self, *, resource, record, extra_context, identity, **kwargs): - self.fill_permissions(resource, record, extra_context, identity) +class PermissionsComponent(UIResourceComponent): + def before_ui_detail(self, *, record, extra_context, identity, **kwargs): + self.fill_permissions(record.data, extra_context, identity) - def before_ui_edit(self, *, resource, record, extra_context, identity, **kwargs): - self.fill_permissions(resource, record, extra_context, identity) + def before_ui_edit(self, *, record, extra_context, identity, **kwargs): + self.fill_permissions(record.data, extra_context, identity) - def before_ui_create(self, *, resource, record, extra_context, identity, **kwargs): - self.fill_permissions(resource, record, extra_context, identity) + def before_ui_create(self, *, extra_context, identity, **kwargs): + self.fill_permissions(None, extra_context, identity) def before_ui_search( - self, *, resource, extra_context, identity, search_options, **kwargs + self, *, extra_context, identity, search_options, **kwargs ): - extra_context["permissions"] = self.get_record_permissions( - ["create"], resource.api_service, identity - ) + from .resource import RecordsUIResource + if not isinstance(self.resource, RecordsUIResource): + return + + extra_context["permissions"] = { + f"can_create": self.resource.api_service.check_permission(identity, "create") + } + search_options["permissions"] = extra_context["permissions"] def form_config( - self, *, form_config, resource, record, view_args, identity, **kwargs + self, *, form_config, record, identity, **kwargs ): - self.fill_permissions(resource, record, form_config, identity) + self.fill_permissions(record, form_config, identity) + + def get_record_permissions(self, actions, service, identity, data): + """Helper for generating (default) record action permissions.""" + return { + f"can_{action}": service.check_permission(identity, action, record=data) + for action in actions + } + + def fill_permissions(self, data, extra_context, identity): + from .resource import RecordsUIResource + if not isinstance(self.resource, RecordsUIResource): + return - def fill_permissions(self, resource, record, extra_context, identity): extra_context["permissions"] = self.get_record_permissions( - [ - "edit", - "new_version", - "manage", - "update_draft", - "read_files", - "review", - "view", - "delete_draft", - "manage_files", - "manage_record_access", - ], - resource.api_service, + current_oarepo_ui.record_actions, + self.resource.api_service, identity, - record, + data, ) -class FilesComponent(ServiceComponent): - def before_ui_edit(self, *, record, resource, extra_context, identity, **kwargs): +class FilesComponent(UIResourceComponent): + def before_ui_edit(self, *, record, extra_context, identity, **kwargs): + from .resource import RecordsUIResource + if not isinstance(self.resource, RecordsUIResource): + return + file_service = get_file_service_for_record_service( - resource.api_service, record=record + self.resource.api_service, record=record ) files = file_service.list_files(identity, record["id"]) extra_context["files"] = files.to_dict() diff --git a/oarepo_ui/resources/config.py b/oarepo_ui/resources/config.py index 233a1dce..d20eec71 100644 --- a/oarepo_ui/resources/config.py +++ b/oarepo_ui/resources/config.py @@ -61,16 +61,12 @@ class RecordsUIResourceConfig(UIResourceConfig): """Name of the API service as registered inside the service registry""" templates = { - "detail": { - "layout": "oarepo_ui/detail.html", - }, - "search": { - "layout": "oarepo_ui/search.html", - }, - "edit": {"layout": "oarepo_ui/form.html"}, - "create": {"layout": "oarepo_ui/form.html"}, + "detail": None, + "search": None, + "edit": None, + "create": None, } - layout = "sample" + """Templates used for rendering the UI. It is a name of a jinjax macro that renders the UI""" empty_record = {} diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py index 52a91789..50f38f65 100644 --- a/oarepo_ui/resources/resource.py +++ b/oarepo_ui/resources/resource.py @@ -1,5 +1,6 @@ import copy from functools import partial +from typing import Iterator, TYPE_CHECKING import deepmerge from flask import abort, g, redirect, request @@ -24,6 +25,9 @@ from oarepo_ui.utils import dump_empty +if TYPE_CHECKING: + from .components import UIResourceComponent + # # Resource # @@ -56,7 +60,7 @@ def as_blueprint(self, **options): # Pluggable components # @property - def components(self): + def components(self) -> Iterator["UIResourceComponent"]: """Return initialized service components.""" return (c(self) for c in self.config.components or []) @@ -96,27 +100,27 @@ def create_url_rules(self): def empty_record(self, resource_requestctx, **kwargs): """Create an empty record with default values.""" - record = dump_empty(self.api_config.schema) + empty_data = dump_empty(self.api_config.schema) files_field = getattr(self.api_config.record_cls, "files", None) if files_field and isinstance(files_field, FilesField): - record["files"] = {"enabled": False} - record = deepmerge.always_merger.merge( - record, copy.deepcopy(self.config.empty_record) + empty_data["files"] = {"enabled": False} + empty_data = deepmerge.always_merger.merge( + empty_data, copy.deepcopy(self.config.empty_record) ) self.run_components( - "empty_record", resource_requestctx=resource_requestctx, record=record + "empty_record", resource_requestctx=resource_requestctx, empty_data=empty_data ) - return record + return empty_data def as_blueprint(self, **options): blueprint = super().as_blueprint(**options) - blueprint.app_context_processor(lambda: self.register_context_processor()) + blueprint.app_context_processor(lambda: self.fill_jinja_context()) return blueprint - def register_context_processor(self): + def fill_jinja_context(self): """function providing flask template app context processors""" ret = {} - self.run_components("register_context_processor", context_processors=ret) + self.run_components("fill_jinja_context", context=ret) return ret @request_read_args @@ -126,32 +130,31 @@ def detail(self): """Returns item detail page.""" record = self._get_record(resource_requestctx, allow_draft=False) # TODO: handle permissions UI way - better response than generic error - serialized_record = self.config.ui_serializer.dump_obj(record.to_dict()) + ui_data = self.config.ui_serializer.dump_obj(record.to_dict()) # make links absolute - if "links" in serialized_record: - for k, v in list(serialized_record["links"].items()): + if "links" in ui_data: + for k, v in list(ui_data["links"].items()): if not isinstance(v, str): continue if not v.startswith("/") and not v.startswith("https://"): v = f"/api{self.api_service.config.url_prefix}{v}" - serialized_record["links"][k] = v + ui_data["links"][k] = v export_path = request.path.split("?")[0] if not export_path.endswith("/"): export_path += "/" export_path += "export" - layout = current_oarepo_ui.get_layout(self.get_layout_name()) _catalog = current_oarepo_ui.catalog - template_def = self.get_template_def("detail") + template_def = fields = ["metadata", "ui", "layout", "record", "extra_context"] source = get_jinja_template(_catalog, template_def, fields) extra_context = dict() ui_links = self.expand_detail_links(identity=g.identity, record=record) - serialized_record["extra_links"] = { + ui_data["extra_links"] = { "ui_links": ui_links, "export_path": export_path, "search_link": self.config.url_prefix, @@ -159,27 +162,22 @@ def detail(self): self.run_components( "before_ui_detail", - resource=self, - record=serialized_record, + record=record, + ui_data=ui_data, identity=g.identity, extra_context=extra_context, args=resource_requestctx.args, view_args=resource_requestctx.view_args, ui_links=ui_links, - ui_config=self.config, - ui_resource=self, - layout=layout, - component_key="search", ) - - metadata = dict(serialized_record.get("metadata", serialized_record)) + metadata = dict(ui_data.get("metadata", ui_data)) return _catalog.render( "detail", + # TODO: Alzbeta: why is this here? It does not seem to be used anywhere inside the library __source=source, metadata=metadata, - ui=dict(serialized_record.get("ui", serialized_record)), - layout=dict(layout), - record=serialized_record, + ui=dict(ui_data.get("ui", ui_data)), + record=ui_data, extra_context=extra_context, ui_links=ui_links, ) @@ -217,8 +215,6 @@ def search(self): ] source = get_jinja_template(_catalog, template_def, fields) - layout = current_oarepo_ui.get_layout(self.get_layout_name()) - page = resource_requestctx.args.get("page", 1) size = resource_requestctx.args.get("size", 10) pagination = Pagination( @@ -239,22 +235,15 @@ def search(self): ) extra_context = dict() - links = self.expand_search_links( - g.identity, pagination, resource_requestctx.args - ) self.run_components( "before_ui_search", - resource=self, identity=g.identity, search_options=search_options, args=resource_requestctx.args, view_args=resource_requestctx.view_args, ui_config=self.config, - ui_resource=self, ui_links=ui_links, - layout=layout, - component_key="search", extra_context=extra_context, ) @@ -268,7 +257,6 @@ def search(self): search_app_config=search_app_config, ui_config=self.config, ui_resource=self, - layout=layout, ui_links=ui_links, extra_context=extra_context, ) @@ -300,8 +288,6 @@ def export(self): } return (exported_record, 200, headers) - def get_layout_name(self): - return self.config.layout def get_template_def(self, template_type): return self.config.templates[template_type] @@ -312,8 +298,7 @@ def get_template_def(self, template_type): def edit(self): record = self._get_record(resource_requestctx, allow_draft=True) data = record.to_dict() - serialized_record = self.config.ui_serializer.dump_obj(record.to_dict()) - layout = current_oarepo_ui.get_layout(self.get_layout_name()) + ui_record = self.config.ui_serializer.dump_obj(record.to_dict()) form_config = self.config.form_config( identity=g.identity, updateUrl=record.links.get("self", None) ) @@ -324,22 +309,19 @@ def edit(self): self.run_components( "form_config", - layout=layout, - resource=self, record=record, - data=record, + data=data, + identity=g.identity, form_config=form_config, args=resource_requestctx.args, view_args=resource_requestctx.view_args, - identity=g.identity, ui_links=ui_links, extra_context=extra_context, ) self.run_components( "before_ui_edit", - layout=layout, resource=self, - record=serialized_record, + record=ui_record, data=data, form_config=form_config, args=resource_requestctx.args, @@ -348,12 +330,14 @@ def edit(self): identity=g.identity, extra_context=extra_context, ) + template_def = self.get_template_def("edit") + _catalog = current_oarepo_ui.catalog source = get_jinja_template( _catalog, template_def, ["record", "extra_context", "form_config", "data"] ) - serialized_record["extra_links"] = { + ui_record["extra_links"] = { "ui_links": ui_links, "search_link": self.config.url_prefix, } @@ -361,7 +345,7 @@ def edit(self): return _catalog.render( "edit", __source=source, - record=serialized_record, + record=ui_record, form_config=form_config, extra_context=extra_context, ui_links=ui_links, @@ -373,7 +357,6 @@ def edit(self): @request_view_args def create(self): empty_record = self.empty_record(resource_requestctx) - layout = current_oarepo_ui.get_layout(self.get_layout_name()) form_config = self.config.form_config( identity=g.identity, # TODO: use api service create link when available @@ -383,9 +366,7 @@ def create(self): self.run_components( "form_config", - layout=layout, - resource=self, - record=empty_record, + record=None, data=empty_record, form_config=form_config, args=resource_requestctx.args, @@ -395,9 +376,6 @@ def create(self): ) self.run_components( "before_ui_create", - layout=layout, - resource=self, - record=empty_record, data=empty_record, form_config=form_config, args=resource_requestctx.args, From a9978128dd622ace1c530fc4318351ae5f04261f Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Dec 2023 18:20:06 +0100 Subject: [PATCH 2/9] typo --- oarepo_ui/resources/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py index 50f38f65..e274f0f5 100644 --- a/oarepo_ui/resources/resource.py +++ b/oarepo_ui/resources/resource.py @@ -147,7 +147,7 @@ def detail(self): _catalog = current_oarepo_ui.catalog - template_def = + template_def = self.get_template_def("detail") fields = ["metadata", "ui", "layout", "record", "extra_context"] source = get_jinja_template(_catalog, template_def, fields) From c1c9733a309f31abe8b103370ef26e1f39cff6d4 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Dec 2023 19:22:36 +0100 Subject: [PATCH 3/9] Template optimizations (layout + blocks removed as they can be done in client code if needed) --- oarepo_ui/ext.py | 5 +- oarepo_ui/resources/catalog.py | 24 --------- oarepo_ui/resources/config.py | 3 ++ oarepo_ui/resources/resource.py | 90 +++++++++++--------------------- tests/model.py | 4 +- tests/templates/TestDetail.jinja | 3 +- 6 files changed, 41 insertions(+), 88 deletions(-) diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py index 8e8d895b..5387b899 100644 --- a/oarepo_ui/ext.py +++ b/oarepo_ui/ext.py @@ -101,7 +101,10 @@ def _catalog_config(self, catalog, env): env.policies.setdefault("json.dumps_kwargs", {}).setdefault("default", str) self.app.update_template_context(context) catalog.jinja_env.loader = env.loader - catalog.jinja_env.autoescape = env.autoescape + + # autoescape everything (this catalogue is used just for html jinjax components, so can do that) ... + catalog.jinja_env.autoescape = True + context.update(catalog.jinja_env.globals) context.update(env.globals) catalog.jinja_env.globals = context diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py index 4560e7a3..d58bb4d4 100644 --- a/oarepo_ui/resources/catalog.py +++ b/oarepo_ui/resources/catalog.py @@ -91,30 +91,6 @@ def _get_from_file( return component -def get_jinja_template(_catalog, template_def, fields=None): - if fields is None: - fields = [] - jinja_content = None - for component in _catalog.jinja_env.loader.searchpath: - if component["component_file"].endswith(template_def["layout"]): - with open(component["component_file"], "r") as file: - jinja_content = file.read() - if not jinja_content: - raise Exception("%s was not found" % (template_def["layout"])) - assembled_template = [jinja_content] - if "blocks" in template_def: - for blk_name, blk in template_def["blocks"].items(): - component_content = "" - for field in fields: - component_content = component_content + "%s={%s} " % (field, field) - component_str = "<%s %s> </%s>" % (blk, component_content, blk) - assembled_template.append( - "{%% block %s %%}%s{%% endblock %%}" % (blk_name, component_str) - ) - assembled_template = "\n".join(assembled_template) - return assembled_template - - def lazy_string_encoder(obj): if isinstance(obj, list): return [lazy_string_encoder(item) for item in obj] diff --git a/oarepo_ui/resources/config.py b/oarepo_ui/resources/config.py index d20eec71..78223248 100644 --- a/oarepo_ui/resources/config.py +++ b/oarepo_ui/resources/config.py @@ -60,6 +60,9 @@ class RecordsUIResourceConfig(UIResourceConfig): api_service = None """Name of the API service as registered inside the service registry""" + search_app_id = None + """ID of the app used for rendering the search config""" + templates = { "detail": None, "search": None, diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py index e274f0f5..0fe808c8 100644 --- a/oarepo_ui/resources/resource.py +++ b/oarepo_ui/resources/resource.py @@ -32,7 +32,6 @@ # Resource # from ..proxies import current_oarepo_ui -from .catalog import get_jinja_template from .config import RecordsUIResourceConfig, UIResourceConfig request_export_args = request_parser( @@ -127,38 +126,30 @@ def fill_jinja_context(self): @request_view_args def detail(self): """Returns item detail page.""" - """Returns item detail page.""" + record = self._get_record(resource_requestctx, allow_draft=False) + # TODO: handle permissions UI way - better response than generic error ui_data = self.config.ui_serializer.dump_obj(record.to_dict()) - # make links absolute - if "links" in ui_data: - for k, v in list(ui_data["links"].items()): - if not isinstance(v, str): - continue - if not v.startswith("/") and not v.startswith("https://"): - v = f"/api{self.api_service.config.url_prefix}{v}" - ui_data["links"][k] = v + ui_data.setdefault("links", {}) + ui_links = self.expand_detail_links(identity=g.identity, record=record) export_path = request.path.split("?")[0] if not export_path.endswith("/"): export_path += "/" export_path += "export" - _catalog = current_oarepo_ui.catalog + ui_data["links"].update( + { + "ui_links": ui_links, + "export_path": export_path, + "search_link": self.config.url_prefix, + } + ) - template_def = self.get_template_def("detail") - fields = ["metadata", "ui", "layout", "record", "extra_context"] - source = get_jinja_template(_catalog, template_def, fields) + self.make_links_absolute(ui_data["links"], self.api_service.config.url_prefix) extra_context = dict() - ui_links = self.expand_detail_links(identity=g.identity, record=record) - - ui_data["extra_links"] = { - "ui_links": ui_links, - "export_path": export_path, - "search_link": self.config.url_prefix, - } self.run_components( "before_ui_detail", @@ -171,10 +162,8 @@ def detail(self): ui_links=ui_links, ) metadata = dict(ui_data.get("metadata", ui_data)) - return _catalog.render( - "detail", - # TODO: Alzbeta: why is this here? It does not seem to be used anywhere inside the library - __source=source, + return current_oarepo_ui.catalog.render( + self.get_template_def("detail"), metadata=metadata, ui=dict(ui_data.get("ui", ui_data)), record=ui_data, @@ -182,6 +171,15 @@ def detail(self): ui_links=ui_links, ) + def make_links_absolute(self, links, api_prefix): + # make links absolute + for k, v in list(links.items()): + if not isinstance(v, str): + continue + if not v.startswith("/") and not v.startswith("https://"): + v = f"/api{api_prefix}{v}" + links[k] = v + def _get_record(self, resource_requestctx, allow_draft=False): if allow_draft: read_method = ( @@ -202,19 +200,6 @@ def search_without_slash(self): @request_search_args def search(self): - _catalog = current_oarepo_ui.catalog - - template_def = self.get_template_def("search") - app_id = template_def["app_id"] - fields = [ - "search_app_config", - "ui_layout", - "layout", - "ui_links", - "extra_content", - ] - source = get_jinja_template(_catalog, template_def, fields) - page = resource_requestctx.args.get("page", 1) size = resource_requestctx.args.get("size", 10) pagination = Pagination( @@ -249,11 +234,10 @@ def search(self): search_config = partial(self.config.search_app_config, **search_options) - search_app_config = search_config(app_id=app_id) + search_app_config = search_config(app_id=self.config.search_app_id) - return _catalog.render( - "search", - __source=source, + return current_oarepo_ui.catalog.render( + self.get_template_def("search"), search_app_config=search_app_config, ui_config=self.config, ui_resource=self, @@ -331,20 +315,13 @@ def edit(self): extra_context=extra_context, ) - template_def = self.get_template_def("edit") - - _catalog = current_oarepo_ui.catalog - source = get_jinja_template( - _catalog, template_def, ["record", "extra_context", "form_config", "data"] - ) ui_record["extra_links"] = { "ui_links": ui_links, "search_link": self.config.url_prefix, } - return _catalog.render( - "edit", - __source=source, + return current_oarepo_ui.catalog.render( + self.get_template_def("edit"), record=ui_record, form_config=form_config, extra_context=extra_context, @@ -383,16 +360,9 @@ def create(self): identity=g.identity, extra_context=extra_context, ) - template_def = self.get_template_def("create") - _catalog = current_oarepo_ui.catalog - - source = get_jinja_template( - _catalog, template_def, ["record", "extra_context", "form_config", "data"] - ) - return _catalog.render( - "create", - __source=source, + return current_oarepo_ui.catalog.render( + self.get_template_def("create"), record=empty_record, form_config=form_config, extra_context=extra_context, diff --git a/tests/model.py b/tests/model.py index dd6dcd9d..5de3f400 100644 --- a/tests/model.py +++ b/tests/model.py @@ -87,8 +87,8 @@ class ModelUIResourceConfig(RecordsUIResourceConfig): ui_serializer_class = ModelUISerializer templates = { **RecordsUIResourceConfig.templates, - "detail": {"layout": "TestDetail.jinja", "blocks": {}}, - "search": {"layout": "TestSearch.jinja", "app_id": "SimpleModel.Search"}, + "detail": "TestDetail", + "search": "TestSearch", } components = [BabelComponent, PermissionsComponent] diff --git a/tests/templates/TestDetail.jinja b/tests/templates/TestDetail.jinja index 92f76875..e5dc3c36 100644 --- a/tests/templates/TestDetail.jinja +++ b/tests/templates/TestDetail.jinja @@ -1,5 +1,6 @@ {#def record, extra_context #} -{%- for link_name, link in record.extra_links.ui_links.items() -%} + +{%- for link_name, link in record.links.ui_links.items() -%} {{link_name}}:{{link}} {% endfor-%} permissions={{ extra_context.permissions }} From 54f2e296dededeed4dcf1e11654a4140032826cc Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Dec 2023 20:18:42 +0100 Subject: [PATCH 4/9] Caching catalogue paths, extra test for priorities on templates --- oarepo_ui/resources/catalog.py | 116 ++++++++++++++++++--------- setup.cfg | 2 +- tests/templates/100-TestDetail.jinja | 7 ++ tests/templates/TestDetail.jinja | 6 +- tests/templates/test_detail.html | 5 -- 5 files changed, 86 insertions(+), 50 deletions(-) create mode 100644 tests/templates/100-TestDetail.jinja delete mode 100644 tests/templates/test_detail.html diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py index d58bb4d4..e868f555 100644 --- a/oarepo_ui/resources/catalog.py +++ b/oarepo_ui/resources/catalog.py @@ -1,6 +1,8 @@ import os import re +from functools import cached_property from pathlib import Path +from typing import Dict, Tuple import jinja2 from jinjax import Catalog @@ -29,53 +31,89 @@ def get_source(self, cname: str, file_ext: "TFileExt" = "") -> str: _root_path, path = self._get_component_path(prefix, name, file_ext=file_ext) return Path(path).read_text() + @cached_property + def component_paths(self) -> Dict[str, Tuple[Path, Path]]: + """ + Returns a cache of component-name => (root_path, component_path). + To invalidate the cache, call `del self.component_paths`. + """ + paths: Dict[str, Tuple[Path, Path, int]] = {} + + # paths known by the current jinja environment + search_paths = { + Path(searchpath["component_file"]) for searchpath in self.jinja_env.loader.searchpath + } + + # iterate all the files inside prefixes + for prefix in self.prefixes: + root_paths = self.prefixes[prefix].searchpath + + for root_path_rec in root_paths: + component_path = root_path_rec["component_path"] + root_path = Path(root_path_rec["root_path"]) + + for curr_folder, _folders, files in os.walk( + component_path, topdown=False, followlinks=True + ): + # the file might be in a subfolder of the component_path, so get the subfolder name + relfolder = os.path.relpath(curr_folder, component_path).strip(".") + + for filename in files: + + # if the file is known to the current jinja environment, + # get the priority and add it to known components + absolute_filepath = Path(curr_folder) / filename + if absolute_filepath in search_paths: + + # check if there is a priority on the file, if not, take default 0 + prefix_pattern = re.compile(r"^\d{3}-") + priority = 0 + if prefix_pattern.match(filename): + # Remove the priority from the filename + priority = int(filename[:3]) + filename = filename[4:] + + if relfolder: + relative_filepath = f"{relfolder}/{filename}" + else: + relative_filepath = filename + + # if the priority is greater, replace the path + if relative_filepath not in paths or priority > paths[relative_filepath][2]: + paths[relative_filepath] = ( + root_path, + absolute_filepath, + priority) + return { + k: (v[0], v[1]) for k, v in paths.items() + } + def _get_component_path( self, prefix: str, name: str, file_ext: "TFileExt" = "" ) -> "tuple[Path, Path]": - name = name.replace(DELIMITER, SLASH) - name_dot = f"{name}." + file_ext = file_ext or self.file_ext - root_paths = self.prefixes[prefix].searchpath - - for root_path in root_paths: - component_path = root_path["component_path"] - for curr_folder, _folders, files in os.walk( - component_path, topdown=False, followlinks=True - ): - relfolder = os.path.relpath(curr_folder, component_path).strip(".") - if relfolder and not name_dot.startswith(relfolder): - continue - - for filename in files: - _filepath = curr_folder + "/" + filename - in_searchpath = False - for searchpath in self.jinja_env.loader.searchpath: - if _filepath == searchpath["component_file"]: - in_searchpath = True - break - if in_searchpath: - prefix_pattern = re.compile(r"^\d{3}-") - without_prefix_filename = filename - if prefix_pattern.match(filename): - # Remove the prefix - without_prefix_filename = prefix_pattern.sub("", filename) - if relfolder: - filepath = f"{relfolder}/{without_prefix_filename}" - else: - filepath = without_prefix_filename - if filepath.startswith(name_dot) and filepath.endswith( - file_ext - ): - return ( - Path(root_path["root_path"]), - Path(curr_folder) / filename, - ) + if not file_ext.startswith("."): + file_ext = "." + file_ext + name = name.replace(SLASH, DELIMITER) + file_ext + + paths = self.component_paths + if name in paths: + return paths[name] + + if self.jinja_env.auto_reload: + # clear cache + del self.component_paths + + paths = self.component_paths + if name in paths: + return paths[name] raise ComponentNotFound( - f"Unable to find a file named {name}{file_ext} " - f"or one following the pattern {name_dot}*{file_ext}" + f"Unable to find a file named {name}" ) + def _get_from_file( self, *, prefix: str, name: str, url_prefix: str, file_ext: str ) -> "Component": diff --git a/setup.cfg b/setup.cfg index ead2e8f5..4b71ff4f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = oarepo-ui -version = 5.0.89 +version = 5.0.90 description = UI module for invenio 3.5+ long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/templates/100-TestDetail.jinja b/tests/templates/100-TestDetail.jinja new file mode 100644 index 00000000..e5dc3c36 --- /dev/null +++ b/tests/templates/100-TestDetail.jinja @@ -0,0 +1,7 @@ +{#def record, extra_context #} + +{%- for link_name, link in record.links.ui_links.items() -%} + {{link_name}}:{{link}} +{% endfor-%} +permissions={{ extra_context.permissions }} +{{ 1|dummy }} \ No newline at end of file diff --git a/tests/templates/TestDetail.jinja b/tests/templates/TestDetail.jinja index e5dc3c36..ba70a309 100644 --- a/tests/templates/TestDetail.jinja +++ b/tests/templates/TestDetail.jinja @@ -1,7 +1,3 @@ {#def record, extra_context #} -{%- for link_name, link in record.links.ui_links.items() -%} - {{link_name}}:{{link}} -{% endfor-%} -permissions={{ extra_context.permissions }} -{{ 1|dummy }} \ No newline at end of file +This component should never be used as it is overriden by 100-TestDetail.jinja diff --git a/tests/templates/test_detail.html b/tests/templates/test_detail.html deleted file mode 100644 index 7ce00d81..00000000 --- a/tests/templates/test_detail.html +++ /dev/null @@ -1,5 +0,0 @@ -{%- for link_name, link in ui_links.items() -%} - {{link_name}}:{{link}} -{% endfor-%} -permissions={{ permissions }} -{{ 1|dummy }} \ No newline at end of file From e720601701384cb2092d892dfd9cb6039ef92fcd Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Sat, 16 Dec 2023 11:17:02 +0100 Subject: [PATCH 5/9] Unified edit template params --- oarepo_ui/resources/components.py | 46 ++++++++++++++++--------------- oarepo_ui/resources/resource.py | 23 ++++++++++------ tests/test_ui_resource_config.py | 1 + 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py index c679c352..08f091d3 100644 --- a/oarepo_ui/resources/components.py +++ b/oarepo_ui/resources/components.py @@ -22,9 +22,9 @@ class UIResourceComponent: the resource configuration. Naming convention for parameters: - * record - the record being displayed, always is an instance of RecordItem + * api_record - the record being displayed, always is an instance of RecordItem + * record - UI serialization of the record as comes from the ui serializer. A dictionary * data - data serialized by the API service serializer. A dictionary - * ui_data - UI serialization of the record as comes from the ui serializer. A dictionary * empty_data - empty record data, compatible with the API service serializer. A dictionary """ @@ -57,8 +57,8 @@ def fill_jinja_context(self, *, context: Dict, **kwargs): def before_ui_detail( self, *, - record: RecordItem, - ui_data: Dict, + api_record: RecordItem, + record: Dict, identity: Identity, args: Dict, view_args: Dict, @@ -69,8 +69,8 @@ def before_ui_detail( """ Called before the detail page is rendered. - :param record: the record being displayed - :param ui_data: UI serialization of the record + :param api_record: the record being displayed + :param record: UI serialization of the record :param identity: the current user identity :param args: query parameters :param view_args: view arguments @@ -100,7 +100,8 @@ def before_ui_search(self, *, """ def form_config(self, *, - record: RecordItem, + api_record: RecordItem, + record: Dict, data: Dict, identity: Identity, form_config: Dict, @@ -112,7 +113,8 @@ def form_config(self, *, """ Called to fill form_config for the create/edit page. - :param record: the record being edited. Can be None if creating a new record. + :param api_record: the record being edited. Can be None if creating a new record. + :param record: UI serialization of the record :param data: data serialized by the API service serializer. If a record is being edited, this is the serialized record data. If a new record is being created, this is empty_data after being processed by the empty_record method on registered UI components. @@ -125,9 +127,9 @@ def form_config(self, *, """ def before_ui_edit(self, *, - record: RecordItem, + api_record: RecordItem, + record: Dict, data: Dict, - ui_data: Dict, identity: Identity, form_config: Dict, args: Dict, @@ -138,9 +140,9 @@ def before_ui_edit(self, *, """ Called before the edit page is rendered, after form_config has been filled. - :param record: the record being edited + :param api_record: the API record being edited :param data: data serialized by the API service serializer. This is the serialized record data. - :param ui_data: UI serialization of the record (localized). The ui data can be used in the edit + :param record: UI serialization of the record (localized). The ui data can be used in the edit template to display, for example, the localized record title. :param identity: the current user identity :param form_config: form configuration dictionary @@ -192,11 +194,11 @@ def form_config( class PermissionsComponent(UIResourceComponent): - def before_ui_detail(self, *, record, extra_context, identity, **kwargs): - self.fill_permissions(record.data, extra_context, identity) + def before_ui_detail(self, *, api_record, extra_context, identity, **kwargs): + self.fill_permissions(api_record.data, extra_context, identity) - def before_ui_edit(self, *, record, extra_context, identity, **kwargs): - self.fill_permissions(record.data, extra_context, identity) + def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs): + self.fill_permissions(api_record.data, extra_context, identity) def before_ui_create(self, *, extra_context, identity, **kwargs): self.fill_permissions(None, extra_context, identity) @@ -215,14 +217,14 @@ def before_ui_search( search_options["permissions"] = extra_context["permissions"] def form_config( - self, *, form_config, record, identity, **kwargs + self, *, form_config, api_record, identity, **kwargs ): - self.fill_permissions(record, form_config, identity) + self.fill_permissions(api_record.data if api_record else None, form_config, identity) def get_record_permissions(self, actions, service, identity, data): """Helper for generating (default) record action permissions.""" return { - f"can_{action}": service.check_permission(identity, action, record=data) + f"can_{action}": service.check_permission(identity, action, record=data or {}) for action in actions } @@ -240,15 +242,15 @@ def fill_permissions(self, data, extra_context, identity): class FilesComponent(UIResourceComponent): - def before_ui_edit(self, *, record, extra_context, identity, **kwargs): + def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs): from .resource import RecordsUIResource if not isinstance(self.resource, RecordsUIResource): return file_service = get_file_service_for_record_service( - self.resource.api_service, record=record + self.resource.api_service, record=api_record ) - files = file_service.list_files(identity, record["id"]) + files = file_service.list_files(identity, api_record["id"]) extra_context["files"] = files.to_dict() def before_ui_detail(self, **kwargs): diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py index 0fe808c8..95cec12c 100644 --- a/oarepo_ui/resources/resource.py +++ b/oarepo_ui/resources/resource.py @@ -153,8 +153,8 @@ def detail(self): self.run_components( "before_ui_detail", - record=record, - ui_data=ui_data, + api_record=record, + record=ui_data, identity=g.identity, extra_context=extra_context, args=resource_requestctx.args, @@ -167,6 +167,7 @@ def detail(self): metadata=metadata, ui=dict(ui_data.get("ui", ui_data)), record=ui_data, + api_record=record, extra_context=extra_context, ui_links=ui_links, ) @@ -282,7 +283,7 @@ def get_template_def(self, template_type): def edit(self): record = self._get_record(resource_requestctx, allow_draft=True) data = record.to_dict() - ui_record = self.config.ui_serializer.dump_obj(record.to_dict()) + ui_data = self.config.ui_serializer.dump_obj(record.to_dict()) form_config = self.config.form_config( identity=g.identity, updateUrl=record.links.get("self", None) ) @@ -293,8 +294,9 @@ def edit(self): self.run_components( "form_config", - record=record, + api_record=record, data=data, + ui_data=ui_data, identity=g.identity, form_config=form_config, args=resource_requestctx.args, @@ -304,8 +306,8 @@ def edit(self): ) self.run_components( "before_ui_edit", - resource=self, - record=ui_record, + api_record=record, + ui_data=ui_data, data=data, form_config=form_config, args=resource_requestctx.args, @@ -315,14 +317,15 @@ def edit(self): extra_context=extra_context, ) - ui_record["extra_links"] = { + ui_data["extra_links"] = { "ui_links": ui_links, "search_link": self.config.url_prefix, } return current_oarepo_ui.catalog.render( self.get_template_def("edit"), - record=ui_record, + record=ui_data, + api_record=record, form_config=form_config, extra_context=extra_context, ui_links=ui_links, @@ -343,6 +346,7 @@ def create(self): self.run_components( "form_config", + api_record=None, record=None, data=empty_record, form_config=form_config, @@ -354,6 +358,8 @@ def create(self): self.run_components( "before_ui_create", data=empty_record, + record=None, + api_record=None, form_config=form_config, args=resource_requestctx.args, view_args=resource_requestctx.view_args, @@ -364,6 +370,7 @@ def create(self): return current_oarepo_ui.catalog.render( self.get_template_def("create"), record=empty_record, + api_record=None, form_config=form_config, extra_context=extra_context, ui_links={}, diff --git a/tests/test_ui_resource_config.py b/tests/test_ui_resource_config.py index 12bc907d..400237d3 100644 --- a/tests/test_ui_resource_config.py +++ b/tests/test_ui_resource_config.py @@ -12,6 +12,7 @@ def test_ui_resource_form_config(app, record_ui_resource): form_config=fc, layout="", resource=record_ui_resource, + api_record=None, record={}, data={}, args={}, From 6270f4c14511c3f40b278e3905b234468ad63365 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Sat, 16 Dec 2023 11:43:32 +0100 Subject: [PATCH 6/9] documentation --- README.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7bc9546d..fa030c3c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ functions to render layouts from model configuration. See also [JinjaX documentation](https://jinjax.scaletti.dev/). -Oarepo supports the use of components within Jinja templates using the JinjaX library. +Oarepo builds its static UI pages on top of the JinjaX library. To load a Jinja application, a JinjaX component is expected on the input. The relative path to the component is taken from the configuration @@ -27,20 +27,14 @@ To work with parameters within components, you need to define them in the templa Example of component specification in config: -```json +```python templates = { - "detail": { - "layout": "docs_app/DetailRoot.html.jinja", - "blocks": { - "record_main_content": "Main", - "record_sidebar": "Sidebar", - }, - }, - "search": {"layout": "docs_app/search.html"}, + "detail": "DetailPage", + "search": "SearchPage" } ``` -Example of possible contents of the DetailRoot component: +Example of possible contents of the DetailPage component, contained inside `templates/DetailPage.jinja` ```json {#def metadata, ui, layout #} @@ -53,18 +47,17 @@ Example of possible contents of the DetailRoot component: {{ webpack['docs_app_components.css']}} {%- endblock %} -``` -Based on the definition from the config, the block content is then automatically added to the component content: -```json {% block record_main_content %} <Main metadata={{metadata}}></Main> {% endblock %} + {% block record_sidebar %} <Sidebar metadata={{metadata}}></Sidebar> {% endblock %} ``` + Sample of possible contents of Main component: ```json {#def metadata, ui, layout #} @@ -74,9 +67,22 @@ Sample of possible contents of Main component: ``` +You can also namespace your ui components, by using dot notation: + +```python +templates = { + "detail": "myrepo.DetailPage", + "search": "myrepo.SearchPage" + } +``` + +Then, the component will be loaded from the `templates/myrepo/DetailPage.jinja` file. + + #### JinjaX components Within the Oarepo-ui library, basic components are defined in the `templates` folder. + ### React To render a custom layout in a React app (e. g. records search result page), this package provides the `useLayout` hook and an entrypoint From 2bc66c25e210188b61ca45cdcb3ff1e38ab44926 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Sat, 16 Dec 2023 12:00:00 +0100 Subject: [PATCH 7/9] refactored to simplify component_paths method --- oarepo_ui/resources/catalog.py | 72 +++++++++++++++++-------------- oarepo_ui/resources/components.py | 2 +- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py index e868f555..bcdcf41d 100644 --- a/oarepo_ui/resources/catalog.py +++ b/oarepo_ui/resources/catalog.py @@ -44,7 +44,41 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: Path(searchpath["component_file"]) for searchpath in self.jinja_env.loader.searchpath } - # iterate all the files inside prefixes + for root_path, namespace, template_path in self._get_all_template_files(): + + # if the file is known to the current jinja environment, + # get the priority and add it to known components + if template_path in search_paths: + template_filename = os.path.basename(template_path) + template_filename, priority = self._extract_priority(template_filename) + + if namespace: + relative_filepath = f"{namespace}/{template_filename}" + else: + relative_filepath = template_filename + + # if the priority is greater, replace the path + if relative_filepath not in paths or priority > paths[relative_filepath][2]: + paths[relative_filepath] = ( + root_path, + template_path, + priority) + + return { + k: (v[0], v[1]) for k, v in paths.items() + } + + def _extract_priority(self, filename): + # check if there is a priority on the file, if not, take default 0 + prefix_pattern = re.compile(r"^\d{3}-") + priority = 0 + if prefix_pattern.match(filename): + # Remove the priority from the filename + priority = int(filename[:3]) + filename = filename[4:] + return filename, priority + + def _get_all_template_files(self): for prefix in self.prefixes: root_paths = self.prefixes[prefix].searchpath @@ -52,41 +86,13 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: component_path = root_path_rec["component_path"] root_path = Path(root_path_rec["root_path"]) - for curr_folder, _folders, files in os.walk( - component_path, topdown=False, followlinks=True + for file_absolute_folder, _folders, files in os.walk( + component_path, topdown=False, followlinks=True ): - # the file might be in a subfolder of the component_path, so get the subfolder name - relfolder = os.path.relpath(curr_folder, component_path).strip(".") - + namespace = os.path.relpath(file_absolute_folder, component_path).strip(".") for filename in files: + yield root_path, namespace, Path(file_absolute_folder) / filename - # if the file is known to the current jinja environment, - # get the priority and add it to known components - absolute_filepath = Path(curr_folder) / filename - if absolute_filepath in search_paths: - - # check if there is a priority on the file, if not, take default 0 - prefix_pattern = re.compile(r"^\d{3}-") - priority = 0 - if prefix_pattern.match(filename): - # Remove the priority from the filename - priority = int(filename[:3]) - filename = filename[4:] - - if relfolder: - relative_filepath = f"{relfolder}/{filename}" - else: - relative_filepath = filename - - # if the priority is greater, replace the path - if relative_filepath not in paths or priority > paths[relative_filepath][2]: - paths[relative_filepath] = ( - root_path, - absolute_filepath, - priority) - return { - k: (v[0], v[1]) for k, v in paths.items() - } def _get_component_path( self, prefix: str, name: str, file_ext: "TFileExt" = "" diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py index 08f091d3..9f84cf82 100644 --- a/oarepo_ui/resources/components.py +++ b/oarepo_ui/resources/components.py @@ -211,7 +211,7 @@ def before_ui_search( return extra_context["permissions"] = { - f"can_create": self.resource.api_service.check_permission(identity, "create") + "can_create": self.resource.api_service.check_permission(identity, "create") } search_options["permissions"] = extra_context["permissions"] From b9285ca7492f069a674ce29cc71a581e9800e094 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Sat, 16 Dec 2023 12:03:58 +0100 Subject: [PATCH 8/9] Formatted sources --- oarepo_ui/ext.py | 1 + oarepo_ui/resources/catalog.py | 37 ++++----- oarepo_ui/resources/components.py | 133 +++++++++++++++++------------- oarepo_ui/resources/resource.py | 7 +- oarepo_ui/theme/webpack.py | 1 - tests/test_ui_resource.py | 2 +- 6 files changed, 97 insertions(+), 84 deletions(-) diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py index 5387b899..7e2c400f 100644 --- a/oarepo_ui/ext.py +++ b/oarepo_ui/ext.py @@ -151,6 +151,7 @@ def development_after_request(self, response: Response): def record_actions(self): return self.app.config["OAREPO_UI_RECORD_ACTIONS"] + class OARepoUIExtension: def __init__(self, app=None): if app: diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py index bcdcf41d..58b31687 100644 --- a/oarepo_ui/resources/catalog.py +++ b/oarepo_ui/resources/catalog.py @@ -41,11 +41,11 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: # paths known by the current jinja environment search_paths = { - Path(searchpath["component_file"]) for searchpath in self.jinja_env.loader.searchpath + Path(searchpath["component_file"]) + for searchpath in self.jinja_env.loader.searchpath } for root_path, namespace, template_path in self._get_all_template_files(): - # if the file is known to the current jinja environment, # get the priority and add it to known components if template_path in search_paths: @@ -58,15 +58,13 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: relative_filepath = template_filename # if the priority is greater, replace the path - if relative_filepath not in paths or priority > paths[relative_filepath][2]: - paths[relative_filepath] = ( - root_path, - template_path, - priority) - - return { - k: (v[0], v[1]) for k, v in paths.items() - } + if ( + relative_filepath not in paths + or priority > paths[relative_filepath][2] + ): + paths[relative_filepath] = (root_path, template_path, priority) + + return {k: (v[0], v[1]) for k, v in paths.items()} def _extract_priority(self, filename): # check if there is a priority on the file, if not, take default 0 @@ -87,17 +85,19 @@ def _get_all_template_files(self): root_path = Path(root_path_rec["root_path"]) for file_absolute_folder, _folders, files in os.walk( - component_path, topdown=False, followlinks=True + component_path, topdown=False, followlinks=True ): - namespace = os.path.relpath(file_absolute_folder, component_path).strip(".") + namespace = os.path.relpath( + file_absolute_folder, component_path + ).strip(".") for filename in files: - yield root_path, namespace, Path(file_absolute_folder) / filename - + yield root_path, namespace, Path( + file_absolute_folder + ) / filename def _get_component_path( self, prefix: str, name: str, file_ext: "TFileExt" = "" ) -> "tuple[Path, Path]": - file_ext = file_ext or self.file_ext if not file_ext.startswith("."): file_ext = "." + file_ext @@ -115,10 +115,7 @@ def _get_component_path( if name in paths: return paths[name] - raise ComponentNotFound( - f"Unable to find a file named {name}" - ) - + raise ComponentNotFound(f"Unable to find a file named {name}") def _get_from_file( self, *, prefix: str, name: str, url_prefix: str, file_ext: str diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py index 9f84cf82..c848cded 100644 --- a/oarepo_ui/resources/components.py +++ b/oarepo_ui/resources/components.py @@ -1,9 +1,10 @@ +from typing import TYPE_CHECKING, Dict + from flask import current_app from flask_principal import Identity from invenio_i18n.ext import current_i18n from invenio_records_resources.services.records.results import RecordItem from oarepo_runtime.datastreams.utils import get_file_service_for_record_service -from typing import Dict, TYPE_CHECKING from ..proxies import current_oarepo_ui @@ -57,14 +58,14 @@ def fill_jinja_context(self, *, context: Dict, **kwargs): def before_ui_detail( self, *, - api_record: RecordItem, - record: Dict, - identity: Identity, - args: Dict, - view_args: Dict, - ui_links: Dict, - extra_context: Dict, - **kwargs, + api_record: RecordItem, + record: Dict, + identity: Identity, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs, ): """ Called before the detail page is rendered. @@ -77,14 +78,18 @@ def before_ui_detail( :param ui_links: UI links for the record, a dictionary of link name -> link url :param extra_context: will be passed to the template as the "extra_context" variable """ - def before_ui_search(self, *, - identity: Identity, - search_options: Dict, - args: Dict, - view_args: Dict, - ui_links: Dict, - extra_context: Dict, - **kwargs): + + def before_ui_search( + self, + *, + identity: Identity, + search_options: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs, + ): """ Called before the search page is rendered. Note: search results are fetched via AJAX, so are not available in this method. @@ -99,17 +104,20 @@ def before_ui_search(self, *, :param extra_context: will be passed to the template as the "extra_context" variable """ - def form_config(self, *, - api_record: RecordItem, - record: Dict, - data: Dict, - identity: Identity, - form_config: Dict, - args: Dict, - view_args: Dict, - ui_links: Dict, - extra_context: Dict, - **kwargs): + def form_config( + self, + *, + api_record: RecordItem, + record: Dict, + data: Dict, + identity: Identity, + form_config: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs, + ): """ Called to fill form_config for the create/edit page. @@ -126,17 +134,20 @@ def form_config(self, *, :param extra_context: will be passed to the template as the "extra_context" variable """ - def before_ui_edit(self, *, - api_record: RecordItem, - record: Dict, - data: Dict, - identity: Identity, - form_config: Dict, - args: Dict, - view_args: Dict, - ui_links: Dict, - extra_context: Dict, - **kwargs): + def before_ui_edit( + self, + *, + api_record: RecordItem, + record: Dict, + data: Dict, + identity: Identity, + form_config: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs, + ): """ Called before the edit page is rendered, after form_config has been filled. @@ -152,15 +163,18 @@ def before_ui_edit(self, *, :param extra_context: will be passed to the template as the "extra_context" variable """ - def before_ui_create(self, *, - data: Dict, - identity: Identity, - form_config: Dict, - args: Dict, - view_args: Dict, - ui_links: Dict, - extra_context: Dict, - **kwargs): + def before_ui_create( + self, + *, + data: Dict, + identity: Identity, + form_config: Dict, + args: Dict, + view_args: Dict, + ui_links: Dict, + extra_context: Dict, + **kwargs, + ): """ Called before the create page is rendered, after form_config has been filled @@ -175,9 +189,7 @@ def before_ui_create(self, *, class BabelComponent(UIResourceComponent): - def form_config( - self, *, form_config, **kwargs - ): + def form_config(self, *, form_config, **kwargs): conf = current_app.config locales = [] for l in current_i18n.get_locales(): @@ -203,10 +215,9 @@ def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs): def before_ui_create(self, *, extra_context, identity, **kwargs): self.fill_permissions(None, extra_context, identity) - def before_ui_search( - self, *, extra_context, identity, search_options, **kwargs - ): + def before_ui_search(self, *, extra_context, identity, search_options, **kwargs): from .resource import RecordsUIResource + if not isinstance(self.resource, RecordsUIResource): return @@ -216,20 +227,23 @@ def before_ui_search( search_options["permissions"] = extra_context["permissions"] - def form_config( - self, *, form_config, api_record, identity, **kwargs - ): - self.fill_permissions(api_record.data if api_record else None, form_config, identity) + def form_config(self, *, form_config, api_record, identity, **kwargs): + self.fill_permissions( + api_record.data if api_record else None, form_config, identity + ) def get_record_permissions(self, actions, service, identity, data): """Helper for generating (default) record action permissions.""" return { - f"can_{action}": service.check_permission(identity, action, record=data or {}) + f"can_{action}": service.check_permission( + identity, action, record=data or {} + ) for action in actions } def fill_permissions(self, data, extra_context, identity): from .resource import RecordsUIResource + if not isinstance(self.resource, RecordsUIResource): return @@ -244,6 +258,7 @@ def fill_permissions(self, data, extra_context, identity): class FilesComponent(UIResourceComponent): def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs): from .resource import RecordsUIResource + if not isinstance(self.resource, RecordsUIResource): return diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py index 95cec12c..c96e2ef7 100644 --- a/oarepo_ui/resources/resource.py +++ b/oarepo_ui/resources/resource.py @@ -1,6 +1,6 @@ import copy from functools import partial -from typing import Iterator, TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator import deepmerge from flask import abort, g, redirect, request @@ -107,7 +107,9 @@ def empty_record(self, resource_requestctx, **kwargs): empty_data, copy.deepcopy(self.config.empty_record) ) self.run_components( - "empty_record", resource_requestctx=resource_requestctx, empty_data=empty_data + "empty_record", + resource_requestctx=resource_requestctx, + empty_data=empty_data, ) return empty_data @@ -273,7 +275,6 @@ def export(self): } return (exported_record, 200, headers) - def get_template_def(self, template_type): return self.config.templates[template_type] diff --git a/oarepo_ui/theme/webpack.py b/oarepo_ui/theme/webpack.py index 85b8238b..006e9173 100644 --- a/oarepo_ui/theme/webpack.py +++ b/oarepo_ui/theme/webpack.py @@ -31,7 +31,6 @@ "oarepo_ui_forms": "./js/oarepo_ui/forms/index.js", "oarepo_ui_theme": "./js/oarepo_ui/theme.js", "oarepo_ui_components": "./js/custom-components.js", - }, dependencies={ "@tanstack/react-query": "^4.32.0", diff --git a/tests/test_ui_resource.py b/tests/test_ui_resource.py index 1751c7f2..00118805 100644 --- a/tests/test_ui_resource.py +++ b/tests/test_ui_resource.py @@ -2,7 +2,7 @@ def test_ui_resource_create_new(app, record_ui_resource, record_service): - assert record_ui_resource.empty_record(None) == {"title": ''} + assert record_ui_resource.empty_record(None) == {"title": ""} def test_ui_resource_form_config(app, record_ui_resource): From 3fd45e88d36fac417914858307797cf95af1b20b Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Sat, 16 Dec 2023 12:09:29 +0100 Subject: [PATCH 9/9] Renamed get_template_def to get_jinjax_macro --- oarepo_ui/resources/resource.py | 34 ++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py index c96e2ef7..1c87e750 100644 --- a/oarepo_ui/resources/resource.py +++ b/oarepo_ui/resources/resource.py @@ -165,7 +165,12 @@ def detail(self): ) metadata = dict(ui_data.get("metadata", ui_data)) return current_oarepo_ui.catalog.render( - self.get_template_def("detail"), + self.get_jinjax_macro( + "detail", + identity=g.identity, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ), metadata=metadata, ui=dict(ui_data.get("ui", ui_data)), record=ui_data, @@ -240,7 +245,12 @@ def search(self): search_app_config = search_config(app_id=self.config.search_app_id) return current_oarepo_ui.catalog.render( - self.get_template_def("search"), + self.get_jinjax_macro( + "search", + identity=g.identity, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ), search_app_config=search_app_config, ui_config=self.config, ui_resource=self, @@ -275,7 +285,11 @@ def export(self): } return (exported_record, 200, headers) - def get_template_def(self, template_type): + def get_jinjax_macro(self, template_type, identity=None, args=None, view_args=None): + """ + Returns which jinjax macro (name of the macro, including optional namespace in the form of "namespace.Macro") + should be used for rendering the template. + """ return self.config.templates[template_type] @login_required @@ -324,7 +338,12 @@ def edit(self): } return current_oarepo_ui.catalog.render( - self.get_template_def("edit"), + self.get_jinjax_macro( + "edit", + identity=g.identity, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ), record=ui_data, api_record=record, form_config=form_config, @@ -369,7 +388,12 @@ def create(self): ) return current_oarepo_ui.catalog.render( - self.get_template_def("create"), + self.get_jinjax_macro( + "create", + identity=g.identity, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ), record=empty_record, api_record=None, form_config=form_config,