diff --git a/conftest.py b/conftest.py index 01fad1f47f..f145c4294a 100644 --- a/conftest.py +++ b/conftest.py @@ -25,7 +25,8 @@ def fixtures(): 'sites', 'tasks', 'users', - 'views' + 'views', + 'config' } fixtures = [] for fixture_dir in settings.FIXTURE_DIRS: diff --git a/package-lock.json b/package-lock.json index 3ac12ecedd..1171279eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@codemirror/lang-html": "^6.4.2", "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-json": "^6.0.2", "@uiw/react-codemirror": "^4.25.1", "bootstrap-sass": "^3.4.1", "classnames": "^2.5.1", @@ -1823,6 +1824,16 @@ "@lezer/javascript": "^1.0.0" } }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.6.0.tgz", @@ -2254,9 +2265,10 @@ } }, "node_modules/@lezer/common": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", - "integrity": "sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", + "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", + "license": "MIT" }, "node_modules/@lezer/css": { "version": "1.1.1", @@ -2294,6 +2306,17 @@ "@lezer/lr": "^1.3.0" } }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.3.tgz", @@ -9453,6 +9476,15 @@ "@lezer/javascript": "^1.0.0" } }, + "@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "requires": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, "@codemirror/language": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.6.0.tgz", @@ -9806,9 +9838,9 @@ } }, "@lezer/common": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", - "integrity": "sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", + "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==" }, "@lezer/css": { "version": "1.1.1", @@ -9846,6 +9878,16 @@ "@lezer/lr": "^1.3.0" } }, + "@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "@lezer/lr": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.3.tgz", diff --git a/package.json b/package.json index b9411d6c06..3bca992082 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@codemirror/lang-html": "^6.4.2", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-javascript": "^6.2.2", "@uiw/react-codemirror": "^4.25.1", "bootstrap-sass": "^3.4.1", diff --git a/rdmo/accounts/checks.py b/rdmo/accounts/checks.py index 766a885b68..d926ddb6d9 100644 --- a/rdmo/accounts/checks.py +++ b/rdmo/accounts/checks.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from django.conf import settings from django.core.checks import Tags, Warning, register diff --git a/rdmo/accounts/tests/test_checks_shibboleth.py b/rdmo/accounts/tests/test_checks_shibboleth.py index 09a9773edc..5b74798dbe 100644 --- a/rdmo/accounts/tests/test_checks_shibboleth.py +++ b/rdmo/accounts/tests/test_checks_shibboleth.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from rdmo.accounts.checks import W_FIX_DISABLED, W_MISSING_FIX, check_shibboleth_remoteuser from .helpers import enable_shibboleth, fake_shibboleth # noqa: F401 diff --git a/rdmo/config/__init__.py b/rdmo/config/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/config/admin.py b/rdmo/config/admin.py new file mode 100644 index 0000000000..448069e1b1 --- /dev/null +++ b/rdmo/config/admin.py @@ -0,0 +1,41 @@ +from django import forms +from django.contrib import admin + +from rdmo.core.admin import ElementAdminForm +from rdmo.core.utils import get_language_fields, get_plugin_python_paths + +from .models import Plugin +from .validators import ( + PluginLockedValidator, + PluginPythonPathValidator, + PluginUniqueURIValidator, + PluginURLNameValidator, +) + + +class PluginAdminForm(ElementAdminForm): + + python_path = forms.ChoiceField(choices=[(plugin, plugin) for plugin in get_plugin_python_paths()]) + + + class Meta: + model = Plugin + fields = '__all__' + + def clean(self): + PluginUniqueURIValidator(self.instance)(self.cleaned_data) + PluginLockedValidator(self.instance)(self.cleaned_data) + PluginPythonPathValidator(self.instance)(self.cleaned_data) + PluginURLNameValidator(self.instance)(self.cleaned_data) + + +@admin.register(Plugin) +class PluginAdmin(admin.ModelAdmin): + form = PluginAdminForm + + search_fields = ['uri', 'python_path', *get_language_fields('title'), *get_language_fields('help')] + list_display = ('uri', 'python_path', 'plugin_type', 'available') + readonly_fields = ('uri', 'plugin_type') + list_filter = ('available', 'python_path', 'sites' , 'groups', 'catalogs') + filter_horizontal = ('catalogs', 'sites', 'editors', 'groups') + ordering = ('python_path','order') diff --git a/rdmo/config/apps.py b/rdmo/config/apps.py new file mode 100644 index 0000000000..a3085bbfb3 --- /dev/null +++ b/rdmo/config/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class ConfigConfig(AppConfig): + name = 'rdmo.config' + verbose_name = _('Config') + + def ready(self): + from . import checks # noqa: F401 diff --git a/rdmo/config/checks.py b/rdmo/config/checks.py new file mode 100644 index 0000000000..192d1cc2ae --- /dev/null +++ b/rdmo/config/checks.py @@ -0,0 +1,78 @@ +from collections import defaultdict + +from django.core.checks import Warning, register +from django.utils.module_loading import import_string + + +@register() +def deprecated_plugin_settings_check(app_configs, **kwargs): + from django.conf import settings + legacy_settings = [ + "PROJECT_EXPORTS", + "PROJECT_SNAPSHOT_EXPORTS", + "PROJECT_IMPORTS", + "PROJECT_ISSUE_PROVIDERS", + "PROJECT_IMPORTS_LIST", + "OPTIONSET_PROVIDERS", + ] + issues = [] + legacy_settings_used_keys = { + i for i in legacy_settings if hasattr(settings, i) + } + if legacy_settings_used_keys: + _verb = "are" if len(legacy_settings_used_keys) > 1 else "is" + legacy_settings = { + i: getattr(settings, i) for i in legacy_settings_used_keys + } + _legacy_settings_plugins = defaultdict(list) + _python_paths = set() + for name,entries in legacy_settings.items(): + for entry in entries: + _legacy_settings_plugins[name].append(entry) + if len(entry) == 3: + _python_paths.add(entry[-1]) + issues.append(Warning( + f"{', '.join(legacy_settings_used_keys)} {_verb} deprecated as of RDMO 2.5.0; " + f"use PLUGINS = ['python.dotted.paths', ...] instead.", + id="rdmo.config.W001", + hint="Define the legacy plugin settings in PLUGINS and remove the legacy settings." + f"\n{repr_new_settings(_python_paths)}", + )) + # If both PLUGINS and any legacy key exist + if settings.PLUGINS: + issues.append(Warning( + "PLUGINS is set in addition to legacy settings; the legacy settings are ignored.", + id="rdmo.config.W002", + hint="Remove the following legacy settings to avoid confusion: " + f"{', '.join(legacy_settings_used_keys)}." + )) + return issues + + +def repr_new_settings(python_paths) -> str: + if not python_paths: + return "" + elif len(python_paths) == 1: + return f"PLUGINS = ['{python_paths}']" + msg = "PLUGINS = [" + for python_path in sorted(python_paths): + msg += f"\n\t{python_path}, " + msg += "\n]" + return msg + + +@register() +def plugins_importable_check(app_configs, **kwargs): + from django.conf import settings + + issues = [] + for plugin_path in settings.PLUGINS: + try: + import_string(plugin_path) + except ImportError as exc: + issues.append(Warning( + f"Plugin import failed: {plugin_path} ({exc})", + id="rdmo.config.W003", + hint="Ensure the plugin path is valid and the module is installed.", + )) + return issues diff --git a/rdmo/config/constants.py b/rdmo/config/constants.py new file mode 100644 index 0000000000..38d1f90161 --- /dev/null +++ b/rdmo/config/constants.py @@ -0,0 +1,9 @@ +from django.db.models import TextChoices + + +class PLUGIN_TYPES(TextChoices): + PROJECT_EXPORT = "project_export", "Project export" + PROJECT_SNAPSHOT_EXPORT = "project_snapshot_export", "Project snapshot export" + PROJECT_IMPORT = "project_import", "Project import" + PROJECT_ISSUE_PROVIDER = "project_issue_provider", "Project issue provider" + OPTIONSET_PROVIDER = "optionset_provider", "Optionset provider" diff --git a/rdmo/config/imports.py b/rdmo/config/imports.py new file mode 100644 index 0000000000..d9d54932c8 --- /dev/null +++ b/rdmo/config/imports.py @@ -0,0 +1,20 @@ +from rdmo.core.import_helpers import ElementImportHelper, ExtraFieldHelper + +from .models import Plugin +from .validators import PluginLockedValidator, PluginUniqueURIValidator + +import_helper_plugin = ElementImportHelper( + model=Plugin, + validators=(PluginLockedValidator, PluginUniqueURIValidator), + lang_fields=('title', 'help'), + extra_fields=( + ExtraFieldHelper(field_name='python_path'), + ExtraFieldHelper(field_name='plugin_settings', overwrite_in_element=True), + ExtraFieldHelper(field_name='available', overwrite_in_element=True), + ExtraFieldHelper(field_name='locked'), + ExtraFieldHelper(field_name='order'), + ExtraFieldHelper(field_name='url_name'), + + ), + add_current_site_sites = True, +) diff --git a/rdmo/config/legacy.py b/rdmo/config/legacy.py new file mode 100644 index 0000000000..886785ad59 --- /dev/null +++ b/rdmo/config/legacy.py @@ -0,0 +1,44 @@ +from django.conf import settings + +from rdmo.config.constants import PLUGIN_TYPES + +PLUGIN_TYPE_TO_SETTING_KEY = { + PLUGIN_TYPES.PROJECT_IMPORT: "PROJECT_IMPORTS", + PLUGIN_TYPES.PROJECT_EXPORT: "PROJECT_EXPORTS", + PLUGIN_TYPES.PROJECT_SNAPSHOT_EXPORT: "PROJECT_SNAPSHOT_EXPORTS", + PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER: "PROJECT_ISSUE_PROVIDERS", + PLUGIN_TYPES.OPTIONSET_PROVIDER: "OPTIONSET_PROVIDERS", +} + +def get_plugins_from_legacy_settings(select_plugin_type=None) -> list[dict]: + """Read 3-tuples (key, label, python-path) from legacy settings.""" + plugin_definitions: list[dict] = [] + for plugin_type, legacy_setting in PLUGIN_TYPE_TO_SETTING_KEY.items(): + if not hasattr(settings, legacy_setting): + continue + if select_plugin_type is not None and select_plugin_type != plugin_type: + continue + + legacy_plugins = getattr(settings, legacy_setting, None) + if not legacy_plugins: + continue + + for entry in legacy_plugins: + try: + key, label, dotted = entry + except ValueError as exc: + raise ValueError( + f"{legacy_setting} must be a sequence of 3-tuples " + f"(key, label, python-path); got {entry!r}" + ) from exc + + plugin_definitions.append({ + "uri_prefix": settings.DEFAULT_URI_PREFIX, + "uri_path": f"{legacy_setting.lower()}/{key}", + "title": label, + "python_path": dotted, + "plugin_type": plugin_type, + "url_name": key, + }) + + return plugin_definitions diff --git a/rdmo/config/management/commands/__init__.py b/rdmo/config/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/config/management/commands/check_plugins.py b/rdmo/config/management/commands/check_plugins.py new file mode 100644 index 0000000000..dc3a9dfe0d --- /dev/null +++ b/rdmo/config/management/commands/check_plugins.py @@ -0,0 +1,26 @@ +from django.core.management.base import BaseCommand +from django.utils.module_loading import import_string + +from rdmo.config.models import Plugin + + +class Command(BaseCommand): + help = "Check that all configured plugins can be imported" + + def handle(self, *args, **options): + if not Plugin.objects.exists(): + self.stdout.write(self.style.SUCCESS("No plugins found.")) + + for plugin in Plugin.objects.order_by('python_path').all(): + try: + import_string(plugin.python_path) + self.stdout.write(self.style.SUCCESS(f"✔ {plugin.python_path}, type={plugin.plugin_type}.")) + except ImportError as e: + if plugin.available: + self.stdout.write(self.style.ERROR( + f"✖ {plugin.python_path}, type={plugin.plugin_type} failed: {e}") + ) + else: + self.stdout.write(self.style.WARNING( + f"!! {plugin.python_path}, type={plugin.plugin_type} (=unavailable) failed: {e}") + ) diff --git a/rdmo/config/management/commands/setup_plugins.py b/rdmo/config/management/commands/setup_plugins.py new file mode 100644 index 0000000000..a82ae58256 --- /dev/null +++ b/rdmo/config/management/commands/setup_plugins.py @@ -0,0 +1,313 @@ +import sys + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils.module_loading import import_string +from django.utils.translation import gettext as _ + +from rest_framework.exceptions import ValidationError as RestFrameworkValidationError + +from rdmo.config.legacy import get_plugins_from_legacy_settings +from rdmo.config.models import Plugin +from rdmo.config.serializers.v1 import PluginSerializer +from rdmo.config.utils import get_plugins_from_settings +from rdmo.core.utils import get_languages + + +def _ensure_plugin_meta(plugin: Plugin, plugin_class, *, dry_run: bool) -> str | None: + if plugin.plugin_meta: + return None + + if dry_run: + return "would refresh plugin_meta" + + plugin.plugin_meta = plugin.build_plugin_meta(plugin_class) + plugin.save(update_fields=["plugin_meta"]) + return "refreshed plugin_meta" + + +def _validate_declared_plugin(plugin: Plugin | None, declared: dict) -> None: + python_path = declared.get("python_path") + restore_plugins = None + if python_path and python_path not in settings.PLUGINS: + restore_plugins = list(settings.PLUGINS) + settings.PLUGINS = [*restore_plugins, python_path] + + serializer_data = { + "uri_prefix": declared.get("uri_prefix"), + "uri_path": declared.get("uri_path"), + "python_path": declared.get("python_path"), + "url_name": declared.get("url_name"), + "available": declared.get("available", True), + } + if declared.get("title"): + languages = get_languages() + if languages: + serializer_data[f"title_{languages[0][-1]}"] = declared.get("title") + + serializer_data = {key: value for key, value in serializer_data.items() if value is not None} + serializer = PluginSerializer(instance=plugin, data=serializer_data, partial=True) + + if python_path and python_path not in serializer.fields["python_path"].choices: + serializer.fields["python_path"].choices = [ + *serializer.fields["python_path"].choices, + python_path, + ] + + try: + serializer.is_valid(raise_exception=True) + except RestFrameworkValidationError as exc: + messages = [] + for field, errors in exc.detail.items(): + for error in errors: + messages.append(f"{field}: {error}") + raise CommandError("Validation failed: " + "; ".join(messages)) from exc + finally: + if restore_plugins is not None: + settings.PLUGINS = restore_plugins + + +def save_declared_plugin(declared: dict, *, replace: bool, dry_run: bool) -> str: + uri_prefix = declared["uri_prefix"] or getattr(settings, "DEFAULT_URI_PREFIX", None) + if not uri_prefix: + raise CommandError("No uri_prefix available (neither provided nor DEFAULT_URI_PREFIX).") + + uri_path = declared["uri_path"] or declared["url_name"] + + try: + plugin = Plugin.objects.get(uri_prefix=uri_prefix, uri_path=uri_path) + exists = True + if not replace: + try: + plugin_class = import_string(plugin.python_path) + except ImportError: + return f"skipped(exists): {plugin.python_path} -> {plugin.uri}" + + refresh_msg = _ensure_plugin_meta(plugin, plugin_class, dry_run=dry_run) + if refresh_msg: + return f"skipped(exists, {refresh_msg}): {plugin.python_path} -> {plugin.uri}" + return f"skipped(exists): {plugin.python_path} -> {plugin.uri}" + except Plugin.DoesNotExist: + plugin = Plugin(uri_prefix=uri_prefix, uri_path=uri_path) + exists = False + + plugin.title_lang1 = declared["title"] + plugin.python_path = declared["python_path"] + if declared["url_name"] is not None: + plugin.url_name = declared["url_name"] + plugin.available = True + + _validate_declared_plugin(plugin, declared) + + action = "replaced" if (exists and replace) else ("created" if not exists else "updated") + if dry_run: + uri = f"{uri_prefix}/plugins/{uri_path}" + return f"{action} (dry-run): {declared['python_path']} -> {uri}" + + plugin.full_clean() + plugin.save() + if settings.MULTISITE: + current_site = Site.objects.get_current() + plugin.editors.set([current_site]) + plugin.sites.set([current_site]) + + return f"{action}: {plugin.python_path} -> {plugin.uri}" + + +def merge_legacy_and_current_plugins(legacy_plugins: list[dict], plugins: list[dict]) -> list[dict]: + legacy_uri_paths = {d["uri_path"] for d in legacy_plugins} + legacy_paths = {d["python_path"] for d in legacy_plugins} + filtered_plugins = [ + i + for i in plugins + if i["uri_path"] not in legacy_uri_paths and i["python_path"] not in legacy_paths + ] + return legacy_plugins + filtered_plugins + + +class Command(BaseCommand): + help = _("Create or update Plugin objects from PLUGINS, legacy settings. Can also clear all plugins.") + + def add_arguments(self, parser): + parser.add_argument( + "--replace", + action="store_true", + help=_("Replace existing rows instead of updating Plugin objects."), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help=_("Do not write to the database, only print intended actions."), + ) + parser.add_argument( + "--clear", + action="store_true", + help=_("Delete ALL existing Plugin objects before importing."), + ) + parser.add_argument( + "--force", + action="store_true", + help=_("Do not prompt for confirmation when using --clear."), + ) + + def _confirm_or_abort(self, prompt: str) -> None: + stdin = sys.stdin + + if not stdin or not stdin.isatty(): + raise CommandError( + "No interactive terminal available for confirmation. " + "Re-run with --force (or run in a real TTY, e.g. docker exec -it)." + ) + + self.stdout.write("") # newline for readability + self.stdout.write(self.style.WARNING(prompt), ending="") + self.stdout.flush() + + try: + answer = stdin.readline() + except EOFError: + answer = "" + except KeyboardInterrupt: + raise CommandError("Clear aborted by user (Ctrl+C).") from None + except (OSError, ValueError): + answer = "" + + if answer.strip().lower() != "yes": + raise CommandError("Clear aborted by user.") + + def handle(self, *args, **options): + dry_run: bool = options["dry_run"] + force: bool = options["force"] + clear: bool = options["clear"] + replace: bool = options["replace"] + + # --------------------------------------------------------------------- + # Phase 0: Gather + validate OUTSIDE a transaction (no DB locks held). + # --------------------------------------------------------------------- + plugins_from_settings: list[dict] = [] + + legacy_plugins = get_plugins_from_legacy_settings() + try: + plugins = get_plugins_from_settings() + except ValidationError as e: + raise CommandError("\n".join(e.messages)) from e + + plugins_from_settings.extend( + merge_legacy_and_current_plugins(legacy_plugins, plugins) + ) + + # If no import source and only --clear, we are done after clear phase. + # If no import source and no clear, error. + if not plugins_from_settings and not clear: + raise CommandError("Nothing to do.") + + if plugins_from_settings: + self.stdout.write( + self.style.WARNING( + "Reading legacy plugin settings. These are deprecated and will be removed in a future release." + ) + ) + plugins_from_settings.sort(key=lambda x: (x["python_path"], x["uri_path"] or "")) + + # Import validation (no DB writes) + errors: list[tuple[dict, Exception]] = [] + for plugin in plugins_from_settings: + try: + import_string(plugin["python_path"]) + except ImportError as exc: + errors.append((plugin, exc)) + self.stdout.write(self.style.ERROR(f"✖ import FAILED: {plugin['python_path']} ({exc})")) + + if errors: + msg = f"{len(errors)} plugin(s) failed import validation; aborting before database changes." + for error in errors: + msg += f"\n- {error[0]}\n\t{error[1]}" + + if dry_run: + self.stdout.write(self.style.WARNING(msg)) + self.stdout.write(self.style.SUCCESS("✔ dry-run complete; no changes committed.")) + return + raise CommandError(msg) + + # --------------------------------------------------------------------- + # Phase 1: Optional destructive clear (short transaction; only if writing). + # --------------------------------------------------------------------- + if clear: + self.stdout.write(self.style.WARNING("⚠ --clear requested: ALL Plugin objects will be removed.")) + + if dry_run: + self.clear_all_plugins(dry_run=True, force=True) + else: + with transaction.atomic(): + self.clear_all_plugins(dry_run=False, force=force) + + # If we only cleared and have nothing to import, we are done. + if not plugins_from_settings: + if clear: + self.stdout.write( + self.style.SUCCESS("✔ clear completed (dry-run; no changes committed).") + if dry_run + else self.style.SUCCESS("✔ clear completed.") + ) + return + raise CommandError("Nothing to do.") + + # --------------------------------------------------------------------- + # Phase 2: Save/update plugins (short transaction; only if writing). + # --------------------------------------------------------------------- + results: list[str] = [] + + if dry_run: + for plugin in plugins_from_settings: + try: + msg = save_declared_plugin(plugin, replace=replace, dry_run=True) + results.append(self.style.SUCCESS(f"✔ {msg}")) + except CommandError as exc: + results.append(self.style.ERROR(f"✖ {plugin['python_path']} failed: {exc}")) + + for line in results: + self.stdout.write(line) + + self.stdout.write(self.style.SUCCESS("✔ dry-run complete; no changes committed.")) + return + + with transaction.atomic(): + for plugin in plugins_from_settings: + try: + msg = save_declared_plugin(plugin, replace=replace, dry_run=False) + results.append(self.style.SUCCESS(f"✔ {msg}")) + except CommandError as exc: + results.append(self.style.ERROR(f"✖ {plugin['python_path']} failed: {exc}")) + + for line in results: + self.stdout.write(line) + + def clear_all_plugins(self, dry_run: bool, force: bool) -> None: + """ + Clear all Plugin rows. In dry-run, print what would be deleted. + Without --force (and not dry-run), prompt for confirmation. + """ + queryset = Plugin.objects.all().order_by("uri_prefix", "uri_path") + + if not queryset.exists(): + self.stdout.write(self.style.WARNING("⚠ no Plugin objects found; nothing to clear.")) + return + + self.stdout.write(self.style.WARNING(f"⚠ about to clear {queryset.count()} Plugin object(s):")) + for p in queryset: + if dry_run: + self.stdout.write(self.style.WARNING(f"• {p.python_path} at {p.uri} will be deleted")) + else: + self.stdout.write(self.style.WARNING(f"• {p.python_path} at {p.uri} marked for deletion")) + + if dry_run: + return + + if not force: + self._confirm_or_abort("Type 'yes' to confirm deletion of ALL Plugin objects: ") + + deleted, _ = queryset.delete() + self.stdout.write(self.style.SUCCESS(f"✔ deleted {deleted} object(s).")) diff --git a/rdmo/config/managers.py b/rdmo/config/managers.py new file mode 100644 index 0000000000..eace2fc080 --- /dev/null +++ b/rdmo/config/managers.py @@ -0,0 +1,109 @@ +from django.conf import settings +from django.db import models + +from rdmo.core.managers import ( + AvailabilityManagerMixin, + AvailabilityQuerySetMixin, + CurrentSiteManagerMixin, + CurrentSiteQuerySetMixin, + ForSiteQuerySetMixin, + GroupsManagerMixin, + GroupsQuerySetMixin, +) +from rdmo.core.utils import jsonfield_contains + + +class PluginQuerySet(ForSiteQuerySetMixin, CurrentSiteQuerySetMixin, GroupsQuerySetMixin, + AvailabilityQuerySetMixin, models.QuerySet): + + def filter_for_project(self, project): + return ( + self + .filter_for_site(project.site) + .filter(catalogs=project.catalog) + .filter(models.Q(groups=None) | models.Q(groups__in=project.groups)) + .filter(available=True) + ) + + def filter_for_settings(self): + if not settings.PLUGINS: + return self.none() + + return self.filter(python_path__in=settings.PLUGINS) + + def filter_for_format(self, file_format: str): + queryset = ( + models.Q(url_name=file_format) + | models.Q(uri_path=file_format) + | models.Q(uri_path__endswith=f'/{file_format}') + ) + + queryset_contains = jsonfield_contains(self.db, 'plugin_settings', 'format', file_format) + if queryset_contains is not None: + queryset |= queryset_contains + + return self.filter(queryset) + + + def for_context(self, project=None, plugin_type=None, plugin_types=None, + user=None, format=None): + queryset = self + + # filter by settings.PLUGINS + queryset = queryset.filter_for_settings() + + # filter by project .site,.catalog and .groups + if project is not None: + queryset = queryset.filter_for_project(project) + + # filter by availability + if user is not None: + queryset = queryset.filter_availability(user) + else: + queryset = queryset.filter(available=True) + + # filter by current site + queryset = queryset.filter_current_site() + + # filter by optional plugin type(s) + if plugin_type is not None and plugin_types is not None: + raise ValueError('Pass either plugin_type or plugin_types, not both.') + + if plugin_type is not None: + queryset = queryset.filter(plugin_type=plugin_type) + elif plugin_types is not None: + queryset = queryset.filter(plugin_type__in=plugin_types) + + # filter by optional format + if format is not None: + queryset = queryset.filter_for_format(format) + + return queryset + + +class PluginManager(CurrentSiteManagerMixin, GroupsManagerMixin, AvailabilityManagerMixin, models.Manager): + + def get_queryset(self) -> PluginQuerySet: + return PluginQuerySet(self.model, using=self._db) + + def filter_current_site(self): + return self.get_queryset().filter_current_site() + + def filter_for_project(self, project): + return self.get_queryset().filter_for_project(project) + + def filter_for_settings(self): + return self.get_queryset().filter_for_settings() + + def filter_for_format(self, file_format): + return self.get_queryset().filter_for_format(file_format) + + def for_context(self, project=None, plugin_type=None, plugin_types=None, + user=None, format=None): + return self.get_queryset().for_context( + project=project, + plugin_type=plugin_type, + plugin_types=plugin_types, + user=user, + format=format + ) diff --git a/rdmo/config/migrations/0001_initial.py b/rdmo/config/migrations/0001_initial.py new file mode 100644 index 0000000000..b58b5970db --- /dev/null +++ b/rdmo/config/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.26 on 2025-11-14 14:00 + +from django.db import migrations, models +import rdmo.core.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('sites', '0002_alter_domain_unique'), + ('questions', '0097_alter_question_widget_type'), + ] + + operations = [ + migrations.CreateModel( + name='Plugin', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(editable=False, verbose_name='created')), + ('updated', models.DateTimeField(editable=False, verbose_name='updated')), + ('uri', models.URLField(blank=True, default='', help_text='The Uniform Resource Identifier of this plugin (auto-generated).', max_length=800, verbose_name='URI')), + ('uri_prefix', models.URLField(help_text='The prefix for the URI of this plugin.', max_length=256, verbose_name='URI Prefix')), + ('uri_path', models.CharField(blank=True, default='', help_text='The path for the URI of this plugin.', max_length=512, verbose_name='URI Path')), + ('comment', models.TextField(blank=True, default='', help_text='Additional internal information about this question.', verbose_name='Comment')), + ('locked', models.BooleanField(default=False, help_text='Designates whether this plugin can be changed.', verbose_name='Locked')), + ('order', models.IntegerField(default=0, help_text='The position of this plugin in lists.', verbose_name='Order')), + ('title_lang1', models.CharField(blank=True, help_text='The title for this plugin (in the primary language).', max_length=256, verbose_name='Title (primary)')), + ('title_lang2', models.CharField(blank=True, help_text='The title for this plugin (in the secondary language).', max_length=256, verbose_name='Title (secondary)')), + ('title_lang3', models.CharField(blank=True, help_text='The title for this plugin (in the tertiary language).', max_length=256, verbose_name='Title (tertiary)')), + ('title_lang4', models.CharField(blank=True, help_text='The title for this plugin (in the quaternary language).', max_length=256, verbose_name='Title (quaternary)')), + ('title_lang5', models.CharField(blank=True, help_text='The title for this plugin (in the quinary language).', max_length=256, verbose_name='Title (quinary)')), + ('help_lang1', models.TextField(blank=True, help_text='The help text for this plugin (in the primary language).', verbose_name='Help (primary)')), + ('help_lang2', models.TextField(blank=True, help_text='The help text for this plugin (in the secondary language).', verbose_name='Help (secondary)')), + ('help_lang3', models.TextField(blank=True, help_text='The help text for this plugin (in the tertiary language).', verbose_name='Help (tertiary)')), + ('help_lang4', models.TextField(blank=True, help_text='The help text for this plugin (in the quaternary language).', verbose_name='Help (quaternary)')), + ('help_lang5', models.TextField(blank=True, help_text='The help text for this plugin (in the quinary language).', verbose_name='Help (quinary)')), + ('available', models.BooleanField(default=True, help_text='Designates whether this plugin is generally available for projects.', verbose_name='Available')), + ('python_path', models.CharField(help_text='Python dotted path to the plugin class, e.g. "rdmo_plugins_provider.module.PluginClass"', max_length=512, verbose_name='Python path')), + ('plugin_settings', models.JSONField(blank=True, default=dict, help_text='Contains the settings for this plugin in JSON format.', verbose_name='Plugin settings')), + ('plugin_meta', models.JSONField(blank=True, default=dict, editable=False, help_text='Contains metadata derived from the plugin class.', verbose_name='Plugin metadata')), + ('plugin_type', models.SlugField(blank=True, default='', editable=False, help_text='The type of plugin this is, e.g. "project_export".', max_length=128, verbose_name='Plugin type')), + ('url_name', models.SlugField(blank=True, default='', help_text='The url_name for this plugin.', max_length=128, verbose_name='URL name')), + ('catalogs', models.ManyToManyField(blank=True, help_text='The catalogs this plugin can be used with. An empty list implies that this plugin can be used with every catalog.', to='questions.catalog', verbose_name='Catalogs')), + ('editors', models.ManyToManyField(blank=True, help_text='The sites that can edit this plugin (in a multi site setup).', related_name='plugins_as_editor', to='sites.site', verbose_name='Editors')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups for which this plugin is active.', to='auth.group', verbose_name='Group')), + ('sites', models.ManyToManyField(blank=True, help_text='The sites this plugin belongs to (in a multi site setup).', to='sites.site', verbose_name='Sites')), + ], + options={ + 'verbose_name': 'Plugin', + 'verbose_name_plural': 'Plugins', + 'ordering': ('uri', 'order'), + }, + bases=(models.Model, rdmo.core.models.TranslationMixin), + ), + ] diff --git a/rdmo/config/migrations/__init__.py b/rdmo/config/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/config/models.py b/rdmo/config/models.py new file mode 100644 index 0000000000..ace3d69053 --- /dev/null +++ b/rdmo/config/models.py @@ -0,0 +1,283 @@ +import mimetypes +from inspect import signature + +from django.conf import settings +from django.contrib.auth.models import Group +from django.contrib.sites.models import Site +from django.db import models +from django.utils.module_loading import import_string +from django.utils.translation import gettext_lazy as _ + +from rdmo.config.managers import PluginManager +from rdmo.config.utils import get_plugin_type_from_class +from rdmo.core.models import Model, TranslationMixin +from rdmo.core.utils import get_distribution_name_from_class, get_distribution_version, join_url + + +class Plugin(Model, TranslationMixin): + + objects = PluginManager() + + uri = models.URLField( + max_length=800, blank=True, default="", + verbose_name=_('URI'), + help_text=_('The Uniform Resource Identifier of this plugin (auto-generated).') + ) + uri_prefix = models.URLField( + max_length=256, + verbose_name=_('URI Prefix'), + help_text=_('The prefix for the URI of this plugin.') + ) + uri_path = models.CharField( + max_length=512, blank=True, default="", + verbose_name=_('URI Path'), + help_text=_('The path for the URI of this plugin.') + ) + comment = models.TextField( + blank=True, default="", + verbose_name=_('Comment'), + help_text=_('Additional internal information about this question.') + ) + locked = models.BooleanField( + default=False, + verbose_name=_('Locked'), + help_text=_('Designates whether this plugin can be changed.') + ) + order = models.IntegerField( + default=0, + verbose_name=_('Order'), + help_text=_('The position of this plugin in lists.') + ) + sites = models.ManyToManyField( + Site, blank=True, + verbose_name=_('Sites'), + help_text=_('The sites this plugin belongs to (in a multi site setup).') + ) + editors = models.ManyToManyField( + Site, related_name='plugins_as_editor', blank=True, + verbose_name=_('Editors'), + help_text=_('The sites that can edit this plugin (in a multi site setup).') + ) + groups = models.ManyToManyField( + Group, blank=True, + verbose_name=_('Group'), + help_text=_('The groups for which this plugin is active.') + ) + catalogs = models.ManyToManyField( + 'questions.Catalog', blank=True, # config app should stay below elements in hierarchy of imports + verbose_name=_('Catalogs'), + help_text=_('The catalogs this plugin can be used with. ' + 'An empty list implies that this plugin can be used with every catalog.') + ) + title_lang1 = models.CharField( + max_length=256, blank=True, + verbose_name=_('Title (primary)'), + help_text=_('The title for this plugin (in the primary language).') + ) + title_lang2 = models.CharField( + max_length=256, blank=True, + verbose_name=_('Title (secondary)'), + help_text=_('The title for this plugin (in the secondary language).') + ) + title_lang3 = models.CharField( + max_length=256, blank=True, + verbose_name=_('Title (tertiary)'), + help_text=_('The title for this plugin (in the tertiary language).') + ) + title_lang4 = models.CharField( + max_length=256, blank=True, + verbose_name=_('Title (quaternary)'), + help_text=_('The title for this plugin (in the quaternary language).') + ) + title_lang5 = models.CharField( + max_length=256, blank=True, + verbose_name=_('Title (quinary)'), + help_text=_('The title for this plugin (in the quinary language).') + ) + help_lang1 = models.TextField( + blank=True, + verbose_name=_('Help (primary)'), + help_text=_('The help text for this plugin (in the primary language).') + ) + help_lang2 = models.TextField( + blank=True, + verbose_name=_('Help (secondary)'), + help_text=_('The help text for this plugin (in the secondary language).') + ) + help_lang3 = models.TextField( + blank=True, + verbose_name=_('Help (tertiary)'), + help_text=_('The help text for this plugin (in the tertiary language).') + ) + help_lang4 = models.TextField( + blank=True, + verbose_name=_('Help (quaternary)'), + help_text=_('The help text for this plugin (in the quaternary language).') + ) + help_lang5 = models.TextField( + blank=True, + verbose_name=_('Help (quinary)'), + help_text=_('The help text for this plugin (in the quinary language).') + ) + available = models.BooleanField( + default=True, + verbose_name=_('Available'), + help_text=_('Designates whether this plugin is generally available for projects.') + ) + python_path = models.CharField( + max_length=512, + verbose_name=_('Python path'), + help_text=_('Python dotted path to the plugin class, e.g. "rdmo_plugins_provider.module.PluginClass"'), + + ) + plugin_meta = models.JSONField( + blank=True, default=dict, editable=False, + verbose_name=_('Plugin metadata'), + help_text=_('Contains metadata derived from the plugin class.'), + ) + plugin_settings = models.JSONField( + blank=True, default=dict, + verbose_name=_('Plugin settings'), + help_text=_('Contains the settings for this plugin in JSON format.'), + ) + plugin_type = models.SlugField( + max_length=128, blank=True, default="", editable=False, + verbose_name=_('Plugin type'), + help_text=_('The type of plugin this is, e.g. "project_export".') + ) + url_name = models.SlugField( + max_length=128, blank=True, default="", + verbose_name=_('URL name'), + help_text=_('The url_name for this plugin.') + ) + + class Meta: + ordering = ('uri', 'order') + verbose_name = _('Plugin') + verbose_name_plural = _('Plugins') + + def __str__(self): + return self.uri + + def save(self, *args, **kwargs): + self.uri = self.build_uri(self.uri_prefix, self.uri_path) + + try: + plugin_class = self.get_class() + except ImportError as e: + raise RuntimeError(f"Could not import plugin from {self.python_path}: {e}") from e + + try: + self.plugin_type = get_plugin_type_from_class(plugin_class) + except ValueError as e: + raise RuntimeError(f"Could not get plugin type from class {plugin_class}: {e}") from e + + try: + self.initialize_class(plugin_class=plugin_class) + except ValueError as e: + raise RuntimeError(f"Could initialize the plugin from class {plugin_class}: {e}") from e + + self.plugin_meta = self.build_plugin_meta(plugin_class) + + super().save(*args, **kwargs) + + @property + def title(self) -> str: + return self.trans('title') + + @property + def help(self) -> str: + return self.trans('help') + + @property + def is_locked(self): + return self.locked + + @classmethod + def build_uri(cls, uri_prefix, uri_path): + if not uri_path: + raise RuntimeError('uri_path is missing') + return join_url(uri_prefix or settings.DEFAULT_URI_PREFIX, '/plugins/', uri_path) + + @property + def has_search(self): + plugin_search = self.plugin_meta.get('search') + if plugin_search is not None: + return plugin_search + return getattr(self.get_class(), 'search', False) + + @property + def has_refresh(self): + plugin_refresh = self.plugin_meta.get('refresh') + if plugin_refresh is not None: + return plugin_refresh + return getattr(self.get_class(), 'refresh', False) + + @property + def upload_accept(self): + plugin_accept = self.plugin_meta.get('accept') + + if isinstance(plugin_accept, dict): + return { + mime_type: set(suffixes) + for mime_type, suffixes in plugin_accept.items() + } + + if isinstance(plugin_accept, str): + # legacy fallback for pre 2.3.0 RDMO, e.g. `accept = '.xml'` + suffix = plugin_accept + mime_type, _encoding = mimetypes.guess_type(f'example{suffix}') + if mime_type: + return {mime_type: {suffix}} + return {} + + if self.plugin_meta.get('upload') is True: + # if one of the plugins does not have the accept field, but is marked as upload plugin + # all file types are allowed + return None + + return {} + + def get_class(self): + return import_string(self.python_path) + + def build_plugin_meta(self, plugin_class): + # Collect plugin metadata from class attributes. + # Metadata is merged from the plugin class' MRO (so base class defaults are + # included, but subclass overrides win) and only includes attributes that + # are explicitly defined on the plugin class or its BasePlugin ancestors. + meta = {} + mro = plugin_class.mro() + attrs = settings.PLUGIN_META_ATTRIBUTES + + for attr in attrs: + if attr.startswith('distribution_'): + continue + + if any(attr in cls.__dict__ for cls in mro): + meta[attr] = getattr(plugin_class, attr) + + if 'distribution_name' in attrs or 'distribution_version' in attrs: + distribution_name = get_distribution_name_from_class(plugin_class) + + if 'distribution_name' in attrs: + meta['distribution_name'] = distribution_name + + if 'distribution_version' in attrs: + version = get_distribution_version(distribution_name) + meta['distribution_version'] = str(version) if version is not None else None + + return meta + + def initialize_class(self, plugin_class=None): + cls = plugin_class or self.get_class() + sig = signature(cls) + if len(sig.parameters) == 0: + return cls() + if len(sig.parameters) == 2: + if sig.parameters['args'].name == 'args' and sig.parameters['kwargs'].name == 'kwargs': + return cls() + if len(sig.parameters) == 3: # the legacy signature, should not be called anymore + key = self.url_name if self.url_name else self.uri_path + return cls(key, self.title, self.python_path) + raise ValueError(f'Could not initialize class {self.python_path} for {sig}') diff --git a/rdmo/config/plugins.py b/rdmo/config/plugins.py new file mode 100644 index 0000000000..0ef4e9613a --- /dev/null +++ b/rdmo/config/plugins.py @@ -0,0 +1,3 @@ + +class BasePlugin: + pass diff --git a/rdmo/config/renderers/__init__.py b/rdmo/config/renderers/__init__.py new file mode 100644 index 0000000000..9b98efe3f3 --- /dev/null +++ b/rdmo/config/renderers/__init__.py @@ -0,0 +1,20 @@ +from rdmo.core.renderers import BaseXMLRenderer + +from .mixins import PluginRendererMixin + + +class PluginRenderer(PluginRendererMixin, BaseXMLRenderer): + + def render_document(self, xml, plugins): + xml.startElement('rdmo', { + 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', + 'version': self.version, + 'required': self.required, + 'created': self.created + }) + for plugin in plugins: + self.render_plugin(xml, plugin) + xml.endElement('rdmo') + + +__all__ = ['PluginRenderer', 'PluginRendererMixin'] diff --git a/rdmo/config/renderers/mixins.py b/rdmo/config/renderers/mixins.py new file mode 100644 index 0000000000..6c32413d59 --- /dev/null +++ b/rdmo/config/renderers/mixins.py @@ -0,0 +1,32 @@ +import json + +from rdmo.core.utils import get_languages + + +class PluginRendererMixin: + + def render_plugin(self, xml, plugin): + if plugin['uri'] not in self.uris: + self.uris.add(plugin['uri']) + + xml.startElement('plugin', {'dc:uri': plugin['uri']}) + self.render_text_element(xml, 'uri_prefix', {}, plugin['uri_prefix']) + self.render_text_element(xml, 'uri_path', {}, plugin['uri_path']) + self.render_text_element(xml, 'dc:comment', {}, plugin['comment']) + + for lang_code, _lang_string, _lang_field in get_languages(): + self.render_text_element(xml, 'title', {'lang': lang_code}, plugin[f'title_{lang_code}']) + self.render_text_element(xml, 'help', {'lang': lang_code}, plugin[f'help_{lang_code}']) + + self.render_text_element(xml, 'python_path', {}, plugin['python_path']) + + plugin_settings = plugin.get('plugin_settings') + if isinstance(plugin_settings, (dict, list)): + plugin_settings = json.dumps(plugin_settings) + self.render_text_element(xml, 'plugin_settings', {}, plugin_settings) + + self.render_text_element(xml, 'available', {}, plugin['available']) + self.render_text_element(xml, 'locked', {}, plugin['locked']) + self.render_text_element(xml, 'order', {}, plugin['order']) + self.render_text_element(xml, 'url_name', {}, plugin['url_name']) + xml.endElement('plugin') diff --git a/rdmo/config/serializers/__init__.py b/rdmo/config/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/config/serializers/export.py b/rdmo/config/serializers/export.py new file mode 100644 index 0000000000..9d408700cf --- /dev/null +++ b/rdmo/config/serializers/export.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from rdmo.core.serializers import TranslationSerializerMixin + +from ..models import Plugin + + +class PluginExportSerializer(TranslationSerializerMixin, serializers.ModelSerializer): + + class Meta: + model = Plugin + fields = ( + 'uri', + 'uri_prefix', + 'uri_path', + 'comment', + 'available', + 'locked', + 'order', + 'python_path', + 'plugin_settings', + 'url_name', + ) + trans_fields = ( + 'title', + 'help', + ) diff --git a/rdmo/config/serializers/v1.py b/rdmo/config/serializers/v1.py new file mode 100644 index 0000000000..814ae29431 --- /dev/null +++ b/rdmo/config/serializers/v1.py @@ -0,0 +1,90 @@ +from rest_framework import serializers + +from rdmo.core.serializers import ( + ElementModelSerializerMixin, + ElementWarningSerializerMixin, + MarkdownSerializerMixin, + ReadOnlyObjectPermissionSerializerMixin, + TranslationSerializerMixin, +) +from rdmo.core.utils import get_plugin_python_paths + +from ..models import Plugin +from ..validators import ( + PluginLockedValidator, + PluginPythonPathValidator, + PluginUniqueURIValidator, + PluginURLNameValidator, +) + + +class PluginSerializer(TranslationSerializerMixin, ElementModelSerializerMixin, + ElementWarningSerializerMixin, ReadOnlyObjectPermissionSerializerMixin, + MarkdownSerializerMixin, serializers.ModelSerializer): + + markdown_fields = ('title', 'text', 'help') + + model = serializers.SerializerMethodField() + + warning = serializers.SerializerMethodField() + read_only = serializers.SerializerMethodField() + + python_path = serializers.ChoiceField(choices=get_plugin_python_paths()) + plugin_type = serializers.SerializerMethodField(read_only=True) + plugin_meta = serializers.JSONField(read_only=True) + + class Meta: + model = Plugin + fields = ( + 'id', + 'model', + 'uri', + 'uri_prefix', + 'uri_path', + 'url_name', + 'comment', + 'locked', + 'order', + 'available', + 'python_path', + 'plugin_type', + 'plugin_settings', + 'plugin_meta', + 'catalogs', + 'sites', + 'editors', + 'groups', + 'title', + 'help', + 'warning', + 'read_only', + ) + trans_fields = ( + 'title', + 'help', + ) + extra_kwargs = { + 'uri_path': {'required': True} + } + validators = ( + PluginUniqueURIValidator(), + PluginLockedValidator(), + PluginPythonPathValidator(), + PluginURLNameValidator(), + ) + warning_fields = ( + 'title', + 'help', + ) + + def get_plugin_type(self, obj) -> str: + return obj.plugin_type + +class PluginIndexSerializer(serializers.ModelSerializer): + + class Meta: + model = Plugin + fields = ( + 'id', + 'uri' + ) diff --git a/rdmo/config/templates/config/export/plugin.html b/rdmo/config/templates/config/export/plugin.html new file mode 100644 index 0000000000..ae395d494f --- /dev/null +++ b/rdmo/config/templates/config/export/plugin.html @@ -0,0 +1,13 @@ +{% load i18n %} + +
  • +

    + {% trans 'URI' %}: {{ plugin.uri }} +

    +

    + {% trans 'Title' %}: {{ plugin.title }} +

    +

    + {% trans 'Help' %}: {{ plugin.help }} +

    +
  • diff --git a/rdmo/config/templates/config/export/plugins.html b/rdmo/config/templates/config/export/plugins.html new file mode 100644 index 0000000000..7200a669fb --- /dev/null +++ b/rdmo/config/templates/config/export/plugins.html @@ -0,0 +1,52 @@ +{% extends 'core/export.html' %} +{% load i18n %} + +{% block body %} + +

    {% trans 'Plugins' %}

    + + + +{% endblock %} \ No newline at end of file diff --git a/rdmo/config/tests/__init__.py b/rdmo/config/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/config/tests/conftest.py b/rdmo/config/tests/conftest.py new file mode 100644 index 0000000000..02095e3a2e --- /dev/null +++ b/rdmo/config/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest + +from django.utils.translation import gettext_lazy as _ + + +@pytest.fixture +def enable_legacy_plugins(settings): + + settings.PLUGINS = [] + # settings.INSTALLED_APPS has 'plugins' already + # insert the relevant legacy plugin-tuples in settings, + # e.g. PROJECT_EXPORTS, PROJECT_IMPORTS, OPTIONSET_PROVIDERS + # to support the backwards compatible settings + settings.PROJECT_EXPORTS = [ + ('xml', _('RDMO XML'), 'rdmo.projects.exports.RDMOXMLExport'), + ('csvcomma', _('CSV (comma separated)'), 'rdmo.projects.exports.CSVCommaExport'), + ('csvsemicolon', _('CSV (semicolon separated)'), 'rdmo.projects.exports.CSVSemicolonExport'), + ('json', _('JSON'), 'rdmo.projects.exports.JSONExport'), + ("simple_export", "Test Export", "plugins.project_export.exports.SimpleExportPlugin"), + ] + settings.PROJECT_IMPORTS = [ + ('xml', _('RDMO XML'), 'rdmo.projects.imports.RDMOXMLImport'), + ('url', _('from URL'), 'rdmo.projects.imports.URLImport'), + ] + + settings.PROJECT_SNAPSHOT_EXPORTS = [ + ("simple_snapshot_export", "Snapshot RDMO XML", "plugins.project_snapshot_export.exports.SimpleSnapshotExportPlugin"), # noqa: E501 + ] + + settings.OPTIONSET_PROVIDERS = [ + ("simple_optionset_provider", "Simple OptionSet Provider", "plugins.optionset_providers.providers.SimpleProvider"), # noqa: E501 + ] + + settings.PROJECT_ISSUE_PROVIDERS = [ + ("simple_issue_provider", "Simple Issue Provider", "plugins.project_snapshot_export.exports.SimpleIssueProvider"), # noqa: E501 + ] + + return settings diff --git a/rdmo/config/tests/test_admin.py b/rdmo/config/tests/test_admin.py new file mode 100644 index 0000000000..00a355885e --- /dev/null +++ b/rdmo/config/tests/test_admin.py @@ -0,0 +1,7 @@ +from django.urls import reverse + + +def test_plugin_search(admin_client): + url = reverse('admin:config_plugin_changelist') + '?q=test' + response = admin_client.get(url) + assert response.status_code == 200 diff --git a/rdmo/config/tests/test_checks.py b/rdmo/config/tests/test_checks.py new file mode 100644 index 0000000000..e6bd615331 --- /dev/null +++ b/rdmo/config/tests/test_checks.py @@ -0,0 +1,109 @@ + +from django.core.checks import Warning + +from rdmo.config.checks import deprecated_plugin_settings_check, plugins_importable_check, repr_new_settings + +LEGACY_KEYS = [ + "PROJECT_EXPORTS", + "PROJECT_SNAPSHOT_EXPORTS", + "PROJECT_IMPORTS", + "PROJECT_ISSUE_PROVIDERS", + "PROJECT_IMPORTS_LIST", + "OPTIONSET_PROVIDERS", +] + + +def _clear_legacy_settings(settings): + """ + Helper to make sure none of the legacy settings are set. + This keeps the tests independent from the global test settings. + """ + for key in [*LEGACY_KEYS]: + if hasattr(settings, key): + delattr(settings, key) + + +def test_deprecated_plugin_settings_no_legacy_returns_empty(settings): + """ + When no legacy plugin settings are defined, the check must be silent. + """ + _clear_legacy_settings(settings) + + issues = deprecated_plugin_settings_check(app_configs=None) + + assert issues == [] + + +def test_deprecated_plugin_settings_with_legacy_only(enable_legacy_plugins, settings): + """ + With only legacy settings configured, we expect a single W001 warning + and a hint that includes the PLUGINS example with python paths. + """ + + issues = deprecated_plugin_settings_check(app_configs=None) + + assert len(issues) == 1 + issue = issues[0] + + assert isinstance(issue, Warning) + assert issue.id == "rdmo.config.W001" + + # the message mentions the deprecated keys + assert "deprecated as of RDMO 2.5.0" in issue.msg + assert "PROJECT_EXPORTS" in issue.msg + + # the hint uses repr_new_settings(...) and should contain python paths + # from the legacy tuples defined in enable_legacy_plugins + assert "PLUGINS" in issue.hint + assert "plugins.project_export.exports.SimpleExportPlugin" in issue.hint + + +def test_deprecated_plugin_settings_with_legacy_and_plugins(enable_legacy_plugins, settings): + """ + If legacy settings AND PLUGINS are set, we expect W001 + W002. + """ + # PLUGINS exists in addition to the legacy settings + settings.PLUGINS = ["plugins.project_export.exports.SimpleExportPlugin"] + + issues = deprecated_plugin_settings_check(app_configs=None) + + ids = {issue.id for issue in issues} + assert ids == {"rdmo.config.W001", "rdmo.config.W002"} + + w2 = next(i for i in issues if i.id == "rdmo.config.W002") + assert "ignored" in w2.msg.lower() + assert "Remove the following legacy settings" in w2.hint + + +def test_repr_new_settings_formats_single_and_multiple_paths(): + """ + repr_new_settings should produce a sensible PLUGINS example + for 0, 1 and multiple python paths. + """ + assert repr_new_settings(set()) == "" + + single = repr_new_settings({"a.b.Class"}) + assert "PLUGINS" in single + assert "a.b.Class" in single + + multi = repr_new_settings({"a.b.Class", "c.d.Other"}) + # multi-line list with both paths present + assert "PLUGINS" in multi + assert "a.b.Class" in multi + assert "c.d.Other" in multi + + +def test_plugins_importable_check_reports_invalid_plugin(settings): + _clear_legacy_settings(settings) + + settings.PLUGINS = [ + "plugins.project_export.exports.SimpleExportPlugin", + "plugins.plugin.does.not.Exist", + ] + + issues = plugins_importable_check(app_configs=None) + + assert len(issues) == 1 + issue = issues[0] + assert isinstance(issue, Warning) + assert issue.id == "rdmo.config.W003" diff --git a/rdmo/config/tests/test_commands.py b/rdmo/config/tests/test_commands.py new file mode 100644 index 0000000000..36fbfad1d1 --- /dev/null +++ b/rdmo/config/tests/test_commands.py @@ -0,0 +1,155 @@ +import io +import sys +import types + +import pytest + +from django.core.management import CommandError, call_command + +from rdmo.config.models import Plugin + + +@pytest.mark.parametrize('use_new_setting', [True, False]) +def test_checks(settings, enable_legacy_plugins, use_new_setting): + # arrange + if use_new_setting: + settings.PLUGINS = ['rdmo.projects.exports.RDMOXMLExport'] + + stdout, stderr = io.StringIO(), io.StringIO() + + # will trigger all registered system checks, including ours + call_command("check", stdout=stdout, stderr=stderr) + + output = stdout.getvalue() + stderr.getvalue() + assert "deprecated as of RDMO 2.5.0" in output + assert "rdmo.config.W001" in output + if use_new_setting: + assert "rdmo.config.W002" in output + else: + assert "rdmo.config.W002" not in output + + +@pytest.mark.parametrize('clear_first', [True, False]) +def test_command_check_plugins(db, settings, clear_first): + # arrange + if clear_first: + Plugin.objects.all().delete() + + instances = Plugin.objects.all() + + stdout, stderr = io.StringIO(), io.StringIO() + call_command('check_plugins', stdout=stdout, stderr=stderr) + + if clear_first: + assert not instances + assert "No plugins found." in stdout.getvalue() + else: + assert instances + python_paths = instances.values_list('python_path', flat=True) + assert all(i in stdout.getvalue() for i in python_paths) + + +@pytest.mark.parametrize('clear_first', [True, False]) +@pytest.mark.parametrize('dry_run', [True, False]) +def test_command_setup_plugins_basic_from_settings(db, settings, clear_first, dry_run): + # arrange: ensure something exists when clear_first is False + if clear_first: + Plugin.objects.all().delete() + + stdout, stderr = io.StringIO(), io.StringIO() + args = ("--dry-run",) if dry_run else [] + call_command('setup_plugins', *args, stdout=stdout, stderr=stderr) + + instances = Plugin.objects.all() + + if dry_run: + assert "dry-run complete" in stdout.getvalue().lower() + if clear_first: + assert instances.count() == 0 + else: + assert instances.count() >= 1 + else: + assert instances.count() >= 1 + + + +def _install_dummy_plugin(monkeypatch, dotted: str = "dummy_mod.DummyPlugin", **attrs) -> str: + """ + Create a dummy importable plugin class at the dotted path. + Returns the dotted path string for convenience. + """ + module_name, class_name = dotted.rsplit(".", 1) + mod = types.ModuleType(module_name) + cls = type(class_name, (), {"__module__": module_name}) + # sensible defaults that the command can read if it imports the class + cls.key = attrs.get("key", "dummy_key") + cls.label = attrs.get("label", "Dummy Label") + cls.plugin_type = attrs.get("plugin_type", "dummy_type") + mod.__dict__[class_name] = cls + monkeypatch.setitem(sys.modules, module_name, mod) + return dotted + + +def test_setup_plugins_dry_run_never_raises(db, settings, monkeypatch): + dotted = _install_dummy_plugin( + monkeypatch, "dummy_mod.ExamplePlugin", key="monkeypatch_plugin", label="MonkeyPatchPlugin" + ) + # IMPORTANT: do not mutate the setting list in-place + settings.PLUGINS = [*settings.PLUGINS, dotted] + count_before = Plugin.objects.count() + + stdout, stderr = io.StringIO(), io.StringIO() + # should NOT raise CommandError now + call_command("setup_plugins", "--dry-run", stdout=stdout, stderr=stderr) + + out = stdout.getvalue().lower() + assert "dry-run complete" in out + # and DB stays untouched + assert Plugin.objects.count() == count_before + + +def test_setup_plugins_merge_legacy_prioritizes_legacy_and_dedupes(db, settings, monkeypatch): + dotted = _install_dummy_plugin(monkeypatch, "dummy.mod.ExamplePlugin", key="example", label="Imported Label") + # legacy tuple wins (compat), PLUGINS contains the same python_path + settings.PROJECT_EXPORTS = [('example', 'Legacy Label', dotted)] + settings.PLUGINS = [dotted] + + stdout, stderr = io.StringIO(), io.StringIO() + call_command("setup_plugins", stdout=stdout, stderr=stderr) + + # exactly one instance, with the legacy title + queryset = Plugin.objects.filter(python_path=dotted) + assert queryset.count() == 1 + instance = queryset.get() + assert instance.title_lang1 == "Legacy Label" + + +def test_setup_plugins_clear_with_dry_run_keeps_rows(db, settings, monkeypatch): + count_before = Plugin.objects.count() + assert count_before >= 1 + + stdout = io.StringIO() + call_command("setup_plugins", "--clear", "--dry-run", stdout=stdout, stderr=io.StringIO()) + + # dry-run clear should not delete anything + assert Plugin.objects.count() == count_before + assert "about to clear" in stdout.getvalue().lower() + assert "dry-run" in stdout.getvalue().lower() + + +def test_setup_plugins_validate_failure(db, settings): + # non-importable path will fail validation + settings.PLUGINS = ["nope.this.module.DoesNotExist"] + + with pytest.raises(CommandError): + call_command("setup_plugins", stdout=io.StringIO(), stderr=io.StringIO()) + + assert not Plugin.objects.filter(python_path="nope.this.module.DoesNotExist").exists() + + +def test_setup_plugins_clear_only_dry_run_is_success_no_raise(db): + # nothing configured; --clear --dry-run should print success and not raise + stdout = io.StringIO() + call_command("setup_plugins", "--clear", "--dry-run", stdout=stdout, stderr=io.StringIO()) + txt = stdout.getvalue().lower() + assert "dry-run complete" in txt diff --git a/rdmo/config/tests/test_models.py b/rdmo/config/tests/test_models.py new file mode 100644 index 0000000000..4e2a0bd48a --- /dev/null +++ b/rdmo/config/tests/test_models.py @@ -0,0 +1,13 @@ +from ..models import Plugin + + +def test_plugin_str(db): + instances = Plugin.objects.all() + for instance in instances: + assert str(instance) + + +def test_plugin_clean(db): + instances = Plugin.objects.all() + for instance in instances: + instance.clean() diff --git a/rdmo/config/tests/test_plugins.py b/rdmo/config/tests/test_plugins.py new file mode 100644 index 0000000000..bbe8ff7955 --- /dev/null +++ b/rdmo/config/tests/test_plugins.py @@ -0,0 +1,83 @@ +from importlib import metadata + +import pytest + +from rdmo.config.constants import PLUGIN_TYPES +from rdmo.config.models import Plugin +from rdmo.config.utils import get_plugin_type_from_class, get_plugins_from_settings +from rdmo.options.providers import Provider +from rdmo.projects.exports import Export +from rdmo.projects.imports import Import +from rdmo.projects.models import Project +from rdmo.projects.providers import IssueProvider + + +def test_get_plugin_types_from_internal_plugins(): + assert get_plugin_type_from_class(Export) == PLUGIN_TYPES.PROJECT_EXPORT + assert get_plugin_type_from_class(Import) == PLUGIN_TYPES.PROJECT_IMPORT + assert get_plugin_type_from_class(IssueProvider) == PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER + assert get_plugin_type_from_class(Provider) == PLUGIN_TYPES.OPTIONSET_PROVIDER + +@pytest.mark.django_db +def test_plugin_create_and_render(): + # Arrange: create the Plugin model instance + project = Project.objects.get(id=1) + instance = Plugin.objects.create( + uri_prefix="https://example.org/terms", + uri_path="test-plugins-export", + python_path="plugins.project_export.exports.SimpleExportPlugin", + title_lang1="Test Export Plugin", + title_lang2="Test Export Plugin(lang2)", + available=True, + plugin_settings={"foo": "bar"}, + ) + + # get class and initialize like a legacy style plugin + export_plugin = instance.initialize_class() + export_plugin.project = project + export_plugin.snapshot = None + # Call export (render) and assert behavior + assert instance.plugin_type == PLUGIN_TYPES.PROJECT_EXPORT + response = export_plugin.render() + assert response.status_code == 200 + text = response.content.decode() + assert text,"response of test export plugin is empty" + + +@pytest.mark.django_db +def test_plugin_save_sets_issue_provider_type(): + instance = Plugin.objects.create( + uri_prefix="https://example.org/terms", + uri_path="test-plugins-issue-provider", + python_path="plugins.project_issue_providers.providers.SimpleIssueProvider", + title_lang1="Test Issue Provider", + title_lang2="Test Issue Provider(lang2)", + available=True, + plugin_settings={"foo": "bar"}, + ) + + plugin = Plugin.objects.get(pk=instance.pk) + assert plugin.plugin_type == PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER + +def test_get_plugins_from_settings_uses_default_uri_prefix(settings): + + plugins = get_plugins_from_settings() + for plugin in plugins: + if plugin['python_path'].startswith('plugins.'): + assert plugin["uri_prefix"] == "https://rdmorganiser.github.io/terms" + +def test_build_plugin_meta_includes_distribution_version(settings, monkeypatch): + settings.PLUGIN_META_ATTRIBUTES = ('distribution_name', 'distribution_version') + + class MockPlugin: + __module__ = 'mocked_package.plugin' + + monkeypatch.setattr( + metadata, + 'packages_distributions', + lambda: {'mocked_package': ['mocked-dist']}, + ) + monkeypatch.setattr(metadata, 'version', lambda name: '0.0.1') + + plugin = Plugin() + assert plugin.build_plugin_meta(MockPlugin) == {'distribution_name': 'mocked-dist', 'distribution_version': '0.0.1'} diff --git a/rdmo/config/tests/test_validator_locked.py b/rdmo/config/tests/test_validator_locked.py new file mode 100644 index 0000000000..0a64f8c6d0 --- /dev/null +++ b/rdmo/config/tests/test_validator_locked.py @@ -0,0 +1,123 @@ +import pytest + +from django.core.exceptions import ValidationError + +from rest_framework.exceptions import ValidationError as RestFrameworkValidationError + +from ..models import Plugin +from ..serializers.v1 import PluginSerializer +from ..validators import PluginLockedValidator + + +def test_create(db): + PluginLockedValidator()({ + 'locked': False + }) + + +def test_create_locked(db): + PluginLockedValidator()({ + 'locked': True + }) + + +def test_update(db): + plugin = Plugin.objects.first() + + PluginLockedValidator(plugin)({ + 'locked': False + }) + + +def test_update_error(db): + plugin = Plugin.objects.first() + plugin.locked = True + plugin.save() + + with pytest.raises(ValidationError): + PluginLockedValidator(plugin)({ + 'locked': True + }) + + +def test_update_lock(db): + plugin = Plugin.objects.first() + + PluginLockedValidator(plugin)({ + 'locked': True + }) + + +def test_update_unlock(db): + plugin = Plugin.objects.first() + plugin.locked = True + plugin.save() + + PluginLockedValidator(plugin)({ + 'locked': False + }) + + +def test_serializer_create(db): + validator = PluginLockedValidator() + serializer = PluginSerializer() + + validator({ + 'locked': False + }, serializer) + + +def test_serializer_create_locked(db): + validator = PluginLockedValidator() + serializer = PluginSerializer() + + validator({ + 'locked': True + }, serializer) + + +def test_serializer_update(db): + plugin = Plugin.objects.first() + + validator = PluginLockedValidator() + serializer = PluginSerializer(instance=plugin) + + validator({}, serializer) + + +def test_serializer_update_error(db): + plugin = Plugin.objects.first() + plugin.locked = True + plugin.save() + + validator = PluginLockedValidator() + serializer = PluginSerializer(instance=plugin) + + with pytest.raises(RestFrameworkValidationError): + validator({ + 'locked': True + }, serializer) + + +def test_serializer_update_lock(db): + plugin = Plugin.objects.first() + + validator = PluginLockedValidator() + serializer = PluginSerializer(instance=plugin) + + validator({ + 'locked': True + }, serializer) + + +def test_serializer_update_unlock(db): + plugin = Plugin.objects.first() + plugin.locked = True + plugin.save() + + validator = PluginLockedValidator() + serializer = PluginSerializer(instance=plugin) + + validator({ + 'locked': False + }, serializer) diff --git a/rdmo/config/tests/test_validator_unique_uri.py b/rdmo/config/tests/test_validator_unique_uri.py new file mode 100644 index 0000000000..c2f600f474 --- /dev/null +++ b/rdmo/config/tests/test_validator_unique_uri.py @@ -0,0 +1,90 @@ +import pytest + +from django.conf import settings +from django.core.exceptions import ValidationError + +from rest_framework.exceptions import ValidationError as RestFrameworkValidationError + +from ..models import Plugin +from ..serializers.v1 import PluginSerializer +from ..validators import PluginUniqueURIValidator + + +def test_unique_uri_validator_create(db): + PluginUniqueURIValidator()({ + 'uri_prefix': settings.DEFAULT_URI_PREFIX, + 'uri_path': 'test' + }) + + +def test_unique_uri_validator_create_error(db): + with pytest.raises(ValidationError): + PluginUniqueURIValidator()({ + 'uri_prefix': settings.DEFAULT_URI_PREFIX, + 'uri_path': Plugin.objects.filter(uri_prefix=settings.DEFAULT_URI_PREFIX).last().uri_path + }) + + +def test_unique_uri_validator_update(db): + plugin = Plugin.objects.first() + + PluginUniqueURIValidator(plugin)({ + 'uri_prefix': plugin.uri_prefix, + 'uri_path': plugin.uri_path + }) + + +def test_unique_uri_validator_update_error(db): + plugin = Plugin.objects.filter(uri_prefix=settings.DEFAULT_URI_PREFIX).first() + + with pytest.raises(ValidationError): + PluginUniqueURIValidator(plugin)({ + 'uri_prefix': plugin.uri_prefix, + 'uri_path': Plugin.objects.filter(uri_prefix=settings.DEFAULT_URI_PREFIX).last().uri_path + }) + + +def test_unique_uri_validator_serializer_create(db): + validator = PluginUniqueURIValidator() + serializer = PluginSerializer() + + validator({ + 'uri_prefix': settings.DEFAULT_URI_PREFIX, + 'uri_path': 'test' + }, serializer) + + +def test_unique_uri_validator_serializer_create_error(db): + validator = PluginUniqueURIValidator() + serializer = PluginSerializer() + + with pytest.raises(RestFrameworkValidationError): + validator({ + 'uri_prefix': settings.DEFAULT_URI_PREFIX, + 'uri_path': Plugin.objects.filter(uri_prefix=settings.DEFAULT_URI_PREFIX).last().uri_path + }, serializer) + + +def test_unique_uri_validator_serializer_update(db): + plugin = Plugin.objects.first() + + validator = PluginUniqueURIValidator() + serializer = PluginSerializer(instance=plugin) + + validator({ + 'uri_prefix': plugin.uri_prefix, + 'uri_path': plugin.uri_path + }, serializer) + + +def test_unique_uri_validator_serializer_update_error(db): + plugin = Plugin.objects.filter(uri_prefix=settings.DEFAULT_URI_PREFIX).first() + + validator = PluginUniqueURIValidator() + serializer = PluginSerializer(instance=plugin) + + with pytest.raises(RestFrameworkValidationError): + validator({ + 'uri_prefix': plugin.uri_prefix, + 'uri_path': Plugin.objects.filter(uri_prefix=settings.DEFAULT_URI_PREFIX).last().uri_path + }, serializer) diff --git a/rdmo/config/tests/test_viewset_plugin.py b/rdmo/config/tests/test_viewset_plugin.py new file mode 100644 index 0000000000..eb6faa12c8 --- /dev/null +++ b/rdmo/config/tests/test_viewset_plugin.py @@ -0,0 +1,216 @@ + +import xml.etree.ElementTree as et + +import pytest + +from django.urls import reverse + +from ..models import Plugin + +users = ( + ('editor', 'editor'), + ('reviewer', 'reviewer'), + ('user', 'user'), + ('api', 'api'), + ('anonymous', None), +) + +status_map = { + 'list': { + 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401 + }, + 'detail': { + 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401 + }, + 'create': { + 'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401 + }, + 'update': { + 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401 + }, + 'delete': { + 'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401 + } +} + +urlnames = { + 'list': 'v1-config:plugin-list', + 'index': 'v1-config:plugin-index', + 'detail': 'v1-config:plugin-detail', + 'export': 'v1-config:plugin-export', + 'detail_export': 'v1-config:plugin-detail-export', +} + +export_formats = ('xml', 'html') + + +@pytest.mark.parametrize('username,password', users) +def test_list(db, client, username, password): + client.login(username=username, password=password) + + url = reverse(urlnames['list']) + response = client.get(url) + assert response.status_code == status_map['list'][username], response.json() + + +@pytest.mark.parametrize('username,password', users) +def test_index(db, client, username, password): + client.login(username=username, password=password) + + url = reverse(urlnames['index']) + response = client.get(url) + assert response.status_code == status_map['list'][username], response.json() + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('export_format', export_formats) +def test_export(db, client, username, password, export_format): + client.login(username=username, password=password) + + url = reverse(urlnames['export']) + export_format + '/' + response = client.get(url) + assert response.status_code == status_map['list'][username], response.content + + if response.status_code == 200 and export_format == 'xml': + root = et.fromstring(response.content) + assert root.tag == 'rdmo' + for child in root: + assert child.tag in ['plugin'] + + +def test_export_search(db, client): + client.login(username='editor', password='editor') + + url = reverse(urlnames['export']) + 'xml/?search=testing' + response = client.get(url) + assert response.status_code == status_map['list']['editor'], response.content + + +@pytest.mark.parametrize('username,password', users) +def test_detail(db, client, username, password): + client.login(username=username, password=password) + instances = Plugin.objects.all() + + for instance in instances: + url = reverse(urlnames['detail'], args=[instance.pk]) + response = client.get(url) + assert response.status_code == status_map['detail'][username], response.json() + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('export_format', export_formats) +def test_detail_export(db, client, username, password, export_format): + client.login(username=username, password=password) + instance = Plugin.objects.first() + + url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/' + response = client.get(url) + assert response.status_code == status_map['detail'][username], response.content + + if response.status_code == 200 and export_format == 'xml': + root = et.fromstring(response.content) + assert root.tag == 'rdmo' + for child in root: + assert child.tag in ['plugin'] + + +@pytest.mark.parametrize('username,password', users) +def test_create(db, client, username, password): + client.login(username=username, password=password) + instances = Plugin.objects.all() + + for instance in instances: + url = reverse(urlnames['list']) + data = { + 'uri_prefix': instance.uri_prefix, + 'uri_path': f'{instance.uri_path}_new_{username}', + 'comment': instance.comment, + 'title_en': instance.title_lang1, + 'title_de': instance.title_lang2, + 'help_en': instance.help_lang1, + 'help_de': instance.help_lang2, + 'python_path': instance.python_path, + } + response = client.post(url, data) + assert response.status_code == status_map['create'][username], response.json() + + +@pytest.mark.parametrize('username,password', users) +def test_update(db, client, username, password): + client.login(username=username, password=password) + instances = Plugin.objects.all() + + for instance in instances: + url = reverse(urlnames['detail'], args=[instance.pk]) + data = { + 'uri_prefix': instance.uri_prefix, + 'uri_path': instance.uri_path, + 'comment': instance.comment, + 'title_en': instance.title_lang1, + 'title_de': instance.title_lang2, + 'help_en': instance.help_lang1, + 'help_de': instance.help_lang2, + 'python_path': instance.python_path, + } + response = client.put(url, data, content_type='application/json') + assert response.status_code == status_map['update'][username], response.json() + + +@pytest.mark.parametrize('python_path', [ + 'rdmo.projects.exports.JSONExport', + 'this.python.path.DoesNotExist', +]) +@pytest.mark.parametrize('is_in_plugins', [ True, False ]) +def test_update_python_path(db, client, settings, python_path, is_in_plugins): + client.login(username='editor', password='editor') + instance = Plugin.objects.filter(python_path__contains='XMLExport').first() + assert instance.python_path != python_path # check for arrangement + if is_in_plugins: + settings.PLUGINS = [instance.python_path, python_path] + assert python_path in settings.PLUGINS # check for arrangement + else: + settings.PLUGINS = [instance.python_path] + assert python_path not in settings.PLUGINS # check for arrangement + + url = reverse(urlnames['detail'], args=[instance.pk]) + data = { + 'uri_prefix': instance.uri_prefix, + 'uri_path': instance.uri_path, + 'comment': instance.comment, + 'title_en': instance.title_lang1, + 'title_de': instance.title_lang2, + 'help_en': instance.help_lang1, + 'help_de': instance.help_lang2, + 'python_path': python_path, + } + + response = client.put(url, data, content_type='application/json') + if 'DoesNotExist' in python_path: + assert response.status_code == 400, response.json() + assert "not a valid choice" in response.json()['python_path'][0] + + instance.refresh_from_db() + assert instance.python_path == instance.python_path # nothing changed + elif is_in_plugins: + assert response.status_code == 200, response.json() + assert response.json()['python_path'] == python_path + + instance.refresh_from_db() + assert instance.python_path == python_path + else: + assert response.status_code == 400, response.json() + assert 'This path is not in the configured paths.' in response.json()['python_path'] + + instance.refresh_from_db() + assert instance.python_path == instance.python_path # nothing changed + + +@pytest.mark.parametrize('username,password', users) +def test_delete(db, client, username, password): + client.login(username=username, password=password) + instances = Plugin.objects.all() + + for instance in instances: + url = reverse(urlnames['detail'], args=[instance.pk]) + response = client.delete(url) + assert response.status_code == status_map['delete'][username], response.json() diff --git a/rdmo/config/urls/__init__.py b/rdmo/config/urls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/config/urls/v1.py b/rdmo/config/urls/v1.py new file mode 100644 index 0000000000..7da1921b99 --- /dev/null +++ b/rdmo/config/urls/v1.py @@ -0,0 +1,14 @@ +from django.urls import include, path + +from rest_framework import routers + +from ..viewsets import PluginViewSet + +app_name = 'v1-config' + +router = routers.DefaultRouter() +router.register(r'plugins', PluginViewSet, basename='plugin') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/rdmo/config/utils.py b/rdmo/config/utils.py new file mode 100644 index 0000000000..b257f8f4a4 --- /dev/null +++ b/rdmo/config/utils.py @@ -0,0 +1,87 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.module_loading import import_string +from django.utils.translation import gettext_lazy as _ + +PLUGINS_URL_NAMES = { + "rdmo.projects.exports.RDMOXMLExport": "xml", + "rdmo.projects.exports.CSVCommaExport": "csvcomma", + "rdmo.projects.exports.CSVSemicolonExport": "csvsemicolon", + "rdmo.projects.exports.JSONExport": "json", + "rdmo.projects.imports.RDMOXMLImport": "xml", +} + + +def get_plugin_type_from_class(plugin_class) -> str: + try: + if plugin_class.plugin_type: + return plugin_class.plugin_type + else: + raise ValueError("Plugin type defined but empty.") from None + except AttributeError as e: + raise ValueError("Plugin type missing.") from e + + +def get_plugins_from_settings() -> list[dict]: + """ + Read python paths from settings.PLUGINS and infer key/title. + Try to import the class to obtain nicer metadata when available. + """ + if not settings.PLUGINS: + return [] + + plugin_definitions = [] + errors = [] + for python_path in settings.PLUGINS: + try: + plugin_class = import_string(python_path) + except ImportError as e: + errors.append(_("Could not import plugin from %(path)s: %(err)s") % { + "path": python_path, + "err": str(e), + }) + continue + + try: + plugin_type = get_plugin_type_from_class(plugin_class) + except ValueError as e: + errors.append(_("Could not get plugin type from %(path)s: %(err)s") % { + "path": python_path, + "err": str(e), + }) + continue + + url_name = ( + PLUGINS_URL_NAMES.get(python_path) + or getattr(plugin_class, "url_name", None) + or getattr(plugin_class, "key", None) + ) + uri_path = ( + getattr(plugin_class, "uri_path", None) + or getattr(plugin_class, "key", None) + or url_name or plugin_class.__name__.lower() + ) + uri_prefix = ( + getattr(plugin_class, "uri_prefix", None) + or getattr(plugin_class, "default_uri_prefix", None) + or settings.DEFAULT_URI_PREFIX + ) + title = ( + getattr(plugin_class, "title", "") + or getattr(plugin_class, "label", "") + or plugin_class.__name__ + ) + + plugin_definitions.append({ + "title": title, + "python_path": python_path, + "uri_prefix": uri_prefix, + "uri_path": uri_path, + "plugin_type": plugin_type, + "url_name": url_name, + }) + + if errors: + raise ValidationError(errors) + + return plugin_definitions diff --git a/rdmo/config/validators.py b/rdmo/config/validators.py new file mode 100644 index 0000000000..325c7efc3d --- /dev/null +++ b/rdmo/config/validators.py @@ -0,0 +1,74 @@ +from django.utils.module_loading import import_string +from django.utils.translation import gettext_lazy as _ + +from rdmo.core.utils import get_plugin_python_paths +from rdmo.core.validators import InstanceValidator, LockedValidator, UniqueURIValidator + +from .constants import PLUGIN_TYPES +from .models import Plugin +from .utils import get_plugin_type_from_class + + +class PluginUniqueURIValidator(UniqueURIValidator): + model = Plugin + + +class PluginLockedValidator(LockedValidator): + pass + + +class PluginPythonPathValidator(InstanceValidator): + + model = Plugin + + def __call__(self, data, serializer=None): + super().__call__(data, serializer) + + available_plugin_paths = get_plugin_python_paths() + if not available_plugin_paths: + self.raise_validation_error({ + 'python_path': _("There are no python paths for plugins available.") + }) + + if data.get('python_path') not in available_plugin_paths: + self.raise_validation_error({ + 'python_path': _("This path is not in the configured paths.") + }) + + if self.instance and self.instance.available: + try: # a double-check, maybe not needed + import_string(data.get('python_path')) + except (ModuleNotFoundError, ImportError): + self.raise_validation_error({ + 'python_path': _("This path could not be not imported.") + }) + + +class PluginURLNameValidator(InstanceValidator): + + model = Plugin + + def __call__(self, data, serializer=None): + super().__call__(data, serializer) + + if self.instance: + if self.instance.plugin_type: + plugin_type = self.instance.plugin_type + else: + plugin_type = get_plugin_type_from_class(self.instance.get_class()) + else: + plugin_type = data.get('plugin_type') + + try: + plugin_type = PLUGIN_TYPES(plugin_type) + except ValueError: + return + + if plugin_type == PLUGIN_TYPES.PROJECT_IMPORT: + url_name = data.get('url_name') + if url_name is None and self.instance: + url_name = self.instance.url_name + if not url_name: + self.raise_validation_error({ + 'url_name': _("This field is required for project import plugins.") + }) diff --git a/rdmo/config/viewsets.py b/rdmo/config/viewsets.py new file mode 100644 index 0000000000..95933c159f --- /dev/null +++ b/rdmo/config/viewsets.py @@ -0,0 +1,66 @@ +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from django_filters.rest_framework import DjangoFilterBackend + +from rdmo.core.exports import XMLResponse +from rdmo.core.filters import SearchFilter +from rdmo.core.permissions import HasModelPermission, HasObjectPermission +from rdmo.core.utils import render_to_format +from rdmo.management.viewsets import ElementToggleCurrentSiteViewSetMixin + +from .models import Plugin +from .renderers import PluginRenderer +from .serializers.export import PluginExportSerializer +from .serializers.v1 import PluginIndexSerializer, PluginSerializer + + +class PluginViewSet(ElementToggleCurrentSiteViewSetMixin, ModelViewSet): + permission_classes = (HasModelPermission | HasObjectPermission, ) + serializer_class = PluginSerializer + queryset = ( + Plugin + .objects + .prefetch_related('catalogs', 'sites', 'editors', 'groups') + .order_by('uri') + ) + + filter_backends = (SearchFilter, DjangoFilterBackend) + search_fields = ('uri', 'title') + filterset_fields = ( + 'uri', + 'uri_prefix', + 'uri_path', + 'comment', + 'plugin_type', + 'sites', + 'editors' + ) + + @action(detail=False) + def index(self, request): + queryset = self.filter_queryset(self.get_queryset()) + serializer = PluginIndexSerializer(queryset, many=True) + return Response(serializer.data) + @action(detail=False, url_path='export(?:/(?P[a-z]+))?') + def export(self, request, export_format='xml'): + queryset = self.filter_queryset(self.get_queryset()) + if export_format == 'xml': + serializer = PluginExportSerializer(queryset, many=True) + xml = PluginRenderer().render(serializer.data) + return XMLResponse(xml, name='plugins') + return render_to_format(self.request, export_format, 'plugins', 'config/export/plugins.html', { + 'plugins': queryset + }) + + @action(detail=True, url_path='export(?:/(?P[a-z]+))?') + def detail_export(self, request, pk=None, export_format='xml'): + if export_format == 'xml': + serializer = PluginExportSerializer(self.get_object()) + xml = PluginRenderer().render([serializer.data]) + return XMLResponse(xml, name=self.get_object().uri_path) + return render_to_format(self.request, export_format, self.get_object().uri_path, + 'config/export/plugins.html', { + 'plugins': [self.get_object()] + }) diff --git a/rdmo/core/constants.py b/rdmo/core/constants.py index d2a92c5340..fdb30309c4 100644 --- a/rdmo/core/constants.py +++ b/rdmo/core/constants.py @@ -58,7 +58,10 @@ ), 'views.view': ( 'views.add_view', 'views.change_view', 'views.delete_view' - ) + ), + 'config.plugin': ( + 'config.add_plugin', 'config.change_plugin', 'config.delete_plugin' + ), } HUMAN2BYTES_MAPPER = { @@ -91,5 +94,6 @@ 'section': 'questions.section', 'catalog': 'questions.catalog', 'task': 'tasks.task', - 'view': 'views.view' + 'view': 'views.view', + 'plugin': 'config.plugin' } diff --git a/rdmo/core/managers.py b/rdmo/core/managers.py index c660592231..1db0ab57c0 100644 --- a/rdmo/core/managers.py +++ b/rdmo/core/managers.py @@ -20,6 +20,15 @@ def filter_group(self, user): return self.filter(models.Q(groups=None) | models.Q(groups__in=groups)) +class ForSiteQuerySetMixin: + + def filter_for_site(self, site): + if settings.MULTISITE: + return self.filter(sites=site) + else: + return self.filter(models.Q(sites=None) | models.Q(sites=site)) + + class AvailabilityQuerySetMixin: def filter_availability(self, user): diff --git a/rdmo/core/plugins.py b/rdmo/core/plugins.py deleted file mode 100644 index ce61e2f11a..0000000000 --- a/rdmo/core/plugins.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.conf import settings - -from .utils import import_class - - -class Plugin: - - def __init__(self, key, label, class_name): - self.key = key - self.label = label - self.class_name = class_name - - -def get_plugins(plugin_settings): - plugins = {} - for key, label, class_name in getattr(settings, plugin_settings): - plugins[key] = import_class(class_name)(key, label, class_name) - return plugins - - -def get_plugin(plugin_settings, plugin_key): - try: - key, label, class_name = next( - (key, label, class_name) - for key, label, class_name in getattr(settings, plugin_settings) - if key == plugin_key - ) - return import_class(class_name)(key, label, class_name) - except StopIteration: - return None diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index 237846cef6..0cfec237d2 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -29,6 +29,7 @@ 'rdmo.views', 'rdmo.projects', 'rdmo.management', + 'rdmo.config', # 3rd party modules 'rest_framework', 'rest_framework.authtoken', @@ -228,10 +229,6 @@ 'PROJECT_VISIBILITY', 'PROJECT_ISSUES', 'PROJECT_VIEWS', - 'PROJECT_EXPORTS', - 'PROJECT_SNAPSHOT_EXPORTS', - 'PROJECT_IMPORTS', - 'PROJECT_IMPORTS_LIST', 'PROJECT_SEND_ISSUE', 'NESTED_PROJECTS', 'PROJECT_VIEWS_SYNC', @@ -331,33 +328,39 @@ # for example: 'not_empty': 'core/text_blocks/template_for_not_empty.html', } +PLUGINS = [ # introduced in 2.5 + 'rdmo.projects.exports.RDMOXMLExport', + 'rdmo.projects.imports.RDMOXMLImport', +] + +PLUGIN_META_ATTRIBUTES = ( + 'accept', + 'upload', + 'search', + 'refresh', + 'delimiter', + 'distribution_name', + 'distribution_version', +) + PROJECT_TABLE_PAGE_SIZE = 20 PROJECT_VISIBILITY = True PROJECT_ISSUES = True -PROJECT_ISSUE_PROVIDERS = [] +# PROJECT_ISSUE_PROVIDERS # deprecated in 2.5 PROJECT_VIEWS = True PROJECT_CONTACT = False PROJECT_CONTACT_RECIPIENTS = [] -PROJECT_EXPORTS = [ - ('xml', _('RDMO XML'), 'rdmo.projects.exports.RDMOXMLExport'), - ('csvcomma', _('CSV (comma separated)'), 'rdmo.projects.exports.CSVCommaExport'), - ('csvsemicolon', _('CSV (semicolon separated)'), 'rdmo.projects.exports.CSVSemicolonExport'), - ('json', _('JSON'), 'rdmo.projects.exports.JSONExport'), -] - -PROJECT_SNAPSHOT_EXPORTS = [] - -PROJECT_IMPORTS = [ - ('xml', _('RDMO XML'), 'rdmo.projects.imports.RDMOXMLImport'), -] +# PROJECT_EXPORTS # deprecated in 2.5 +# PROJECT_SNAPSHOT_EXPORTS # deprecated in 2.5 +# PROJECT_IMPORTS # deprecated in 2.5 -PROJECT_IMPORTS_LIST = [] +# PROJECT_IMPORTS_LIST # deprecated in 2.5 PROJECT_FILE_QUOTA = '10Mb' @@ -377,7 +380,7 @@ NESTED_PROJECTS = True -OPTIONSET_PROVIDERS = [] +# OPTIONSET_PROVIDERS # deprecated in 2.5 PROJECT_VALUES_SEARCH_LIMIT = 10 diff --git a/rdmo/core/static/core/css/base.scss b/rdmo/core/static/core/css/base.scss index 40373e48f0..4d2d38fce7 100644 --- a/rdmo/core/static/core/css/base.scss +++ b/rdmo/core/static/core/css/base.scss @@ -138,6 +138,10 @@ code { color: rgb(0, 128, 0); background-color: rgba(0, 128, 0, 0.1); } + &.code-config { + color: rgb(0, 102, 102); + background-color: rgba(0, 102, 102, 0.1); + } &.code-order { color: rgb(96, 96, 96); background-color: rgba(96, 96, 96, 0.1); diff --git a/rdmo/core/urls/v1/__init__.py b/rdmo/core/urls/v1/__init__.py index 31119717b6..8418e2a355 100644 --- a/rdmo/core/urls/v1/__init__.py +++ b/rdmo/core/urls/v1/__init__.py @@ -3,6 +3,7 @@ urlpatterns = [ path('accounts/', include('rdmo.accounts.urls.v1')), path('conditions/', include('rdmo.conditions.urls.v1')), + path('config/', include('rdmo.config.urls.v1')), path('domain/', include('rdmo.domain.urls.v1')), path('management/', include('rdmo.management.urls.v1')), path('options/', include('rdmo.options.urls.v1')), diff --git a/rdmo/core/utils.py b/rdmo/core/utils.py index d198111ca8..a3ec48ab70 100644 --- a/rdmo/core/utils.py +++ b/rdmo/core/utils.py @@ -1,22 +1,25 @@ -import importlib import json import logging import os import re from datetime import datetime +from importlib import metadata from pathlib import Path from urllib.parse import urlparse from django.conf import settings +from django.db import connections, models from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.template.loader import get_template, render_to_string from django.utils.dateparse import parse_date from django.utils.encoding import force_str from django.utils.formats import get_format +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from defusedcsv import csv from markdown import markdown +from packaging.version import Version from .constants import HUMAN2BYTES_MAPPER from .pandoc import get_pandoc_content, get_pandoc_content_disposition @@ -95,16 +98,21 @@ def get_model_field_meta(model): if hasattr(field, 'help_text'): meta[field.name]['help_text'] = field.help_text - if model.__name__ == 'Page': + if model._meta.model_name == 'page': meta['elements'] = { 'verbose_name': _('Elements'), 'help_text': _('The questions and question sets for this page.') } - elif model.__name__ == 'QuestionSet': + elif model._meta.model_name == 'questionset': meta['elements'] = { 'verbose_name': _('Elements'), 'help_text': _('The questions and question sets for this question set.') } + elif model._meta.model_name == 'plugin': + meta['python_path'] = { + **meta.get('python_path', {}), + 'choices': [(python_path, python_path) for python_path in get_plugin_python_paths()] + } return meta @@ -204,9 +212,17 @@ def sanitize_url(s): return s -def import_class(string): - module_name, class_name = string.rsplit('.', 1) - return getattr(importlib.import_module(module_name), class_name) +def get_plugin_python_paths(raise_exception=False): + plugin_paths = [] + for python_path in settings.PLUGINS: + try: + import_string(python_path) + except (ImportError, ValueError) as e: + if raise_exception: + raise e from e + else: + plugin_paths.append(python_path) + return plugin_paths def human2bytes(string): @@ -314,3 +330,36 @@ def parse_date_from_string(date: str) -> datetime.date: f"Invalid date format for: {date}. Valid formats {get_format('DATE_INPUT_FORMATS')}" ) return parsed_date + + +def jsonfield_contains(using: str, field: str, key: str, value) -> models.Q | None: + connection = connections[using] + + if getattr(connection.features, 'supports_json_field_contains', False): + return models.Q(**{f'{field}__contains': {key: value}}) + + if connection.vendor == 'sqlite': + # e.g. '"format": "csv"' with proper JSON escaping for key/value + fragment = f'{json.dumps(key)}: {json.dumps(value)}' + return models.Q(**{f'{field}__icontains': fragment}) + + return None + + +def get_distribution_name_from_class(python_class): + module = getattr(python_class, "__module__", "") + top = module.split(".", 1)[0] + if not top: + return None + + dist_names = metadata.packages_distributions().get(top, []) + return sorted(dist_names)[0] if dist_names else None + + +def get_distribution_version(distribution_name): + if not distribution_name: + return None + try: + return Version(metadata.version(distribution_name)) + except metadata.PackageNotFoundError: + return None diff --git a/rdmo/core/views.py b/rdmo/core/views.py index f8d9603866..44f9d60e6e 100644 --- a/rdmo/core/views.py +++ b/rdmo/core/views.py @@ -65,6 +65,7 @@ def api(request): 'accounts', 'conditions', 'core', + 'config', 'domain', 'management', 'options', diff --git a/rdmo/management/assets/js/actions/elementActions.js b/rdmo/management/assets/js/actions/elementActions.js index 26790ec026..cbcc6714d9 100644 --- a/rdmo/management/assets/js/actions/elementActions.js +++ b/rdmo/management/assets/js/actions/elementActions.js @@ -5,6 +5,7 @@ import { updateConfig } from 'rdmo/core/assets/js/actions/configActions' import { siteId } from 'rdmo/core/assets/js/utils/meta' import ConditionsApi from '../api/ConditionsApi' +import ConfigApi from '../api/ConfigApi' import DomainApi from '../api/DomainApi' import OptionsApi from '../api/OptionsApi' import QuestionsApi from '../api/QuestionsApi' @@ -12,6 +13,7 @@ import TasksApi from '../api/TasksApi' import ViewsApi from '../api/ViewsApi' import ConditionsFactory from '../factories/ConditionsFactory' +import ConfigFactory from '../factories/ConfigFactory' import DomainFactory from '../factories/DomainFactory' import OptionsFactory from '../factories/OptionsFactory' import QuestionsFactory from '../factories/QuestionsFactory' @@ -87,6 +89,11 @@ export function fetchElements(elementType) { action = (dispatch) => ViewsApi.fetchViews(true) .then(views => dispatch(fetchElementsSuccess({ views }))) break + + case 'plugins': + action = (dispatch) => ConfigApi.fetchPlugins(true) + .then(plugins => dispatch(fetchElementsSuccess({ plugins }))) + break } return dispatch(action) @@ -271,13 +278,14 @@ export function fetchElement(elementType, elementId, elementAction=null) { OptionsApi.fetchOptionSet(elementId), ConditionsApi.fetchConditions('index'), OptionsApi.fetchOptions('index'), - QuestionsApi.fetchQuestions('index') - ]).then(([element, conditions, options, questions]) => { + QuestionsApi.fetchQuestions('index'), + ConfigApi.fetchPlugins('index', { plugin_type: 'optionset_provider' }) + ]).then(([element, conditions, options, questions, plugins]) => { if (elementAction == 'copy') { delete element.questions } return { - element, conditions, options, questions + element, conditions, options, questions, plugins } }) } @@ -343,6 +351,18 @@ export function fetchElement(elementType, elementId, elementAction=null) { element, catalogs })) break + + case 'plugins': + action = () => Promise.all([ + ConfigApi.fetchPlugin(elementId), + QuestionsApi.fetchCatalogs('index'), + ]).then(([element, catalogs]) => { + if (elementAction == 'copy') { + delete element.catalogs + } + return { element, catalogs } + }) + break } return dispatch(action) @@ -433,6 +453,10 @@ export function storeElement(elementType, element, elementAction = null, back = case 'views': action = () => ViewsApi.storeView(element, elementAction) break + + case 'plugins': + action = () => ConfigApi.storePlugin(element, elementAction) + break } return dispatch(action) @@ -546,8 +570,9 @@ export function createElement(elementType, parent={}) { action = () => Promise.all([ OptionsFactory.createOptionSet(getState().config, parent), OptionsApi.fetchOptions('index'), - ]).then(([element, options]) => ({ - element, parent, options + ConfigApi.fetchPlugins('index', { plugin_type: 'optionset_provider' }) + ]).then(([element, options, plugins]) => ({ + element, parent, options, plugins })) break @@ -589,6 +614,15 @@ export function createElement(elementType, parent={}) { element, catalogs })) break + + case 'plugins': + action = () => Promise.all([ + ConfigFactory.createPlugin(getState().config), + QuestionsApi.fetchCatalogs('index') + ]).then(([element, catalogs]) => ({ + element, catalogs + })) + break } return dispatch(action) @@ -664,6 +698,10 @@ export function deleteElement(elementType, element) { case 'views': action = () => ViewsApi.deleteView(element) break + + case 'plugins': + action = () => ConfigApi.deletePlugin(element) + break } return dispatch(action) diff --git a/rdmo/management/assets/js/api/ConfigApi.js b/rdmo/management/assets/js/api/ConfigApi.js new file mode 100644 index 0000000000..8b1d1fc4aa --- /dev/null +++ b/rdmo/management/assets/js/api/ConfigApi.js @@ -0,0 +1,35 @@ +import isNil from 'lodash/isNil' + +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' + +class ConfigApi extends BaseApi { + + static fetchPlugins(action, params = {}) { + let url = '/api/v1/config/plugins/' + if (action == 'index') url += 'index/' + const searchParams = new URLSearchParams(params) + if ([...searchParams].length) { + url += `?${searchParams.toString()}` + } + return this.get(url) + } + + static fetchPlugin(id) { + return this.get(`/api/v1/config/plugins/${id}/`) + } + + static storePlugin(plugin, action) { + if (isNil(plugin.id)) { + return this.post('/api/v1/config/plugins/', plugin) + } else { + const actionPath = isNil(action) ? '' : `${action}/` + return this.put(`/api/v1/config/plugins/${plugin.id}/${actionPath}`, plugin) + } + } + + static deletePlugin(plugin) { + return this.delete(`/api/v1/config/plugins/${plugin.id}/`) + } +} + +export default ConfigApi diff --git a/rdmo/management/assets/js/components/edit/EditOptionSet.js b/rdmo/management/assets/js/components/edit/EditOptionSet.js index f8d3e09265..957e606617 100644 --- a/rdmo/management/assets/js/components/edit/EditOptionSet.js +++ b/rdmo/management/assets/js/components/edit/EditOptionSet.js @@ -21,8 +21,8 @@ import useDeleteModal from '../../hooks/useDeleteModal' const EditOptionSet = ({ config, optionset, elements, elementActions }) => { - const { sites, providers } = config - const { elementAction, parent, conditions, options } = elements + const { sites } = config + const { elementAction, parent, conditions, options, plugins } = elements const updateOptionSet = (key, value) => elementActions.updateElement(optionset, {[key]: value}) const storeOptionSet = (back) => elementActions.storeElement('optionsets', optionset, elementAction, back) @@ -34,6 +34,9 @@ const EditOptionSet = ({ config, optionset, elements, elementActions }) => { const editCondition = (condition) => elementActions.fetchElement('conditions', condition) const createCondition = () => elementActions.createElement('conditions', { optionset }) + const editPlugin = (plugin) => elementActions.fetchElement('plugins', plugin) + const createPlugin = () => elementActions.createElement('plugins', { optionset }) + const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal() const info = @@ -103,8 +106,9 @@ const EditOptionSet = ({ config, optionset, elements, elementActions }) => { addText={gettext('Add existing condition')} createText={gettext('Create new condition')} onChange={updateOptionSet} onCreate={createCondition} onEdit={editCondition} /> - } diff --git a/rdmo/management/assets/js/components/edit/EditPlugin.js b/rdmo/management/assets/js/components/edit/EditPlugin.js new file mode 100644 index 0000000000..2da5691290 --- /dev/null +++ b/rdmo/management/assets/js/components/edit/EditPlugin.js @@ -0,0 +1,152 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Tabs, Tab } from 'react-bootstrap' +import get from 'lodash/get' + +import Checkbox from './common/Checkbox' +import JsonField from './common/JsonField' +import Number from './common/Number' +import Select from './common/Select' +import Text from './common/Text' +import Textarea from './common/Textarea' +import UriPrefix from './common/UriPrefix' + +import { BackButton, SaveButton, DeleteButton } from '../common/Buttons' +import { ReadOnlyIcon } from '../common/Icons' + +import PluginInfo from '../info/PluginInfo' +import DeletePluginModal from '../modals/DeletePluginModal' + +import useDeleteModal from '../../hooks/useDeleteModal' + +const EditPlugin = ({ config, plugin, elements, elementActions}) => { + + const { sites, groups } = config + const { elementAction, catalogs } = elements + + const updatePlugin = (key, value) => elementActions.updateElement(plugin, {[key]: value}) + const storePlugin = (back) => elementActions.storeElement('plugins', plugin, elementAction, back) + const deletePlugin = () => elementActions.deleteElement('plugins', plugin) + + const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal() + + const info = + + const pluginModel = plugin.model || 'config.plugin' + const pythonPathOptions = get(config, ['meta', pluginModel, 'python_path', 'choices'], []) + .map(([value, label]) => ({id: value, name: label})) + + return ( +
    +
    +
    + + + + +
    + { + plugin.id ? <> + {gettext('Plugin')}{': '} + {plugin.uri} + : {gettext('Create plugin')} + } +
    + + { + plugin.id &&
    + { info } +
    + } + +
    +
    +
    + +
    +
    + +
    +
    + + + +