diff --git a/ckanext/drupal_api/assets/webassets.yml b/ckanext/drupal_api/assets/webassets.yml index ee0105e..e69de29 100644 --- a/ckanext/drupal_api/assets/webassets.yml +++ b/ckanext/drupal_api/assets/webassets.yml @@ -1,5 +0,0 @@ -# styles: -# filters: cssrewrite -# output: ckanext-drupal_api/%(version)s-styles.css -# contents: -# - css/styles.css diff --git a/ckanext/drupal_api/config.py b/ckanext/drupal_api/config.py index 7c265df..5fd2857 100644 --- a/ckanext/drupal_api/config.py +++ b/ckanext/drupal_api/config.py @@ -1,7 +1,19 @@ +from __future__ import annotations + +from typing import Any + +import ckan.plugins.toolkit as tk + + CONFIG_DRUPAL_URL = "ckanext.drupal_api.drupal_url" +DEFAULT_DRUPAL_URL = "" CONFIG_CACHE_DURATION = "ckanext.drupal_api.cache.duration" +DEFAULT_CACHE_DURATION = 3600 + CONFIG_REQUEST_TIMEOUT = "ckanext.drupal_api.timeout" +DEFAULT_REQUEST_TIMEOUT = 5 + CONFIG_REQUEST_HTTP_USER = "ckanext.drupal_api.request.user" CONFIG_REQUEST_HTTP_PASS = "ckanext.drupal_api.request.pass" @@ -14,5 +26,105 @@ CONFIG_MENU_EXPORT = "ckanext.drupal_api.core.menu_export_endpoint" DEFAULT_MENU_EXPORT_EP = "/resource/layout/export" -DEFAULT_REQUEST_TIMEOUT = 5 -DEFAULT_CACHE_DURATION = 3600 + +def get_cache_ttl() -> int: + return tk.asint(tk.config[CONFIG_CACHE_DURATION] or DEFAULT_CACHE_DURATION) + + +def get_drupal_url() -> str: + return (tk.config[CONFIG_DRUPAL_URL] or DEFAULT_DRUPAL_URL).strip("/") + + +def get_api_version() -> str: + return tk.config[CONFIG_DRUPAL_API_VERSION] or DEFAULT_API_VERSION + + +def get_menu_export_endpoint() -> str: + if get_api_version() == JSON_API: + return "/jsonapi/menu_items/{menu_id}" + + return tk.config[CONFIG_MENU_EXPORT] or DEFAULT_MENU_EXPORT_EP + + +def get_request_timeout() -> int: + return tk.asint(tk.config[CONFIG_REQUEST_TIMEOUT] or DEFAULT_REQUEST_TIMEOUT) + + +def get_http_user() -> str | None: + return tk.config[CONFIG_REQUEST_HTTP_USER] + + +def get_http_pass() -> str | None: + return tk.config[CONFIG_REQUEST_HTTP_PASS] + + +def get_config_options() -> dict[str, dict[str, Any]]: + """Defines how we are going to render the global configuration + options for an extension.""" + unicode_safe = tk.get_validator("unicode_safe") + one_of = tk.get_validator("one_of") + default = tk.get_validator("default") + int_validator = tk.get_validator("is_positive_integer") + url_validator = tk.get_validator("url_validator") + + return { + "cache_ttl": { + "key": CONFIG_CACHE_DURATION, + "label": tk._("Cache TTL"), + "value": get_cache_ttl(), + "validators": [default(DEFAULT_CACHE_DURATION), int_validator], + "type": "number", + }, + "drupal_url": { + "key": CONFIG_DRUPAL_URL, + "label": tk._("Drupal base URL"), + "value": get_drupal_url(), + "validators": [unicode_safe, url_validator], + "type": "text", + "required": True, + }, + "api_version": { + "key": CONFIG_DRUPAL_API_VERSION, + "label": tk._("API version"), + "value": get_api_version(), + "validators": [default(DEFAULT_API_VERSION), one_of([JSON_API, CORE_API])], + "type": "select", + "options": [ + {"value": JSON_API, "text": "JSON API"}, + {"value": CORE_API, "text": "Core REST API"}, + ], + }, + "menu_export_endpoint": { + "key": CONFIG_MENU_EXPORT, + "label": tk._("Menu export API endpoint"), + "value": get_menu_export_endpoint(), + "validators": [unicode_safe], + "type": "text", + "disabled": get_api_version() == JSON_API, + "required": True, + "help_text": tk._( + "If you are using the core API version, you might face the situation when your endpoint differ from the default one" + ), + }, + "request_timeout": { + "key": CONFIG_REQUEST_TIMEOUT, + "label": tk._("API request timeout"), + "value": get_request_timeout(), + "validators": [default(DEFAULT_REQUEST_TIMEOUT), int_validator], + "type": "number", + }, + "http_user": { + "key": CONFIG_REQUEST_HTTP_USER, + "label": tk._("HTTP auth user"), + "value": get_http_user(), + "validators": [unicode_safe], + "type": "text", + }, + "http_pass": { + "key": CONFIG_REQUEST_HTTP_PASS, + "label": tk._("HTTP auth password"), + "value": get_http_pass(), + "validators": [unicode_safe], + "type": "password", + }, + } diff --git a/ckanext/drupal_api/config_declaration.yaml b/ckanext/drupal_api/config_declaration.yaml index e4d44db..def8436 100644 --- a/ckanext/drupal_api/config_declaration.yaml +++ b/ckanext/drupal_api/config_declaration.yaml @@ -3,6 +3,8 @@ groups: - annotation: ckanext-drupal-api options: - key: ckanext.drupal_api.drupal_url + default: "" + - key: ckanext.drupal_api.cache.duration type: int default: 3600 diff --git a/ckanext/drupal_api/helpers.py b/ckanext/drupal_api/helpers.py index 076d6ce..bfba45c 100644 --- a/ckanext/drupal_api/helpers.py +++ b/ckanext/drupal_api/helpers.py @@ -6,10 +6,8 @@ from requests.exceptions import RequestException -import ckan.plugins.toolkit as tk - -import ckanext.drupal_api.config as c -from ckanext.drupal_api.utils import cached, _get_api_version +import ckanext.drupal_api.config as da_conf +from ckanext.drupal_api.utils import cached, get_api_version from ckanext.drupal_api.logic.api import make_request from ckanext.drupal_api.types import Menu, T, MaybeNotCached, DontCache @@ -30,7 +28,7 @@ def get_helpers(): @helper @cached def menu(name: str, cache_extras: Optional[dict[str, Any]] = None) -> MaybeNotCached[Menu]: - api_connector = _get_api_version() + api_connector = get_api_version() drupal_api = api_connector.get() if not drupal_api: @@ -55,9 +53,10 @@ def custom_endpoint(endpoint: str) -> dict: Returns: dict: response from Drupal """ - base_url = tk.config.get(c.CONFIG_DRUPAL_URL) + base_url = da_conf.get_drupal_url() + if not base_url: - log.error("Drupal URL is missing: %s", c.CONFIG_DRUPAL_URL) + log.error("Drupal URL is missing: %s", da_conf.CONFIG_DRUPAL_URL) return DontCache({}) try: @@ -65,4 +64,5 @@ def custom_endpoint(endpoint: str) -> dict: except RequestException as e: log.error(f"Custom endpoint request error - {endpoint}: {e}") return DontCache({}) + return resp diff --git a/ckanext/drupal_api/logic/api.py b/ckanext/drupal_api/logic/api.py index 540b5d4..7965223 100644 --- a/ckanext/drupal_api/logic/api.py +++ b/ckanext/drupal_api/logic/api.py @@ -1,4 +1,5 @@ from __future__ import annotations + import logging import requests from typing import Optional @@ -6,18 +7,17 @@ import ckan.plugins.toolkit as tk import ckan.plugins as p -import ckanext.drupal_api.config as c + +import ckanext.drupal_api.config as da_conf log = logging.getLogger(__name__) def make_request(url: str) -> dict: - http_user: str = tk.config.get(c.CONFIG_REQUEST_HTTP_USER) - http_pass: str = tk.config.get(c.CONFIG_REQUEST_HTTP_PASS) - timeout = tk.asint( - tk.config.get(c.CONFIG_REQUEST_TIMEOUT, c.DEFAULT_REQUEST_TIMEOUT) - ) + http_user = da_conf.get_http_user() + http_pass = da_conf.get_http_pass() + timeout = da_conf.get_request_timeout() session = requests.Session() @@ -35,10 +35,12 @@ class Drupal: @classmethod def get(cls, instance: str = "default") -> Optional[Drupal]: - url = tk.config.get(c.CONFIG_DRUPAL_URL) + url = da_conf.get_drupal_url() + if not url: - log.error("Drupal URL is missing: %s", c.CONFIG_DRUPAL_URL) + log.error("Drupal URL is missing: %s", da_conf.CONFIG_DRUPAL_URL) return + default_lang = tk.config.get("ckan.locale_default") current_lang = tk.h.lang() localised_url = url.format( @@ -92,12 +94,10 @@ def _request(self, endpoint: str) -> dict: return make_request(url) def get_menu(self, name: str) -> dict: - data: dict = self._request( - endpoint=tk.config.get(c.CONFIG_MENU_EXPORT, c.DEFAULT_MENU_EXPORT_EP) - ) + data: dict = self._request(endpoint=da_conf.get_menu_export_endpoint()) log.info( f"Menu {name} has been fetched successfully. Cached for \ - {tk.config.get(c.CONFIG_CACHE_DURATION, c.DEFAULT_CACHE_DURATION)} seconds" + {da_conf.get_cache_ttl()} seconds" ) return data.get(name, {}) diff --git a/ckanext/drupal_api/plugin.py b/ckanext/drupal_api/plugin.py index af86dea..9460978 100644 --- a/ckanext/drupal_api/plugin.py +++ b/ckanext/drupal_api/plugin.py @@ -1,13 +1,22 @@ +from __future__ import annotations + import ckan.plugins as p import ckan.plugins.toolkit as tk import ckanext.drupal_api.helpers as helpers from ckanext.drupal_api.views import blueprints +import ckanext.ap_main.types as ap_types +from ckanext.ap_main.interfaces import IAdminPanel + +import ckanext.drupal_api.config as da_config + + class DrupalApiPlugin(p.SingletonPlugin): p.implements(p.ITemplateHelpers) p.implements(p.IConfigurer) p.implements(p.IBlueprint) + p.implements(IAdminPanel, inherit=True) # ITemplateHelpers @@ -18,13 +27,37 @@ def get_helpers(self): def update_config(self, config_): tk.add_template_directory(config_, "templates") - tk.add_ckan_admin_tab(config_, "drupal_api.drupal_api_config", "Drupal API") + + def update_config_schema(self, schema): + for _, config in da_config.get_config_options().items(): + schema.update({config["key"]: config["validators"]}) + + return schema # IBlueprint def get_blueprint(self): return blueprints + # IAdminPanel + + def register_config_sections( + self, config_list: list[ap_types.SectionConfig] + ) -> list[ap_types.SectionConfig]: + config_list.append( + ap_types.SectionConfig( + name="Drupal API", + configs=[ + ap_types.ConfigurationItem( + name="Configuration", + blueprint="drupal_api.config", + info="Drupal API settings", + ) + ], + ) + ) + return config_list + if tk.check_ckan_version("2.10"): tk.blanket.config_declarations(DrupalApiPlugin) diff --git a/ckanext/drupal_api/templates/admin/drupal_api_config.html b/ckanext/drupal_api/templates/admin/drupal_api_config.html deleted file mode 100644 index 71166e7..0000000 --- a/ckanext/drupal_api/templates/admin/drupal_api_config.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "admin/base.html" %} - -{% block primary_content_inner %} - -{% if not drupal_url %} -

-

- - {{ _('ERROR. Missing Drupal URL. The extension won\'t work properly!') }} -
-

-{% endif %} - -
- - - - - - - - - - - - - - - -
DescriptionAction
-

{{ _('Clear cache for the synced drupal menus') }}

-

- {% set menu_export_url = drupal_url + menu_export_endpoint %} - - {{ _("Current menu export URL")}} - - {{ menu_export_url }} -

-

- {{ _("Current API version")}} - {{ api_version }} -

- -

- {{ _("Current Cache TTL")}} - {{ cache_ttl_current or cache_ttl_default }} -

-
-

- - {{ _("If you made changes on the Drupal side, but you don't see the changes:") }} - -

    -
  1. {{ _("Clear the Drupal cache via Drush or Admin panel.") }}
  2. -
  3. {{ _("Clear the CKAN cache by pressing the clear button.") }}
  4. -
-

-
-
-

- - {{ _('If you are using the core API version, you might face the situation when your endpoint differ from the default one.')}} - DEFAULT_MENU_EXPORT_EP = "/resource/layout/export" -
- {{ _('In this case, you can specify the menu export endpoint through the config, e.g.:')}} - ckanext.drupal_api.core.menu_export_endpoint = /api/v1/menu_export -

-
-
- -
-

{{ _('Clear cache for the custom endpoints') }}

-
- -
-
-{% endblock %} - -{% block secondary_content %} -
-

- - {{ _('Drupal API') }} -

-
-

{{ _('Cache info') }}

-

- {{ _('Default cache TTL is {} sec.').format(cache_ttl_default) }} - {{ _('You can specify the TTL by providing next config option:') }} -

ckanext.drupal_api.cache.duration
-

- -
- -

{{ _('API versions') }}

-

- {{ _("Currently, there are two supported API versions:") }} - JSON API {{ _('and') }} - RESTful Web Services - ({{ _('default') }}) - {{ _("You can specify the API version with next config option:") }} -

ckanext.drupal_api.api_version
- {{ _("There are two options: ") }} json {{ _('and') }} core -

-
-
-{% endblock %} \ No newline at end of file diff --git a/ckanext/drupal_api/templates/drupal_api/config.html b/ckanext/drupal_api/templates/drupal_api/config.html new file mode 100644 index 0000000..e2692eb --- /dev/null +++ b/ckanext/drupal_api/templates/drupal_api/config.html @@ -0,0 +1,65 @@ +{% extends 'admin_panel/base.html' %} + +{% import 'macros/autoform.html' as autoform %} +{% import 'macros/form.html' as form %} + +{% block breadcrumb_content %} +
  • {% link_for _("Configuration"), named_route='drupal_api.config' %}
  • +{% endblock breadcrumb_content %} + +{% block ap_content %} +

    {{ _("Drupal API config") }}

    + +
    + + + +
    + + {% if not configs.drupal_url.value %} +
    + + {{ _('ERROR. Missing Drupal URL. The extension won\'t work properly!') }} +
    + {% endif %} + +
    + {% for _, config in configs.items() %} + {% set default_attrs = {'class': 'form-control'} %} + {% set value = data[config.key] or config.value %} + + {% if config.disabled %} + {% do default_attrs.update({"disabled": 1}) %} + {% endif %} + + {% macro info(text) %} + {% if text %} +
    + + {{ text }} +
    + {% endif %} + {% endmacro %} + + {% if config.type == "select" %} + {% call form.select(config.key if not config.disabled else "", label=config.label, options=config.options, selected=value, error=errors[config.key], attrs=default_attrs, is_required=config.required) %} + {{ info(config.help_text) }} + {% endcall %} + {% elif config.type in ("text", "number", "password") %} + {% call form.input(config.key if not config.disabled else "", label=config.label, value=value, error=errors[config.key], type=config.type, attrs=default_attrs, is_required=config.required) %} + {{ info(config.help_text) }} + {% endcall %} + {% else %} + {% call form.textarea(config.key if not config.disabled else "", label=config.label, value=value, error=errors[config.key], attrs=default_attrs, is_required=config.required) %} + {{ info(config.help_text) }} + {% endcall %} + {% endif %} + {% endfor %} + + +
    +{% endblock ap_content %} diff --git a/ckanext/drupal_api/templates/page.html b/ckanext/drupal_api/templates/page.html deleted file mode 100644 index 0704e83..0000000 --- a/ckanext/drupal_api/templates/page.html +++ /dev/null @@ -1,6 +0,0 @@ -{% ckan_extends %} - -{%- block styles %} - {{ super() }} - {% asset 'ckanext-drupal-api/styles' %} -{% endblock %} \ No newline at end of file diff --git a/ckanext/drupal_api/tests/test_plugin.py b/ckanext/drupal_api/tests/test_plugin.py deleted file mode 100644 index 44e503e..0000000 --- a/ckanext/drupal_api/tests/test_plugin.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Tests for plugin.py. - -Tests are written using the pytest library (https://docs.pytest.org), and you -should read the testing guidelines in the CKAN docs: -https://docs.ckan.org/en/2.9/contributing/testing.html - -To write tests for your extension you should install the pytest-ckan package: - - pip install pytest-ckan - -This will allow you to use CKAN specific fixtures on your tests. - -For instance, if your test involves database access you can use `clean_db` to -reset the database: - - import pytest - - from ckan.tests import factories - - @pytest.mark.usefixtures("clean_db") - def test_some_action(): - - dataset = factories.Dataset() - - # ... - -For functional tests that involve requests to the application, you can use the -`app` fixture: - - from ckan.plugins import toolkit - - def test_some_endpoint(app): - - url = toolkit.url_for('myblueprint.some_endpoint') - - response = app.get(url) - - assert response.status_code == 200 - - -To temporary patch the CKAN configuration for the duration of a test you can use: - - import pytest - - @pytest.mark.ckan_config("ckanext.myext.some_key", "some_value") - def test_some_action(): - pass -""" -import ckanext.drupal_api.plugin as plugin - - -def test_plugin(): - pass diff --git a/ckanext/drupal_api/tests/test_utils.py b/ckanext/drupal_api/tests/test_utils.py deleted file mode 100644 index f4b2056..0000000 --- a/ckanext/drupal_api/tests/test_utils.py +++ /dev/null @@ -1,5 +0,0 @@ -from ckanext.drupal_api.utils import cached - - -def test_cached(): - pass diff --git a/ckanext/drupal_api/types.py b/ckanext/drupal_api/types.py index 542f288..0839c61 100644 --- a/ckanext/drupal_api/types.py +++ b/ckanext/drupal_api/types.py @@ -1,4 +1,6 @@ -from typing import Callable, Dict, Generic, Optional, TypeVar, Union, cast, Iterable +from __future__ import annotations + +from typing import Callable, Dict, Generic, TypeVar, Union, Iterable T = TypeVar("T", bound=Callable) diff --git a/ckanext/drupal_api/utils.py b/ckanext/drupal_api/utils.py index e98b926..4449efb 100644 --- a/ckanext/drupal_api/utils.py +++ b/ckanext/drupal_api/utils.py @@ -4,12 +4,12 @@ import pickle from functools import wraps -from typing import Callable, Dict, Optional, Union, cast, Any +from typing import Callable, Optional, Union, cast import ckan.plugins.toolkit as tk import ckan.lib.redis as redis -import ckanext.drupal_api.config as c +import ckanext.drupal_api.config as da_conf from ckanext.drupal_api.types import T, MaybeNotCached, DontCache from ckanext.drupal_api.logic.api import CoreAPI, JsonAPI @@ -17,17 +17,16 @@ log = logging.getLogger(__name__) -def _get_api_version() -> Optional[Union[CoreAPI, JsonAPI]]: +def get_api_version() -> Optional[Union[CoreAPI, JsonAPI]]: """ Returns a connector class for an API There are two supported versions: - JSON API - Rest API (Drupal core) """ - supported_api = {c.JSON_API: JsonAPI, c.CORE_API: CoreAPI} + supported_api = {da_conf.JSON_API: JsonAPI, da_conf.CORE_API: CoreAPI} - api_version: str = tk.config.get(c.CONFIG_DRUPAL_API_VERSION, c.DEFAULT_API_VERSION) - return supported_api.get(api_version) + return supported_api.get(da_conf.get_api_version()) def cached(func: Callable[..., MaybeNotCached[T]]) -> Callable[..., T]: @@ -47,9 +46,7 @@ def wrapper(*args, **kwargs): return cast(T, json.loads(value)) value = func(*args, **kwargs) - cache_duration = tk.asint( - tk.config.get(c.CONFIG_CACHE_DURATION, c.DEFAULT_CACHE_DURATION) - ) + cache_duration = da_conf.get_cache_ttl() if isinstance(value, DontCache): return cast(T, value.unwrap()) @@ -79,12 +76,3 @@ def drop_cache_for(name): def _get_redis_conn(): return redis.connect_to_redis() - - -def _get_menu_export_endpoint(): - if tk.config.get( - c.CONFIG_DRUPAL_API_VERSION, c.DEFAULT_API_VERSION - ) == "json": - return "/jsonapi/menu_items/{menu_id}" - else: - return tk.config.get(c.CONFIG_MENU_EXPORT, c.DEFAULT_MENU_EXPORT_EP) diff --git a/ckanext/drupal_api/views.py b/ckanext/drupal_api/views.py index 812bfb8..8f2fd44 100644 --- a/ckanext/drupal_api/views.py +++ b/ckanext/drupal_api/views.py @@ -1,58 +1,75 @@ import logging from flask import Blueprint +from flask.views import MethodView import ckan.plugins.toolkit as tk -import ckan.model as model +from ckan.logic import parse_params -import ckanext.drupal_api.config as c -from ckanext.drupal_api.utils import drop_cache_for, _get_menu_export_endpoint +from ckanext.ap_main.utils import ap_before_request + +import ckanext.drupal_api.config as da_conf +from ckanext.drupal_api.utils import drop_cache_for from ckanext.drupal_api.helpers import custom_endpoint, menu log = logging.getLogger(__name__) -drupal_api = Blueprint("drupal_api", __name__) +drupal_api = Blueprint("drupal_api", __name__, url_prefix="/admin-panel/drupal_api") +drupal_api.before_request(ap_before_request) +class ConfigView(MethodView): + def get(self): + return tk.render( + "drupal_api/config.html", + {"configs": da_conf.get_config_options(), "data": {}, "errors": {}}, + ) -@drupal_api.before_request -def before_request(): - try: - context = { - "model": model, - "user": tk.g.user, - "auth_user_obj": tk.g.userobj - } - tk.check_access('sysadmin', context) - except tk.NotAuthorized: - tk.base.abort(403, tk._('Need to be system administrator to manage cache')) + def post(self): + data_dict = parse_params(tk.request.form) + try: + tk.get_action("config_option_update")( + {"user": tk.current_user.name}, + data_dict, + ) + except tk.ValidationError as e: + return tk.render( + "drupal_api/config.html", + extra_vars={ + "data": data_dict, + "errors": e.error_dict, + "error_summary": e.error_summary, + "configs": da_conf.get_config_options(), + }, + ) -@drupal_api.route("/ckan-admin/drupal-api", methods=("GET", "POST")) -def drupal_api_config(): - """ - Invalidates cache - """ - if not tk.request.form: - return tk.render( - "admin/drupal_api_config.html", - { - "cache_ttl_default": c.DEFAULT_CACHE_DURATION, - "cache_ttl_current": tk.config.get(c.CONFIG_CACHE_DURATION), - "drupal_url": tk.config.get(c.CONFIG_DRUPAL_URL, "").strip('/'), - "menu_export_endpoint": _get_menu_export_endpoint(), - "api_version": tk.config.get(c.CONFIG_DRUPAL_API_VERSION, c.DEFAULT_API_VERSION) - }, - ) - else: + tk.h.flash_success(tk._("Config options have been updated")) + return tk.h.redirect_to("drupal_api.config") + + +class ConfigClearCacheView(MethodView): + def post(self): if "clear-menu-cache" in tk.request.form: drop_cache_for(menu.__name__) - tk.h.flash_success(tk._("Cache has been cleared")) if "clear-custom-cache" in tk.request.form: drop_cache_for(custom_endpoint.__name__) - return tk.h.redirect_to("drupal_api.drupal_api_config") + tk.h.flash_success(tk._("Cache has been cleared")) + + return tk.h.redirect_to("drupal_api.config") + +drupal_api.add_url_rule( + "/config", + view_func=ConfigView.as_view("config"), + methods=("GET", "POST"), +) +drupal_api.add_url_rule( + "/clear_cache", + view_func=ConfigClearCacheView.as_view("clear_cache"), + methods=("POST",), +) blueprints = [drupal_api] diff --git a/setup.py b/setup.py index 7be6a2a..8a55243 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # http://packaging.python.org/en/latest/tutorial.html#version - version='0.6.1', + version='0.7.0', description='''''', long_description=long_description,