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' %}
+
+
+
+ {% for plugin in plugins %}
+
+ -
+
+
+ {% trans 'URI' %}: {{ plugin.uri }}
+
+
+ {% if plugin.comment %}
+
+
+ {% trans 'Comment' %}: {{ plugin.comment }}
+
+
+ {% endif %}
+
+
+ {% trans 'Title' %}: {{ plugin.title }}
+
+
+
+ {% trans 'Help text' %}: {{ plugin.help }}
+
+
+
+ {% trans 'Python path' %}: {{ plugin.python_path }}
+
+
+ {% if plugin.url_name %}
+
+
+ {% trans 'URL name' %}: {{ plugin.url_name }}
+
+
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+{% 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} />
-
+
{get(config, 'settings.multisite') && }
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 }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+ {get(config, 'settings.groups') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+ {plugin.id &&
}
+
+
+
+
+ )
+}
+
+EditPlugin.propTypes = {
+ config: PropTypes.object.isRequired,
+ plugin: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditPlugin
diff --git a/rdmo/management/assets/js/components/edit/common/JsonField.js b/rdmo/management/assets/js/components/edit/common/JsonField.js
new file mode 100644
index 0000000000..14e08cb0bd
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/JsonField.js
@@ -0,0 +1,102 @@
+import React, { useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+import ReactCodeMirror from '@uiw/react-codemirror'
+import { json } from '@codemirror/lang-json'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const JsonField = ({ config, element, field, onChange, disabled = false }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const [value, setValue] = useState(formatValue(element[field]))
+ const [parseError, setParseError] = useState(false)
+
+ useEffect(() => {
+ setValue(formatValue(element[field]))
+ }, [element.id, field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings) || parseError,
+ 'has-error': !isEmpty(errors)
+ })
+
+ const handleChange = (newValue) => {
+ setValue(newValue)
+ if (parseError) {
+ setParseError(false)
+ }
+ }
+
+ const handleBlur = () => {
+ if (disabled || element.read_only) {
+ return
+ }
+ try {
+ const parsed = value.trim() ? JSON.parse(value) : {}
+ setParseError(false)
+ onChange(field, parsed)
+ } catch (e) {
+ setParseError(true)
+ }
+ }
+
+ return (
+
+
+
+
+
+ {help &&
{help}
}
+ {parseError && (
+
+ {gettext('Please provide valid JSON.')}
+
+ )}
+
+ {errors && (
+
+ {errors.map((error, index) => (
+ - {error}
+ ))}
+
+ )}
+
+ )
+}
+
+const formatValue = (value) => {
+ if (isNil(value)) return ''
+ if (typeof value === 'string') return value
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch (e) {
+ return ''
+ }
+}
+
+JsonField.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ onChange: PropTypes.func,
+ disabled: PropTypes.bool
+}
+
+export default JsonField
diff --git a/rdmo/management/assets/js/components/element/Plugin.js b/rdmo/management/assets/js/components/element/Plugin.js
new file mode 100644
index 0000000000..aee37a4511
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Plugin.js
@@ -0,0 +1,73 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { siteId } from 'rdmo/core/assets/js/utils/meta'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { AvailableLink, CodeLink, CopyLink, EditLink, LockedLink, ToggleCurrentSiteLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const Plugin = ({ config, plugin, elementActions, filter=false, filterSites=false, filterEditors=false }) => {
+ const showElement = filterElement(config, filter, filterSites, filterEditors, plugin)
+
+ const editUrl = buildPath('plugins', plugin.id)
+ const copyUrl = buildPath('plugins', plugin.id, 'copy')
+
+ const fetchEdit = () => elementActions.fetchElement('plugins', plugin.id)
+ const fetchCopy = () => elementActions.fetchElement('plugins', plugin.id, 'copy')
+ const toggleAvailable = () => elementActions.storeElement('plugins', {...plugin, available: !plugin.available })
+ const toggleLocked = () => elementActions.storeElement('plugins', {...plugin, locked: !plugin.locked })
+ const toggleCurrentSite = () => elementActions.storeElement('plugins', plugin, 'toggle-site')
+
+ return showElement && (
+
+
+
+
+
+ {gettext('Plugin')}{': '}
+
+
+ {
+ get(config, 'display.uri.plugins', true) &&
+ fetchEdit()} />
+
+ }
+
+ {gettext('Type')}{': '}
+ {plugin.plugin_type || gettext('Unknown')}
+
+
+
+
+
+ )
+}
+
+Plugin.propTypes = {
+ config: PropTypes.object.isRequired,
+ plugin: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ filter: PropTypes.string,
+ filterSites: PropTypes.bool,
+ filterEditors: PropTypes.bool
+}
+
+export default Plugin
diff --git a/rdmo/management/assets/js/components/elements/Plugins.js b/rdmo/management/assets/js/components/elements/Plugins.js
new file mode 100644
index 0000000000..4d6128d394
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Plugins.js
@@ -0,0 +1,85 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite} from '../common/Filter'
+import { BackButton, NewButton } from '../common/Buttons'
+import { Checkbox } from '../common/Checkboxes'
+
+import Plugin from '../element/Plugin'
+
+const Plugins = ({ config, plugins, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.plugins.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.plugins.uri_prefix', value)
+ const updateFilterSite = (value) => configActions.updateConfig('filter.sites', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const updateDisplayPluginsURI = (value) => configActions.updateConfig('display.uri.plugins', value)
+
+ const createPlugin = () => elementActions.createElement('plugins')
+
+ return (
+
+
+
+
+
+
+
{gettext('Plugins')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite && <>
+
+
+
+
+
+
+ >
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Plugins')}}
+ value={get(config, 'display.uri.plugins', true)} onChange={updateDisplayPluginsURI} />
+
+
+
+
+
+ {
+ plugins.map((plugin, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Plugins.propTypes = {
+ config: PropTypes.object.isRequired,
+ plugins: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Plugins
diff --git a/rdmo/management/assets/js/components/info/PluginInfo.js b/rdmo/management/assets/js/components/info/PluginInfo.js
new file mode 100644
index 0000000000..ef7f914f46
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/PluginInfo.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const PluginInfo = ({ plugin }) => {
+ return (
+
+
+ {gettext('Plugin type')}{': '}
+ {plugin.plugin_type || gettext('Unknown')}
+
+
+ {gettext('Python path')}{': '}
+ {plugin.python_path}
+
+
+ )
+}
+
+PluginInfo.propTypes = {
+ plugin: PropTypes.object.isRequired
+}
+
+export default PluginInfo
diff --git a/rdmo/management/assets/js/components/main/Edit.js b/rdmo/management/assets/js/components/main/Edit.js
index 4e20597bee..6089be6a17 100644
--- a/rdmo/management/assets/js/components/main/Edit.js
+++ b/rdmo/management/assets/js/components/main/Edit.js
@@ -12,6 +12,7 @@ import EditQuestionSet from '../edit/EditQuestionSet'
import EditSection from '../edit/EditSection'
import EditTask from '../edit/EditTask'
import EditView from '../edit/EditView'
+import EditPlugin from '../edit/EditPlugin'
import useScrollEffect from '../../hooks/useScrollEffect'
@@ -43,6 +44,9 @@ const Edit = ({ config, elements, elementActions }) => {
return
case 'views':
return
+
+ case 'plugins':
+ return
}
}
diff --git a/rdmo/management/assets/js/components/main/Elements.js b/rdmo/management/assets/js/components/main/Elements.js
index 24e3dd4810..260738d9df 100644
--- a/rdmo/management/assets/js/components/main/Elements.js
+++ b/rdmo/management/assets/js/components/main/Elements.js
@@ -12,6 +12,7 @@ import QuestionSets from '../elements/QuestionSets'
import Sections from '../elements/Sections'
import Tasks from '../elements/Tasks'
import Views from '../elements/Views'
+import Plugins from '../elements/Plugins'
import useScrollEffect from '../../hooks/useScrollEffect'
@@ -54,6 +55,10 @@ const Elements = ({ config, elements, configActions, elementActions }) => {
case 'views':
return
+
+ case 'plugins':
+ return
}
}
diff --git a/rdmo/management/assets/js/components/modals/DeletePluginModal.js b/rdmo/management/assets/js/components/modals/DeletePluginModal.js
new file mode 100644
index 0000000000..b52ef1fe17
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeletePluginModal.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { Modal } from 'react-bootstrap'
+
+const DeletePluginModal = ({ plugin, info, show, onClose, onDelete }) => (
+
+
+ {gettext('Delete plugin')}
+
+
+ %(plugin)s?'),
+ {plugin: plugin.uri}
+ )}} />
+ { info }
+
+
+
+
+
+
+)
+
+DeletePluginModal.propTypes = {
+ plugin: PropTypes.object.isRequired,
+ info: PropTypes.object,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeletePluginModal
diff --git a/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js b/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js
index c451ebf1f2..f484fd6a49 100644
--- a/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js
+++ b/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js
@@ -69,60 +69,66 @@ const ElementsSidebar = ({ config, elements, elementActions, importActions }) =>
elementActions.fetchElements('views')}>{gettext('Views')}
-
-
- Export
-
-
- {gettext('Export all visible elements.')}
-
-
-
-
- {gettext('XML')}
+ elementActions.fetchElements('plugins')}>{gettext('Plugins')}
- {
- [
- 'catalogs',
- 'sections',
- 'pages',
- 'questionsets',
- 'questions',
- 'optionsets',
- 'conditions',
- 'tasks'
- ].includes(elementType) && (
- -
- {gettext('XML (full)')}
-
- )
- }
-
+ { elementType != 'plugins' && <>
+ Export
+
+
+ {gettext('Export all visible elements.')}
+
+
+
+ -
+ {gettext('XML')}
+
+ {
+ [
+ 'catalogs',
+ 'sections',
+ 'pages',
+ 'questionsets',
+ 'questions',
+ 'optionsets',
+ 'conditions',
+ 'tasks'
+ ].includes(elementType) && (
+ -
+ {gettext('XML (full)')}
+
+ )
+ }
+
+
+
+ > }
Import
diff --git a/rdmo/management/assets/js/constants/elements.js b/rdmo/management/assets/js/constants/elements.js
index 112dd13fa8..54fc310311 100644
--- a/rdmo/management/assets/js/constants/elements.js
+++ b/rdmo/management/assets/js/constants/elements.js
@@ -9,7 +9,8 @@ const elementTypes = {
'options.option': 'options',
'conditions.condition': 'conditions',
'tasks.task': 'tasks',
- 'views.view': 'views'
+ 'views.view': 'views',
+ 'config.plugin': 'plugins'
}
const elementModules = {
@@ -23,7 +24,8 @@ const elementModules = {
'options.option': 'options',
'conditions.condition': 'conditions',
'tasks.task': 'tasks',
- 'views.view': 'views'
+ 'views.view': 'views',
+ 'config.plugin': 'plugins'
}
const codeClass = {
@@ -37,7 +39,8 @@ const codeClass = {
'options.option': 'code-options',
'conditions.condition': 'code-conditions',
'tasks.task': 'code-tasks',
- 'views.view': 'code-views'
+ 'views.view': 'code-views',
+ 'config.plugin': 'code-config'
}
const verboseNames = {
@@ -51,7 +54,8 @@ const verboseNames = {
'options.option': gettext('Option'),
'conditions.condition': gettext('Condition'),
'tasks.task': gettext('Task'),
- 'views.view': gettext('View')
+ 'views.view': gettext('View'),
+ 'config.plugin': gettext('Plugin')
}
export { elementTypes, elementModules, codeClass, verboseNames }
diff --git a/rdmo/management/assets/js/factories/ConfigFactory.js b/rdmo/management/assets/js/factories/ConfigFactory.js
new file mode 100644
index 0000000000..8fdda63a06
--- /dev/null
+++ b/rdmo/management/assets/js/factories/ConfigFactory.js
@@ -0,0 +1,17 @@
+import { siteId } from 'rdmo/core/assets/js/utils/meta'
+
+class ConfigFactory {
+
+ static createPlugin(config) {
+ return {
+ model: 'config.plugin',
+ uri_prefix: config.settings.default_uri_prefix,
+ plugin_settings: {},
+ available: true,
+ sites: config.settings.multisite ? [siteId] : [],
+ editors: config.settings.multisite ? [siteId] : []
+ }
+ }
+}
+
+export default ConfigFactory
diff --git a/rdmo/management/assets/js/factories/OptionsFactory.js b/rdmo/management/assets/js/factories/OptionsFactory.js
index 30ab9bb59e..8075235283 100644
--- a/rdmo/management/assets/js/factories/OptionsFactory.js
+++ b/rdmo/management/assets/js/factories/OptionsFactory.js
@@ -8,6 +8,7 @@ class OptionsFactory {
uri_prefix: config.settings.default_uri_prefix,
questions: parent.question ? [parent.question.id] : [],
editors: config.settings.multisite ? [siteId] : [],
+ plugins: []
}
}
diff --git a/rdmo/management/assets/js/reducers/elementsReducer.js b/rdmo/management/assets/js/reducers/elementsReducer.js
index fc8f62d982..3534e4a6c3 100644
--- a/rdmo/management/assets/js/reducers/elementsReducer.js
+++ b/rdmo/management/assets/js/reducers/elementsReducer.js
@@ -19,7 +19,8 @@ const initialState = {
widgetTypes: [],
valueTypes: [],
tasks: [],
- views: []
+ views: [],
+ plugins: []
}
export default function elementsReducer(state = initialState, action) {
diff --git a/rdmo/management/constants.py b/rdmo/management/constants.py
index 4c1ddc78a9..8f472ca848 100644
--- a/rdmo/management/constants.py
+++ b/rdmo/management/constants.py
@@ -1,5 +1,6 @@
from rdmo.conditions.models import Condition
+from rdmo.config.models import Plugin
from rdmo.domain.models import Attribute
from rdmo.options.models import Option, OptionSet
from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section
@@ -7,6 +8,7 @@
from rdmo.views.models import View
RDMO_MODEL_PATH_MAPPER = {
+ 'config.plugin': Plugin,
'conditions.condition': Condition,
'domain.attribute': Attribute,
'options.optionset': OptionSet,
diff --git a/rdmo/management/imports.py b/rdmo/management/imports.py
index 38036cdd3e..4a0405e30d 100644
--- a/rdmo/management/imports.py
+++ b/rdmo/management/imports.py
@@ -7,6 +7,7 @@
from django.http import HttpRequest
from rdmo.conditions.imports import import_helper_condition
+from rdmo.config.imports import import_helper_plugin
from rdmo.core.imports import (
ImportElementFields,
check_permissions,
@@ -50,7 +51,8 @@
"questions.page": import_helper_page,
"questions.catalog": import_helper_catalog,
"tasks.task": import_helper_task,
- "views.view": import_helper_view
+ "views.view": import_helper_view,
+ "config.plugin": import_helper_plugin,
}
diff --git a/rdmo/management/rules.py b/rdmo/management/rules.py
index 4fbfed13c0..eeb82651e3 100644
--- a/rdmo/management/rules.py
+++ b/rdmo/management/rules.py
@@ -210,3 +210,15 @@ def is_legacy_reviewer(user) -> bool:
rules.add_perm('questions.add_question_object', is_element_editor)
rules.add_perm('questions.change_question_object', is_element_editor)
rules.add_perm('questions.delete_question_object', is_element_editor)
+
+# Model permissions for config.plugin
+rules.add_perm('config.view_plugin', is_editor | is_reviewer)
+rules.add_perm('config.add_plugin', is_editor)
+
+# Object permissions for config.plugin
+rules.add_perm('config.view_plugin_object', is_element_editor | is_element_reviewer)
+rules.add_perm('config.add_plugin_object', is_element_editor)
+rules.add_perm('config.change_plugin_object', is_element_editor)
+rules.add_perm('config.delete_plugin_object', is_element_editor)
+# toggle current site field perm
+rules.add_perm('config.change_plugin_toggle_site', is_editor)
diff --git a/rdmo/management/tests/test_import_options.py b/rdmo/management/tests/test_import_options.py
index 84707cd0e0..8d53bd5a3d 100644
--- a/rdmo/management/tests/test_import_options.py
+++ b/rdmo/management/tests/test_import_options.py
@@ -45,6 +45,11 @@
],
"http://example.com/terms/options/plugin": []
}
+OPTIONSET_PLUGIN_URIS = {
+ "http://example.com/terms/options/plugin": [
+ "http://example.com/terms/plugins/testing/optionset-provider"
+ ]
+}
LEGACY_SKIP_URIS = [
"http://example.com/terms/options/one_two_three_other/textarea"
]
@@ -69,6 +74,10 @@ def test_create_optionsets(db, settings, delete_all_objects):
db_ordered_options_uris = db_optionset.options.filter(uri__in=options_uris).order_by(
'option_optionsets__order').values_list('uri',flat=True)
assert options_uris == list(db_ordered_options_uris)
+ for optionset_uri, plugin_uris in OPTIONSET_PLUGIN_URIS.items():
+ db_optionset = OptionSet.objects.get(uri=optionset_uri)
+ db_plugin_uris = list(db_optionset.plugins.values_list('uri', flat=True))
+ assert db_plugin_uris == plugin_uris
def test_update_optionsets(db, settings, delete_all_objects):
delete_all_objects(OptionSet, Option)
diff --git a/rdmo/management/tests/test_import_plugins.py b/rdmo/management/tests/test_import_plugins.py
new file mode 100644
index 0000000000..1003a170fe
--- /dev/null
+++ b/rdmo/management/tests/test_import_plugins.py
@@ -0,0 +1,31 @@
+import json
+from pathlib import Path
+
+from rdmo.config.models import Plugin
+
+from .helpers_import_elements import (
+ parse_xml_and_import_elements,
+)
+
+
+def test_create_plugins(db, settings):
+ Plugin.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'plugins.xml'
+ _, root, imported_elements = parse_xml_and_import_elements(xml_file)
+
+ assert len(root) == len(imported_elements) == Plugin.objects.count() == 2
+ assert all(element['created'] is True for element in imported_elements)
+ assert all(element['updated'] is False for element in imported_elements)
+
+ plugin = Plugin.objects.get(uri="https://example.com/terms/plugins/simple-export-plugin")
+ assert plugin.uri_prefix == "https://example.com/terms"
+ assert plugin.uri_path == "simple-export-plugin"
+ assert plugin.comment == "Example export plugin imported via XML"
+ assert plugin.title_lang1 == "Example Export Plugin"
+ assert plugin.title_lang2 == "Beispiel Export Plugin"
+ assert plugin.python_path == "plugins.project_export.exports.SimpleExportPlugin"
+ plugin_settings = plugin.plugin_settings
+ if isinstance(plugin_settings, str):
+ plugin_settings = json.loads(plugin_settings)
+ assert plugin_settings["token"] == "abc123"
diff --git a/rdmo/options/imports.py b/rdmo/options/imports.py
index 3ad97c1b6c..0a590aefd8 100644
--- a/rdmo/options/imports.py
+++ b/rdmo/options/imports.py
@@ -13,9 +13,8 @@
validators=(OptionSetLockedValidator, OptionSetUniqueURIValidator),
extra_fields=(
ExtraFieldHelper(field_name='order'),
- ExtraFieldHelper(field_name='provider_key', value=''),
),
- m2m_instance_fields=('conditions', ),
+ m2m_instance_fields=('conditions', 'plugins'),
m2m_through_instance_fields=[
ThroughInstanceMapper(
field_name='options',
diff --git a/rdmo/options/migrations/0037_optionset_plugins.py b/rdmo/options/migrations/0037_optionset_plugins.py
new file mode 100644
index 0000000000..fb5f89b5af
--- /dev/null
+++ b/rdmo/options/migrations/0037_optionset_plugins.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.26 on 2025-11-14 14:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('config', '0001_initial'),
+ ('options', '0036_option_default_text'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='optionset',
+ name='plugins',
+ field=models.ManyToManyField(blank=True, help_text='The list of plugins evaluated for this option set.', related_name='optionsets', to='config.plugin', verbose_name='Plugins'),
+ ),
+ ]
diff --git a/rdmo/options/migrations/0038_remove_optionset_provider_key.py b/rdmo/options/migrations/0038_remove_optionset_provider_key.py
new file mode 100644
index 0000000000..30cc84eb17
--- /dev/null
+++ b/rdmo/options/migrations/0038_remove_optionset_provider_key.py
@@ -0,0 +1,44 @@
+# Generated by Django 4.2.27 on 2026-01-09 09:10
+
+from django.db import migrations
+
+
+def migrate_optionset_provider_key(apps, schema_editor):
+ OptionSet = apps.get_model('options', 'OptionSet')
+ Plugin = apps.get_model('config', 'Plugin')
+
+ providers = {
+ plugin.url_name: plugin.id
+ for plugin in Plugin.objects.filter(plugin_type='optionset_provider').exclude(url_name='')
+ }
+
+ if not providers:
+ return
+
+ through_model = OptionSet.plugins.through
+
+ optionsets = OptionSet.objects.exclude(provider_key='').values('id', 'provider_key')
+ for optionset in optionsets:
+ plugin_id = providers.get(optionset['provider_key'])
+ if plugin_id is None:
+ continue
+
+ through_model.objects.get_or_create(
+ optionset_id=optionset['id'],
+ plugin_id=plugin_id
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('options', '0037_optionset_plugins'),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_optionset_provider_key, migrations.RunPython.noop),
+ migrations.RemoveField(
+ model_name='optionset',
+ name='provider_key',
+ ),
+ ]
\ No newline at end of file
diff --git a/rdmo/options/models.py b/rdmo/options/models.py
index 1aa9315132..757e5bdf02 100644
--- a/rdmo/options/models.py
+++ b/rdmo/options/models.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
from django.conf import settings
from django.contrib.sites.models import Site
from django.db import models
@@ -7,8 +5,8 @@
from django.utils.translation import gettext_lazy as _
from rdmo.conditions.models import Condition
+from rdmo.config.models import Plugin
from rdmo.core.models import TranslationMixin
-from rdmo.core.plugins import get_plugin
from rdmo.core.utils import join_url
@@ -49,11 +47,6 @@ class OptionSet(models.Model):
verbose_name=_('Editors'),
help_text=_('The sites that can edit this option set (in a multi site setup).')
)
- provider_key = models.SlugField(
- max_length=128, blank=True,
- verbose_name=_('Provider'),
- help_text=_('The provider for this optionset. If set, it will create dynamic options for this optionset.')
- )
options = models.ManyToManyField(
'Option', through='OptionSetOption', blank=True, related_name='optionsets',
verbose_name=_('Options'),
@@ -64,6 +57,11 @@ class OptionSet(models.Model):
verbose_name=_('Conditions'),
help_text=_('The list of conditions evaluated for this option set.')
)
+ plugins = models.ManyToManyField(
+ Plugin, blank=True, related_name='optionsets',
+ verbose_name=_('Plugins'),
+ help_text=_('The list of plugins evaluated for this option set.')
+ )
class Meta:
ordering = ('uri', )
@@ -82,20 +80,16 @@ def label(self) -> str:
return self.uri
@property
- def provider(self) -> list:
- return get_plugin('OPTIONSET_PROVIDERS', self.provider_key)
-
- @property
- def has_provider(self) -> bool:
- return self.provider is not None
+ def has_plugins(self) -> bool:
+ return self.plugins.exists()
@property
def has_search(self) -> bool:
- return self.has_provider and self.provider.search
+ return self.has_plugins and any(i.has_search for i in self.plugins.all())
@property
def has_refresh(self) -> bool:
- return self.has_provider and self.provider.refresh
+ return self.has_plugins and any(i.has_refresh for i in self.plugins.all())
@property
def has_conditions(self) -> bool:
@@ -106,7 +100,7 @@ def is_locked(self) -> bool:
return self.locked
@cached_property
- def elements(self) -> list[Option]:
+ def elements(self) -> list:
return [element.option for element in sorted(self.optionset_options.all(), key=lambda e: e.order)]
@classmethod
diff --git a/rdmo/options/providers.py b/rdmo/options/providers.py
index 0990632623..8a334b6311 100644
--- a/rdmo/options/providers.py
+++ b/rdmo/options/providers.py
@@ -1,7 +1,9 @@
-from rdmo.core.plugins import Plugin
+from rdmo.config.plugins import BasePlugin
-class Provider(Plugin):
+class Provider(BasePlugin):
+
+ plugin_type = 'optionset_provider'
# determines if the provider supports "live searching" via autocomplete
search = False
@@ -11,27 +13,3 @@ class Provider(Plugin):
def get_options(self, project, search=None, user=None, site=None):
raise NotImplementedError
-
-
-class SimpleProvider(Provider):
-
- refresh = True
-
- def get_options(self, project, search=None, user=None, site=None):
- return [
- {
- 'id': 'simple_1',
- 'text': 'Simple answer 1',
- 'help': 'One'
- },
- {
- 'id': 'simple_2',
- 'text': 'Simple answer 2',
- 'help': 'Two'
- },
- {
- 'id': 'simple_3',
- 'text': 'Simple answer 3',
- 'help': 'Three'
- }
- ]
diff --git a/rdmo/options/renderers/__init__.py b/rdmo/options/renderers/__init__.py
index f7342577bd..7b8de4549a 100644
--- a/rdmo/options/renderers/__init__.py
+++ b/rdmo/options/renderers/__init__.py
@@ -1,4 +1,5 @@
from rdmo.conditions.renderers.mixins import ConditionRendererMixin
+from rdmo.config.renderers import PluginRendererMixin
from rdmo.core.renderers import BaseXMLRenderer
from rdmo.domain.renderers.mixins import AttributeRendererMixin
@@ -6,7 +7,7 @@
class OptionSetRenderer(OptionSetRendererMixin, OptionRendererMixin, ConditionRendererMixin,
- AttributeRendererMixin, BaseXMLRenderer):
+ PluginRendererMixin, AttributeRendererMixin, BaseXMLRenderer):
def render_document(self, xml, optionsets):
xml.startElement('rdmo', {
diff --git a/rdmo/options/renderers/mixins.py b/rdmo/options/renderers/mixins.py
index 6c021dfafd..903363823f 100644
--- a/rdmo/options/renderers/mixins.py
+++ b/rdmo/options/renderers/mixins.py
@@ -11,7 +11,6 @@ def render_optionset(self, xml, optionset):
self.render_text_element(xml, 'uri_prefix', {}, optionset['uri_prefix'])
self.render_text_element(xml, 'uri_path', {}, optionset['uri_path'])
self.render_text_element(xml, 'dc:comment', {}, optionset['comment'])
- self.render_text_element(xml, 'provider_key', {}, optionset['provider_key'])
xml.startElement('options', {})
for optionset_option in optionset['optionset_options']:
@@ -26,6 +25,11 @@ def render_optionset(self, xml, optionset):
self.render_text_element(xml, 'condition', {'dc:uri': condition['uri']}, None)
xml.endElement('conditions')
+ xml.startElement('plugins', {})
+ for plugin in optionset.get('plugins', []):
+ self.render_text_element(xml, 'plugin', {'dc:uri': plugin['uri']}, None)
+ xml.endElement('plugins')
+
xml.endElement('optionset')
for optionset_option in optionset['optionset_options']:
@@ -35,6 +39,10 @@ def render_optionset(self, xml, optionset):
for condition in optionset['conditions']:
self.render_condition(xml, condition)
+ if self.context.get('plugins'):
+ for plugin in optionset['plugins']:
+ self.render_plugin(xml, plugin)
+
class OptionRendererMixin:
diff --git a/rdmo/options/serializers/export.py b/rdmo/options/serializers/export.py
index 8adb40b441..8669858709 100644
--- a/rdmo/options/serializers/export.py
+++ b/rdmo/options/serializers/export.py
@@ -1,6 +1,7 @@
from rest_framework import serializers
from rdmo.conditions.serializers.export import ConditionExportSerializer
+from rdmo.config.serializers.export import PluginExportSerializer
from rdmo.core.serializers import TranslationSerializerMixin
from ..models import Option, OptionSet, OptionSetOption
@@ -41,6 +42,7 @@ class OptionSetExportSerializer(serializers.ModelSerializer):
optionset_options = OptionSetOptionExportSerializer(many=True)
conditions = ConditionExportSerializer(many=True)
+ plugins = PluginExportSerializer(many=True)
class Meta:
model = OptionSet
@@ -50,7 +52,7 @@ class Meta:
'uri_path',
'comment',
'order',
- 'provider_key',
'optionset_options',
- 'conditions'
+ 'conditions',
+ 'plugins',
)
diff --git a/rdmo/options/serializers/v1/optionset.py b/rdmo/options/serializers/v1/optionset.py
index 14a4b5169e..0fc6e9bc1f 100644
--- a/rdmo/options/serializers/v1/optionset.py
+++ b/rdmo/options/serializers/v1/optionset.py
@@ -1,5 +1,7 @@
from rest_framework import serializers
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from rdmo.core.serializers import (
ElementModelSerializerMixin,
ReadOnlyObjectPermissionSerializerMixin,
@@ -22,6 +24,15 @@ class Meta:
)
+class OptionSetPluginsSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = Plugin
+ fields = (
+ 'uri',
+ 'order'
+ )
+
class OptionSetSerializer(ThroughModelSerializerMixin, ElementModelSerializerMixin,
ReadOnlyObjectPermissionSerializerMixin, serializers.ModelSerializer):
@@ -30,6 +41,10 @@ class OptionSetSerializer(ThroughModelSerializerMixin, ElementModelSerializerMix
options = OptionSetOptionSerializer(source='optionset_options', read_only=False, required=False, many=True)
questions = serializers.PrimaryKeyRelatedField(queryset=Question.objects.all(), required=False, many=True)
+ plugins = serializers.PrimaryKeyRelatedField(
+ queryset=Plugin.objects.filter(plugin_type=PLUGIN_TYPES.OPTIONSET_PROVIDER), required=False, many=True
+ )
+
read_only = serializers.SerializerMethodField()
condition_uris = serializers.SerializerMethodField()
@@ -46,10 +61,10 @@ class Meta:
'locked',
'read_only',
'order',
- 'provider_key',
'options',
'conditions',
'questions',
+ 'plugins',
'editors',
'read_only',
'condition_uris',
diff --git a/rdmo/options/templates/options/export/optionset.html b/rdmo/options/templates/options/export/optionset.html
index b321464d22..3ab8565532 100644
--- a/rdmo/options/templates/options/export/optionset.html
+++ b/rdmo/options/templates/options/export/optionset.html
@@ -15,12 +15,18 @@
{% endfor %}
- {% if optionset.provider_key %}
+ {% if optionset.plugins.all %}
- {% trans 'Provider' %}: {{ optionset.provider.label }}
+ {% trans 'Plugins' %}:
+
+ {% for plugin in optionset.plugins.all %}
+ {% include 'config/export/plugin.html' with option=option %}
+ {% endfor %}
+
+
{% endif %}
{% if optionset.conditions.all %}
diff --git a/rdmo/options/tests/test_viewset_options.py b/rdmo/options/tests/test_viewset_options.py
index 254d719271..38ae8f2d49 100644
--- a/rdmo/options/tests/test_viewset_options.py
+++ b/rdmo/options/tests/test_viewset_options.py
@@ -134,7 +134,8 @@ def test_create_optionset(db, client, username, password):
'comment': instance.comment,
'text_en': instance.text_lang1,
'text_de': instance.text_lang2,
- 'optionsets': [optionset.id]
+ 'optionsets': [optionset.id],
+ 'plugins': [3],
}
response = client.post(url, data, content_type='application/json')
assert response.status_code == status_map['create'][username], response.json()
diff --git a/rdmo/options/tests/test_viewset_optionsets.py b/rdmo/options/tests/test_viewset_optionsets.py
index e7336fbf10..2e7fc30bb8 100644
--- a/rdmo/options/tests/test_viewset_optionsets.py
+++ b/rdmo/options/tests/test_viewset_optionsets.py
@@ -4,6 +4,8 @@
from django.urls import reverse
+from ...config.constants import PLUGIN_TYPES
+from ...config.models import Plugin
from ..models import OptionSet
users = (
@@ -155,6 +157,9 @@ def test_create_m2m(db, client, username, password):
client.login(username=username, password=password)
instances = OptionSet.objects.all()
+ plugin = Plugin.objects.filter(plugin_type=PLUGIN_TYPES.OPTIONSET_PROVIDER).first()
+ plugins = [plugin.pk] if plugin else []
+
for instance in instances:
optionset_options = [{
'option': optionset_option.option.id,
@@ -170,6 +175,7 @@ def test_create_m2m(db, client, username, password):
'order': instance.order,
'options': optionset_options,
'conditions': conditions,
+ 'plugins': plugins,
}
response = client.post(url, data, content_type='application/json')
assert response.status_code == status_map['create'][username], response.json()
@@ -181,6 +187,7 @@ def test_create_m2m(db, client, username, password):
'order': optionset_option.order
} for optionset_option in new_instance.optionset_options.all()]
assert conditions == [condition.pk for condition in new_instance.conditions.all()]
+ assert plugins == [plugin.pk for plugin in new_instance.plugins.all()]
@pytest.mark.parametrize('username,password', users)
@@ -218,6 +225,9 @@ def test_update_m2m(db, client, username, password):
client.login(username=username, password=password)
instances = OptionSet.objects.all()
+ plugin = Plugin.objects.filter(plugin_type=PLUGIN_TYPES.OPTIONSET_PROVIDER).first()
+ plugins = [plugin.pk] if plugin else []
+
for instance in instances:
optionset_options = [{
'option': optionset_option.option.id,
@@ -233,6 +243,7 @@ def test_update_m2m(db, client, username, password):
'order': instance.order,
'options': optionset_options,
'conditions': conditions,
+ 'plugins': plugins,
}
response = client.put(url, data, content_type='application/json')
assert response.status_code == status_map['update'][username], response.json()
@@ -244,6 +255,7 @@ def test_update_m2m(db, client, username, password):
'order': optionset_option.order
} for optionset_option in instance.optionset_options.all()]
assert conditions == [condition.pk for condition in instance.conditions.all()]
+ assert plugins == [plugin.pk for plugin in instance.plugins.all()]
@pytest.mark.parametrize('username,password', users)
diff --git a/rdmo/options/tests/test_viewset_providers.py b/rdmo/options/tests/test_viewset_providers.py
new file mode 100644
index 0000000000..8189f8bb87
--- /dev/null
+++ b/rdmo/options/tests/test_viewset_providers.py
@@ -0,0 +1,40 @@
+import pytest
+
+from django.urls import reverse
+
+from rdmo.config.models import Plugin
+
+users = (
+ ('editor', 'editor'),
+ ('reviewer', 'reviewer'),
+ ('user', 'user'),
+ ('api', 'api'),
+ ('anonymous', None),
+)
+
+
+@pytest.fixture
+def optionset_provider(settings):
+ assert 'plugins.optionset_providers.providers.SimpleProvider' in settings.PLUGINS
+
+ plugin = Plugin.objects.get(python_path='plugins.optionset_providers.providers.SimpleProvider')
+ plugin.available = True
+ plugin.title_lang1 = plugin.title_lang1 or 'Simple OptionSet Provider'
+ plugin.url_name = plugin.url_name or 'simple'
+ plugin.save()
+
+ return plugin
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, optionset_provider, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse('v1-options:provider-list')
+ response = client.get(url)
+
+ if password:
+ assert response.status_code == 200
+ assert {'id': 'simple', 'text': optionset_provider.title} in response.json()
+ else:
+ assert response.status_code == 401
diff --git a/rdmo/options/viewsets.py b/rdmo/options/viewsets.py
index b0eaffd2d4..515420362f 100644
--- a/rdmo/options/viewsets.py
+++ b/rdmo/options/viewsets.py
@@ -1,4 +1,3 @@
-from django.conf import settings
from django.db import models
from rest_framework.decorators import action
@@ -14,6 +13,8 @@
from rdmo.core.utils import is_truthy, render_to_format
from rdmo.core.views import ChoicesViewSet
+from ..config.constants import PLUGIN_TYPES
+from ..config.models import Plugin
from .models import Option, OptionSet
from .renderers import OptionRenderer, OptionSetRenderer
from .serializers.export import OptionExportSerializer, OptionSetExportSerializer
@@ -33,6 +34,7 @@ class OptionSetViewSet(ModelViewSet):
'optionset_options__option',
'conditions',
'questions',
+ 'plugins',
'editors'
)
@@ -86,7 +88,8 @@ def get_export_renderer_context(self, request):
return {
'attributes': full or is_truthy(request.GET.get('attributes')),
'conditions': full or is_truthy(request.GET.get('conditions')),
- 'options': full or is_truthy(request.GET.get('options'))
+ 'options': full or is_truthy(request.GET.get('options')),
+ 'plugins': full or is_truthy(request.GET.get('plugins')),
}
@@ -148,4 +151,16 @@ class AdditionalInputsViewSet(ChoicesViewSet):
class ProviderViewSet(ChoicesViewSet):
permission_classes = (IsAuthenticated, )
- queryset = settings.OPTIONSET_PROVIDERS
+
+ def get_queryset(self):
+ providers = {}
+
+ plugins = Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.OPTIONSET_PROVIDER,
+ user=self.request.user
+ )
+ for plugin in plugins:
+ key = plugin.url_name or plugin.uri_path
+ if key:
+ providers[key] = plugin.title
+ return list(providers.items())
diff --git a/rdmo/projects/exports.py b/rdmo/projects/exports.py
index a6b681071c..9c3c374484 100644
--- a/rdmo/projects/exports.py
+++ b/rdmo/projects/exports.py
@@ -3,8 +3,8 @@
from django.conf import settings
from django.http import HttpResponse
+from rdmo.config.plugins import BasePlugin
from rdmo.core.exports import prettify_xml
-from rdmo.core.plugins import Plugin
from rdmo.core.utils import render_to_csv, render_to_json
from rdmo.views.templatetags import view_tags
from rdmo.views.utils import ProjectWrapper
@@ -14,11 +14,11 @@
from .serializers.export import SnapshotSerializer as SnapshotExportSerializer
-class Export(Plugin):
+class Export(BasePlugin):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ plugin_type = 'project_export'
+ def __init__(self, *args, **kwargs):
self.project = None
self.snapshot = None
diff --git a/rdmo/projects/forms.py b/rdmo/projects/forms.py
index 0fa78f22c1..943c294ab3 100644
--- a/rdmo/projects/forms.py
+++ b/rdmo/projects/forms.py
@@ -5,11 +5,13 @@
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.db.models import Q
+from django.http import Http404
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from rdmo.core.constants import VALUE_TYPE_FILE
-from rdmo.core.plugins import get_plugin
from rdmo.core.utils import markdown2html
from .constants import ROLE_CHOICES
@@ -353,9 +355,17 @@ def __init__(self, *args, **kwargs):
# get the provider
if self.provider_key:
- self.provider = get_plugin('PROJECT_ISSUE_PROVIDERS', self.provider_key)
+
+ plugin = Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER, project=self.project, format=self.provider_key
+ ).first()
+ if plugin is None:
+ raise Http404
+ self.provider = plugin.initialize_class()
else:
self.provider = self.instance.provider
+ if self.provider is None:
+ raise Http404
# add fields for the integration options
for field in self.provider.fields:
diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py
index bfb40937cc..53a0e5ce25 100644
--- a/rdmo/projects/imports.py
+++ b/rdmo/projects/imports.py
@@ -11,8 +11,8 @@
import requests
+from rdmo.config.plugins import BasePlugin
from rdmo.core.imports import handle_fetched_file
-from rdmo.core.plugins import Plugin
from rdmo.core.xml import get_ns_map, get_uri, read_xml_file
from rdmo.domain.models import Attribute
from rdmo.options.models import Option
@@ -25,14 +25,13 @@
log = logging.getLogger(__name__)
-class Import(Plugin):
+class Import(BasePlugin):
+ plugin_type = 'project_import'
accept = None
upload = True
def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
self.file_name = None
self.source_title = None
self.current_project = None
diff --git a/rdmo/projects/management/commands/export_projects.py b/rdmo/projects/management/commands/export_projects.py
index f754b3f433..73fb59cc58 100644
--- a/rdmo/projects/management/commands/export_projects.py
+++ b/rdmo/projects/management/commands/export_projects.py
@@ -5,7 +5,8 @@
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Prefetch
-from rdmo.core.plugins import get_plugin
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from rdmo.core.utils import render_to_format
from rdmo.projects.models import Project
from rdmo.projects.utils import get_value_path
@@ -96,9 +97,12 @@ def export_view(self, key):
def export_projects(self):
for project in self.get_queryset():
- export_plugin = get_plugin('PROJECT_EXPORTS', self.format)
- if export_plugin is None:
+ export_plugins = Plugin.objects.for_context(
+ project=project, plugin_type=PLUGIN_TYPES.PROJECT_EXPORT,
+ format=self.format)
+ if export_plugins.exists() is not None:
raise CommandError(f'Format "{self.format}" is not supported.')
+ export_plugin = export_plugins.first()
export_plugin.project = project
export_plugin.snapshot = None
diff --git a/rdmo/projects/mixins.py b/rdmo/projects/mixins.py
index 1ce167ca0a..530f742b4c 100644
--- a/rdmo/projects/mixins.py
+++ b/rdmo/projects/mixins.py
@@ -7,8 +7,9 @@
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from rdmo.core.imports import handle_uploaded_file
-from rdmo.core.plugins import get_plugin, get_plugins
from rdmo.questions.models import Question
from .models import Membership, Project
@@ -63,10 +64,17 @@ def update_values(self, current_project, catalog, values, snapshots=None):
.get(value.collection_index)
def get_import_plugin(self, key, current_project=None):
- import_plugin = get_plugin('PROJECT_IMPORTS', key)
- if import_plugin is None:
+ plugins = Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_IMPORT,
+ project=current_project,
+ user=self.request.user,
+ format=key
+ )
+ plugin = next((i for i in plugins if i.url_name == key), None)
+ if plugin is None:
raise Http404
+ import_plugin = plugin.initialize_class()
import_plugin.request = self.request
import_plugin.current_project = current_project
@@ -98,7 +106,11 @@ def import_form(self):
'errors': [_('There has been an error with your import. No uploaded or retrieved file could be found.')]
}, status=400)
- for import_key, import_plugin in get_plugins('PROJECT_IMPORTS').items():
+ for plugin in Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_IMPORT, project=current_project,
+ user=self.request.user, format=Path(import_file_name).suffix.lstrip('.')
+ ):
+ import_plugin = plugin.initialize_class()
import_plugin.current_project = current_project
import_plugin.file_name = import_file_name
import_plugin.source_title = import_source_title
@@ -114,7 +126,7 @@ def import_form(self):
}, status=400)
# store information in session for ProjectCreateImportView
- self.request.session['import_key'] = import_key
+ self.request.session['import_key'] = plugin.url_name
# attach questions and current values
self.update_values(current_project, import_plugin.catalog,
diff --git a/rdmo/projects/models/integration.py b/rdmo/projects/models/integration.py
index 8bcad4f1df..3c533f0437 100644
--- a/rdmo/projects/models/integration.py
+++ b/rdmo/projects/models/integration.py
@@ -2,7 +2,8 @@
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from rdmo.core.plugins import get_plugin
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from ..managers import IntegrationManager
@@ -34,7 +35,16 @@ def get_absolute_url(self):
@property
def provider(self):
- return get_plugin('PROJECT_ISSUE_PROVIDERS', self.provider_key)
+ plugins = (
+ Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER,
+ project=self.project,
+ format=self.provider_key)
+ )
+ if plugins.exists():
+ return plugins.first().initialize_class()
+ else:
+ return None
def get_option_value(self, key):
try:
@@ -43,6 +53,8 @@ def get_option_value(self, key):
return None
def save_options(self, options):
+ if self.provider is None:
+ raise ValueError(_('The provider key is required.'))
for field in self.provider.fields:
try:
integration_option = IntegrationOption.objects.get(integration=self, key=field.get('key'))
diff --git a/rdmo/projects/providers.py b/rdmo/projects/providers.py
index 267706bfc7..ea16f8f332 100644
--- a/rdmo/projects/providers.py
+++ b/rdmo/projects/providers.py
@@ -1,15 +1,15 @@
-import json
-
from django.core.exceptions import ObjectDoesNotExist
-from django.http import Http404, HttpResponse, HttpResponseRedirect
+from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
-from rdmo.core.plugins import Plugin
+from rdmo.config.plugins import BasePlugin
from rdmo.services.providers import OauthProviderMixin
-class IssueProvider(Plugin):
+class IssueProvider(BasePlugin):
+
+ plugin_type = 'project_issue_provider'
def send_issue(self, request, issue, integration, subject, message, attachments):
raise NotImplementedError
@@ -71,61 +71,3 @@ def get_post_data(self, request, issue, integration, subject, message, attachmen
def get_issue_url(self, response):
raise NotImplementedError
-
-
-class SimpleIssueProvider(OauthIssueProvider):
-
- add_label = _('Add Simple integration')
- send_label = _('Send to Simple')
- description = _('This integration allow the creation of issues in arbitrary Simple repositories. '
- 'The upload of attachments is not supported.')
-
- @property
- def fields(self):
- return [
- {
- 'key': 'project_url',
- 'placeholder': 'https://example.com/projects/',
- 'help': _('The URL of the project to send tasks to.')
- },
- {
- 'key': 'secret',
- 'placeholder': 'Secret (random) string',
- 'help': _('The secret for a webhook to close a task (optional).'),
- 'required': False,
- 'secret': True
- }
- ]
-
- def get(self, request, url):
- raise NotImplementedError
-
- def post(self, request, url, json=None, files=None, multipart=None):
- raise NotImplementedError
-
- def webhook(self, request, integration):
- secret = integration.get_option_value('secret')
- header_signature = request.headers.get('X-Secret')
- if secret == header_signature:
- try:
- payload = json.loads(request.body.decode())
- except json.decoder.JSONDecodeError as e:
- return HttpResponse(e, status=400)
-
- action = payload.get('action')
- url = payload.get('url')
-
- try:
- issue_resource = integration.resources.get(url=url)
- if action == 'closed':
- issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_CLOSED
- else:
- issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_IN_PROGRESS
-
- issue_resource.issue.save()
- except ObjectDoesNotExist:
- pass
-
- return HttpResponse(status=200)
-
- raise Http404
diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py
index 790fc18994..b5cdf415f7 100644
--- a/rdmo/projects/serializers/v1/__init__.py
+++ b/rdmo/projects/serializers/v1/__init__.py
@@ -7,6 +7,7 @@
from rest_framework.exceptions import ValidationError
from rdmo.accounts.utils import get_full_name
+from rdmo.config.models import Plugin
from rdmo.domain.models import Attribute
from rdmo.questions.models import Catalog
from rdmo.services.validators import ProviderValidator
@@ -148,6 +149,26 @@ class Meta:
)
+class ProjectImportPluginSerializer(serializers.ModelSerializer):
+
+ key = serializers.CharField(source='url_name')
+ label = serializers.CharField(source='title')
+ class_name = serializers.CharField(source='python_path')
+ href = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Plugin
+ fields = (
+ 'key',
+ 'label',
+ 'class_name',
+ 'href',
+ )
+
+ def get_href(self, obj) -> str:
+ return reverse('project_create_import', args=[obj.url_name])
+
+
class ProjectIntegrationOptionSerializer(serializers.ModelSerializer):
class Meta:
diff --git a/rdmo/projects/serializers/v1/page.py b/rdmo/projects/serializers/v1/page.py
index 23a3f558b9..12e99676f8 100644
--- a/rdmo/projects/serializers/v1/page.py
+++ b/rdmo/projects/serializers/v1/page.py
@@ -49,7 +49,7 @@ class Meta:
'uri',
'model',
'options',
- 'has_provider',
+ 'has_plugins',
'has_search',
'has_refresh',
'has_conditions'
diff --git a/rdmo/projects/tests/test_commands.py b/rdmo/projects/tests/test_commands.py
index e777e760b6..5cb25cd81e 100644
--- a/rdmo/projects/tests/test_commands.py
+++ b/rdmo/projects/tests/test_commands.py
@@ -50,7 +50,7 @@ def test_prune_projects_output2(db, settings):
stdout, stderr = io.StringIO(), io.StringIO()
instances = Project.objects.filter(id__in=projects_without_owner)
- call_command('prune_projects', stdout=stdout, stderr=stderr)
+ call_command('prune_projects', '--no-color', stdout=stdout, stderr=stderr)
assert stdout.getvalue() == \
f"Found projects without ['owner']:\n{get_prune_output(instances)}"
@@ -60,18 +60,18 @@ def test_prune_projects_output2(db, settings):
def test_prune_projects_remove(db, settings):
stdout, stderr = io.StringIO(), io.StringIO()
- instances = list(Project.objects.filter(id__in=projects_without_owner).all()).copy()
-
call_command('prune_projects', '--remove', stdout=stdout, stderr=stderr)
std_output = stdout.getvalue()
- prune_output = f"Found projects without ['owner']:\n{get_prune_output(instances, True)}"
- assert std_output == prune_output
+ assert "Found projects without ['owner']:" in std_output
+ for project_id in projects_without_owner:
+ assert f"(id={project_id})" in std_output
+
assert not stderr.getvalue()
stdout, stderr = io.StringIO(), io.StringIO()
call_command('prune_projects', '--remove', stdout=stdout, stderr=stderr)
- assert stdout.getvalue() == "No projects without ['owner']\n"
+ assert "No projects without ['owner']" in stdout.getvalue()
assert not stderr.getvalue()
diff --git a/rdmo/projects/tests/test_view_issue.py b/rdmo/projects/tests/test_view_issue.py
index cf4f6d745e..3879ff4b41 100644
--- a/rdmo/projects/tests/test_view_issue.py
+++ b/rdmo/projects/tests/test_view_issue.py
@@ -199,7 +199,7 @@ def test_issue_send_post_attachments(db, client, files, username, password, issu
@pytest.mark.parametrize('issue_id', issues)
def test_issue_send_post_integration(db, client, mocker, username, password, issue_id):
mocked_send_issue = Mock(return_value=HttpResponseRedirect(redirect_to='https://example.com/login/oauth/authorize'))
- mocker.patch('rdmo.projects.providers.SimpleIssueProvider.send_issue', mocked_send_issue)
+ mocker.patch('plugins.project_issue_providers.providers.SimpleIssueProvider.send_issue', mocked_send_issue)
client.login(username=username, password=password)
issue = Issue.objects.get(id=issue_id)
diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py
index fd56761a8b..7caced823c 100644
--- a/rdmo/projects/tests/test_viewset_project.py
+++ b/rdmo/projects/tests/test_viewset_project.py
@@ -622,7 +622,7 @@ def test_options(db, client, username, password):
def test_options_text_and_help(db, client, mocker):
- mocker.patch('rdmo.options.providers.SimpleProvider.get_options', return_value=[
+ mocker.patch('plugins.optionset_providers.providers.SimpleProvider.get_options', return_value=[
{
'id': 'simple_1',
'text': 'Simple answer 1'
@@ -653,9 +653,14 @@ def test_upload_accept(db, client, username, password):
if password:
assert response.status_code == 200
- assert response.json() == {
- 'application/xml': ['.xml']
- }
+ if username == "admin":
+ assert response.json() == {
+ 'application/xml': ['.xml'], 'text/plain': ['.txt']
+ }
+ else:
+ assert response.json() == {
+ 'application/xml': ['.xml']
+ }
else:
assert response.status_code == 401
@@ -669,7 +674,11 @@ def test_imports(db, client, username, password):
if password:
assert response.status_code == 200
- assert len(response.json()) == 1
- assert response.json()[0]['key'] == 'url'
+ if username == 'admin':
+ assert len(response.json()) == 3
+ assert {i['key'] for i in response.json()} == {'url', 'xml', 'simple'}
+ else:
+ assert len(response.json()) == 2
+ assert {i['key'] for i in response.json()} == {'url', 'xml'}
else:
assert response.status_code == 401
diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py
index 4bd983f0f1..8321b5e4d2 100644
--- a/rdmo/projects/utils.py
+++ b/rdmo/projects/utils.py
@@ -1,5 +1,4 @@
import logging
-import mimetypes
from collections import defaultdict
from pathlib import Path
@@ -12,7 +11,6 @@
from django.utils.timezone import now
from rdmo.core.mail import send_mail
-from rdmo.core.plugins import get_plugins
from rdmo.core.utils import remove_double_newlines
from rdmo.tasks.managers import TaskQuerySet
from rdmo.views.managers import ViewQuerySet
@@ -282,27 +280,14 @@ def set_context_querystring_with_filter_and_page(context: dict) -> dict:
return context
-def get_upload_accept():
+def get_upload_accept(plugins):
accept = defaultdict(set)
- for import_plugin in get_plugins('PROJECT_IMPORTS').values():
- if isinstance(import_plugin.accept, dict):
- for mime_type, suffixes in import_plugin.accept.items():
- accept[mime_type].update(suffixes)
-
- elif isinstance(import_plugin.accept, str):
- # legacy fallback for pre 2.3.0 RDMO, e.g. `accept = '.xml'`
- suffix = import_plugin.accept
- mime_type, _encoding = mimetypes.guess_type(f'example{suffix}')
- if mime_type:
- accept[mime_type].update([suffix])
-
- elif import_plugin.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
+ for plugin in plugins:
+ if plugin.upload_accept is None:
return {}
-
- return accept
-
+ for mime_type, suffixes in plugin.upload_accept.items():
+ accept[mime_type].update(suffixes)
+ return {mime_type: sorted(suffixes) for mime_type, suffixes in accept.items()}
def compute_set_prefix_from_set_value(set_value, value):
set_prefix_length = len(set_value.set_prefix.split('|')) if set_value.set_prefix else 0
diff --git a/rdmo/projects/views/integration.py b/rdmo/projects/views/integration.py
index a0191c0b85..3ef4ef842a 100644
--- a/rdmo/projects/views/integration.py
+++ b/rdmo/projects/views/integration.py
@@ -6,9 +6,10 @@
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import CreateView, DeleteView, UpdateView, View
-from rdmo.core.plugins import get_plugin
+from rdmo.config.constants import PLUGIN_TYPES
from rdmo.core.views import ObjectPermissionMixin, RedirectViewMixin
+from ...config.models import Plugin
from ..forms import IntegrationForm
from ..models import Integration, Project
@@ -35,7 +36,18 @@ def get_form_kwargs(self):
return kwargs
def get_context_data(self, **kwargs):
- kwargs['provider'] = get_plugin('PROJECT_ISSUE_PROVIDERS', self.provider_key)
+ plugin = (
+ Plugin.objects
+ .for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER, project=self.project,
+ user=self.request.user,format=self.provider_key
+ ).first()
+ )
+ if plugin is not None:
+ kwargs['provider'] = plugin.initialize_class()
+ else:
+ kwargs['provider'] = None
+
return super().get_context_data(**kwargs)
def get_success_url(self):
diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py
index 04600c6c16..902483eb13 100644
--- a/rdmo/projects/views/project.py
+++ b/rdmo/projects/views/project.py
@@ -13,7 +13,8 @@
from django.views.generic import DeleteView, DetailView, TemplateView
from django.views.generic.edit import FormMixin
-from rdmo.core.plugins import get_plugin, get_plugins
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from rdmo.core.views import CSRFViewMixin, ObjectPermissionMixin, RedirectViewMixin, StoreIdViewMixin
from rdmo.questions.models import Catalog
@@ -85,7 +86,15 @@ def get_context_data(self, **kwargs):
context['ancestors_import'] = ancestors_import
context['memberships'] = memberships.order_by('user__last_name', '-project__level')
context['integrations'] = integrations.order_by('provider_key', '-project__level')
- context['providers'] = get_plugins('PROJECT_ISSUE_PROVIDERS')
+ context['providers'] = {}
+ for plugin in Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER,
+ project=project,
+ user=self.request.user
+ ):
+ if plugin.url_name:
+ context['providers'][plugin.url_name] = plugin.initialize_class()
+
context['issues'] = [
issue for issue in project.issues.order_by('-status', 'task__order', 'task__uri') if issue.resolve(values)
]
@@ -93,8 +102,11 @@ def get_context_data(self, **kwargs):
context['snapshots'] = project.snapshots.all()
context['invites'] = project.invites.all()
context['membership'] = Membership.objects.filter(project=project, user=self.request.user).first()
+ upload_plugins = Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_IMPORT, project=project,user=self.request.user
+ )
context['upload_accept'] = ','.join([
- suffix for suffixes in get_upload_accept().values() for suffix in suffixes
+ suffix for suffixes in get_upload_accept(upload_plugins).values() for suffix in suffixes
])
return context
@@ -175,10 +187,14 @@ class ProjectExportView(ObjectPermissionMixin, DetailView):
permission_required = 'projects.export_project_object'
def get_export_plugin(self):
- export_plugin = get_plugin('PROJECT_EXPORTS', self.kwargs.get('format'))
- if export_plugin is None:
+ export_plugin_instance = Plugin.objects.for_context(
+ project=self.object, plugin_type=PLUGIN_TYPES.PROJECT_EXPORT,
+ user=self.request.user, format=self.kwargs.get('format')
+ ).first()
+ if export_plugin_instance is None:
raise Http404
+ export_plugin = export_plugin_instance.initialize_class()
export_plugin.request = self.request
export_plugin.project = self.object
diff --git a/rdmo/projects/views/snapshot.py b/rdmo/projects/views/snapshot.py
index 437f8acd01..5b0793ce00 100644
--- a/rdmo/projects/views/snapshot.py
+++ b/rdmo/projects/views/snapshot.py
@@ -5,7 +5,8 @@
from django.urls import reverse
from django.views.generic import CreateView, DetailView, UpdateView
-from rdmo.core.plugins import get_plugin
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
from rdmo.core.views import ObjectPermissionMixin, RedirectViewMixin
from ..forms import SnapshotCreateForm
@@ -78,10 +79,16 @@ def get_permission_object(self):
return self.get_object().project
def get_export_plugin(self):
- export_plugin = get_plugin('PROJECT_SNAPSHOT_EXPORTS', self.kwargs.get('format'))
- if export_plugin is None:
+ export_plugins = Plugin.objects.for_context(
+ project=self.get_object().project,
+ plugin_type=PLUGIN_TYPES.PROJECT_EXPORT,
+ user=self.request.user, format=self.kwargs.get('format')
+ )
+ if not export_plugins.exists():
raise Http404
+ export_plugin_instance = export_plugins.first()
+ export_plugin = export_plugin_instance.initialize_class()
export_plugin.request = self.request
export_plugin.snapshot = self.object
diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py
index d6df96c770..54616ff8f2 100644
--- a/rdmo/projects/viewsets.py
+++ b/rdmo/projects/viewsets.py
@@ -21,6 +21,7 @@
from rest_framework_extensions.mixins import NestedViewSetMixin
from rdmo.conditions.models import Condition
+from rdmo.config.models import Plugin
from rdmo.core.permissions import HasModelPermission
from rdmo.core.utils import human2bytes, is_truthy, return_file_response
from rdmo.options.models import OptionSet
@@ -28,6 +29,7 @@
from rdmo.tasks.models import Task
from rdmo.views.models import View
+from ..config.constants import PLUGIN_TYPES
from .filters import (
AttributeFilterBackend,
OptionFilterBackend,
@@ -58,6 +60,7 @@
IssueSerializer,
MembershipSerializer,
ProjectCopySerializer,
+ ProjectImportPluginSerializer,
ProjectIntegrationSerializer,
ProjectInviteSerializer,
ProjectInviteUpdateSerializer,
@@ -261,21 +264,28 @@ def options(self, request, pk=None):
# check if the optionset belongs to this catalog and if it has a provider
project.catalog.prefetch_elements()
- if Question.objects.filter_by_catalog(project.catalog).filter(optionsets=optionset) and \
- optionset.provider is not None:
+ if (
+ Question.objects.filter_by_catalog(project.catalog).filter(optionsets=optionset).exists()
+ and optionset.has_plugins
+ ):
options = []
- for option in optionset.provider.get_options(project, search=request.GET.get('search'),
- user=request.user, site=request.site):
- if 'id' not in option:
- raise RuntimeError(f"'id' is missing in options of '{optionset.provider.class_name}'")
- elif 'text' not in option:
- raise RuntimeError(f"'text' is missing in options of '{optionset.provider.class_name}'")
- if 'text_and_help' not in option:
- if 'help' in option:
- option['text_and_help'] = '{text} [{help}]'.format(**option)
- else:
- option['text_and_help'] = '{text}'.format(**option)
- options.append(option)
+ for plugin in optionset.plugins.all():
+ provider = plugin.initialize_class()
+ if provider is None:
+ continue # skip when plugin class initialization fails
+
+ for option in provider.get_options(project, search=request.GET.get('search'),
+ user=request.user, site=request.site):
+ if 'id' not in option:
+ raise RuntimeError(f"'id' is missing in options of '{provider.class_name}'")
+ elif 'text' not in option:
+ raise RuntimeError(f"'text' is missing in options of '{provider.class_name}'")
+ if 'text_and_help' not in option:
+ if 'help' in option:
+ option['text_and_help'] = '{text} [{help}]'.format(**option)
+ else:
+ option['text_and_help'] = '{text}'.format(**option)
+ options.append(option)
return Response(options)
@@ -387,16 +397,14 @@ def contact(self, request, pk):
@action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, ))
def upload_accept(self, request):
- return Response(get_upload_accept())
+ plugins = Plugin.objects.for_context(plugin_type=PLUGIN_TYPES.PROJECT_IMPORT, user=self.request.user)
+ return Response(get_upload_accept(plugins))
@action(detail=False, permission_classes=(IsAuthenticated, ))
def imports(self, request):
- return Response([{
- 'key': key,
- 'label': label,
- 'class_name': class_name,
- 'href': reverse('project_create_import', args=[key])
- } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] )
+ plugins = Plugin.objects.for_context(plugin_type=PLUGIN_TYPES.PROJECT_IMPORT, user=request.user)
+ serializer = ProjectImportPluginSerializer(plugins, many=True)
+ return Response(serializer.data)
def perform_create(self, serializer):
project = serializer.save(site=get_current_site(self.request))
diff --git a/rdmo/questions/migrations/0032_meta.py b/rdmo/questions/migrations/0032_meta.py
index c1adc06061..a33641ebd0 100644
--- a/rdmo/questions/migrations/0032_meta.py
+++ b/rdmo/questions/migrations/0032_meta.py
@@ -10,6 +10,7 @@ class Migration(migrations.Migration):
dependencies = [
('questions', '0031_rename_attribute_entity_to_attribute'),
+ ('domain', '0038_rename_attributeentity_to_attribute'), # post hoc fix for mysql/sqlite
]
operations = [
diff --git a/rdmo/services/validators.py b/rdmo/services/validators.py
index b6f02f68f0..86282bb9ff 100644
--- a/rdmo/services/validators.py
+++ b/rdmo/services/validators.py
@@ -1,13 +1,18 @@
from rest_framework.serializers import ValidationError
-from rdmo.core.plugins import get_plugin
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
class ProviderValidator:
def __call__(self, data):
provider_key = data.get('provider_key')
- provider = get_plugin('PROJECT_ISSUE_PROVIDERS', provider_key)
+ plugin = Plugin.objects.for_context(
+ plugin_type=PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER,
+ format=provider_key
+ ).first()
+ provider = plugin.initialize_class() if plugin else None
if provider is None:
raise ValidationError({
'provider_key': 'Please provide a valid provider.'
diff --git a/rdmo/services/views.py b/rdmo/services/views.py
index 0bea0b1cdc..fb69a84a77 100644
--- a/rdmo/services/views.py
+++ b/rdmo/services/views.py
@@ -1,18 +1,24 @@
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
-from rdmo.core.plugins import get_plugin
+from rdmo.config.constants import PLUGIN_TYPES
+from rdmo.config.models import Plugin
PROVIDER_TYPES = [
- 'PROJECT_ISSUE_PROVIDERS',
- 'PROJECT_EXPORTS',
- 'PROJECT_IMPORTS'
+ PLUGIN_TYPES.PROJECT_ISSUE_PROVIDER,
+ PLUGIN_TYPES.PROJECT_EXPORT,
+ PLUGIN_TYPES.PROJECT_IMPORT,
]
def oauth_callback(request, provider_key):
- for provider_type in PROVIDER_TYPES:
- provider = get_plugin(provider_type, provider_key)
+ for plugin in (Plugin.objects.for_context(
+ user=request.user,
+ format=provider_key,
+ plugin_types=PROVIDER_TYPES
+ )
+ ):
+ provider = plugin.initialize_class()
if provider and provider.get_from_session(request, 'state'):
return provider.callback(request)
diff --git a/testing/config/settings/base.py b/testing/config/settings/base.py
index 23cd12fa07..31feab92d7 100644
--- a/testing/config/settings/base.py
+++ b/testing/config/settings/base.py
@@ -83,29 +83,31 @@
PROJECT_SEND_INVITE = True
-PROJECT_SNAPSHOT_EXPORTS = [
- ('xml', _('RDMO XML'), 'rdmo.projects.exports.RDMOXMLExport'),
-]
-
EMAIL_RECIPIENTS_CHOICES = [
('email@example.com', 'Emmi Email '),
]
EMAIL_RECIPIENTS_INPUT = True
-OPTIONSET_PROVIDERS = [
- ('simple', _('Simple provider'), 'rdmo.options.providers.SimpleProvider')
-]
-
-PROJECT_ISSUE_PROVIDERS = [
- ('simple', _('Simple provider'), 'rdmo.projects.providers.SimpleIssueProvider')
+INSTALLED_APPS += [
+ 'plugins', # introduced in 2.5, rdmo/testing/plugins
]
-PROJECT_IMPORTS += [
- ('url', _('from URL'), 'rdmo.projects.imports.URLImport'),
+PLUGINS = [ # introduced in 2.5
+ # internal rdmo plugins
+ 'rdmo.projects.exports.RDMOXMLExport',
+ 'rdmo.projects.exports.CSVCommaExport',
+ 'rdmo.projects.exports.CSVSemicolonExport',
+ 'rdmo.projects.exports.JSONExport',
+ 'rdmo.projects.imports.RDMOXMLImport',
+ 'rdmo.projects.imports.URLImport',
+ # rdmo/testing/plugins
+ 'plugins.optionset_providers.providers.SimpleProvider', # here or in app/test
+ 'plugins.project_issue_providers.providers.SimpleIssueProvider',
+ 'plugins.project_export.exports.SimpleExportPlugin',
+ 'plugins.project_snapshot_export.exports.SimpleSnapshotExportPlugin',
+ 'plugins.project_import.imports.SimpleImportPlugin',
]
-PROJECT_IMPORTS_LIST = ['url']
-
PROJECT_VALUES_VALIDATION = True
PROJECT_CONTACT = True
diff --git a/testing/fixtures/config.json b/testing/fixtures/config.json
new file mode 100644
index 0000000000..efc8b2594a
--- /dev/null
+++ b/testing/fixtures/config.json
@@ -0,0 +1,365 @@
+[
+{
+ "model": "config.plugin",
+ "pk": 1,
+ "fields": {
+ "created": "2025-10-17T15:22:18.643Z",
+ "updated": "2025-10-17T20:46:23.845Z",
+ "uri": "http://example.com/terms/plugins/testing/project/export",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "testing/project/export",
+ "url_name": "simple",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "Test Export Plugin",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": false,
+ "python_path": "plugins.project_export.exports.SimpleExportPlugin",
+ "plugin_meta": {},
+ "plugin_type": "project_export",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 2,
+ "fields": {
+ "created": "2025-10-17T15:25:52.789Z",
+ "updated": "2025-10-17T15:25:52.789Z",
+ "uri": "http://example.com/terms/plugins/testing/project/import",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "testing/project/import",
+ "url_name": "simple",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "Test Import Plugin",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": false,
+ "python_path": "plugins.project_import.imports.SimpleImportPlugin",
+ "plugin_meta": {
+ "accept": {
+ "text/plain": [
+ ".txt"
+ ]
+ },
+ "upload": true
+ },
+ "plugin_type": "project_import",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 3,
+ "fields": {
+ "created": "2025-10-17T19:39:03.248Z",
+ "updated": "2025-10-17T19:39:03.248Z",
+ "uri": "http://example.com/terms/plugins/testing/optionset-provider",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "testing/optionset-provider",
+ "url_name": "simple",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": false,
+ "python_path": "plugins.optionset_providers.providers.SimpleProvider",
+ "plugin_type": "optionset_provider",
+ "plugin_meta": {
+ "search": false,
+ "refresh": true
+ },
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 4,
+ "fields": {
+ "created": "2025-11-11T11:11:11Z",
+ "updated": "2025-11-11T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/export/xml",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/export/xml",
+ "url_name": "xml",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "XML Export",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "rdmo.projects.exports.RDMOXMLExport",
+ "plugin_meta": {},
+ "plugin_type": "project_export",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 5,
+ "fields": {
+ "created": "2025-11-11T11:11:11Z",
+ "updated": "2025-11-11T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/export/csvcomma",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/export/csvcomma",
+ "url_name": "csvcomma",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "CSV Comma Export",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "rdmo.projects.exports.CSVCommaExport",
+ "plugin_meta": {
+ "delimiter": ","
+ },
+ "plugin_type": "project_export",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 6,
+ "fields": {
+ "created": "2025-11-11T11:11:11Z",
+ "updated": "2025-11-11T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/export/csvsemicolon",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/export/csvsemicolon",
+ "url_name": "csvsemicolon",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "CSV Semicolon Export",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "rdmo.projects.exports.CSVSemicolonExport",
+ "plugin_meta": {
+ "delimiter": ";"
+ },
+ "plugin_type": "project_export",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 7,
+ "fields": {
+ "created": "2025-11-11T11:11:11Z",
+ "updated": "2025-11-11T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/export/json",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/export/json",
+ "url_name": "json",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "JSON Export",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "rdmo.projects.exports.JSONExport",
+ "plugin_type": "project_export",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 8,
+ "fields": {
+ "created": "2025-11-11T11:11:11Z",
+ "updated": "2025-11-11T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/import/xml",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/import/xml",
+ "url_name": "xml",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "XML Import",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "rdmo.projects.imports.RDMOXMLImport",
+ "plugin_meta": {
+ "accept": {
+ "application/xml": [
+ ".xml"
+ ]
+ },
+ "upload": true
+ },
+ "plugin_type": "project_import",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+{
+ "model": "config.plugin",
+ "pk": 9,
+ "fields": {
+ "created": "2025-11-24T11:11:11Z",
+ "updated": "2025-11-24T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/import/url",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/import/url",
+ "url_name": "url",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "URL Import",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "rdmo.projects.imports.URLImport",
+ "plugin_meta": {
+ "accept": false,
+ "upload": false
+ },
+ "plugin_type": "project_import",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+},
+ {
+ "model": "config.plugin",
+ "pk": 10,
+ "fields": {
+ "created": "2025-11-24T11:11:11Z",
+ "updated": "2025-11-24T11:11:11Z",
+ "uri": "http://example.com/terms/plugins/project/issue/provider/simple",
+ "uri_prefix": "http://example.com/terms",
+ "uri_path": "project/issue/provider/simple",
+ "url_name": "simple",
+ "comment": "",
+ "locked": false,
+ "order": 0,
+ "title_lang1": "URL Import",
+ "title_lang2": "",
+ "title_lang3": "",
+ "title_lang4": "",
+ "title_lang5": "",
+ "help_lang1": "",
+ "help_lang2": "",
+ "help_lang3": "",
+ "help_lang4": "",
+ "help_lang5": "",
+ "available": true,
+ "python_path": "plugins.project_issue_providers.providers.SimpleIssueProvider",
+ "plugin_meta": {},
+ "plugin_type": "project_issue_provider",
+ "plugin_settings": {},
+ "sites": [],
+ "editors": [],
+ "groups": [],
+ "catalogs": [1]
+ }
+ }
+]
diff --git a/testing/fixtures/options.json b/testing/fixtures/options.json
index 7b682a9bbf..bf29915e43 100644
--- a/testing/fixtures/options.json
+++ b/testing/fixtures/options.json
@@ -9,9 +9,8 @@
"comment": "",
"locked": false,
"order": 0,
- "provider_key": "",
"editors": [],
- "conditions": []
+ "plugins": []
}
},
{
@@ -24,9 +23,9 @@
"comment": "",
"locked": false,
"order": 1,
- "provider_key": "",
"editors": [],
- "conditions": []
+ "conditions": [],
+ "plugins": []
}
},
{
@@ -39,11 +38,11 @@
"comment": "",
"locked": false,
"order": 2,
- "provider_key": "",
"editors": [],
"conditions": [
16
- ]
+ ],
+ "plugins": []
}
},
{
@@ -56,7 +55,7 @@
"comment": "",
"locked": false,
"order": 4,
- "provider_key": "simple",
+ "plugins": [3],
"editors": [],
"conditions": []
}
@@ -71,11 +70,11 @@
"comment": "",
"locked": false,
"order": 0,
- "provider_key": "",
"editors": [
2
],
- "conditions": []
+ "conditions": [],
+ "plugins": []
}
},
{
@@ -88,11 +87,11 @@
"comment": "",
"locked": false,
"order": 0,
- "provider_key": "",
"editors": [
3
],
- "conditions": []
+ "conditions": [],
+ "plugins": []
}
},
{
diff --git a/testing/plugins/__init__.py b/testing/plugins/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/testing/plugins/optionset_providers/__init__.py b/testing/plugins/optionset_providers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/testing/plugins/optionset_providers/providers.py b/testing/plugins/optionset_providers/providers.py
new file mode 100644
index 0000000000..aa0ec1b9ea
--- /dev/null
+++ b/testing/plugins/optionset_providers/providers.py
@@ -0,0 +1,25 @@
+from rdmo.options.providers import Provider
+
+
+class SimpleProvider(Provider):
+ default_uri_prefix = "https://rdmorganiser.github.io/terms"
+ refresh = True
+
+ def get_options(self, project, search=None, user=None, site=None):
+ return [
+ {
+ 'id': 'simple_1',
+ 'text': 'Simple answer 1',
+ 'help': 'One'
+ },
+ {
+ 'id': 'simple_2',
+ 'text': 'Simple answer 2',
+ 'help': 'Two'
+ },
+ {
+ 'id': 'simple_3',
+ 'text': 'Simple answer 3',
+ 'help': 'Three'
+ }
+ ]
diff --git a/testing/plugins/project_export/__init__.py b/testing/plugins/project_export/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/testing/plugins/project_export/exports.py b/testing/plugins/project_export/exports.py
new file mode 100644
index 0000000000..9376ba0a87
--- /dev/null
+++ b/testing/plugins/project_export/exports.py
@@ -0,0 +1,5 @@
+from rdmo.projects.exports import JSONExport
+
+
+class SimpleExportPlugin(JSONExport):
+ default_uri_prefix = "https://rdmorganiser.github.io/terms"
diff --git a/testing/plugins/project_import/__init__.py b/testing/plugins/project_import/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/testing/plugins/project_import/imports.py b/testing/plugins/project_import/imports.py
new file mode 100644
index 0000000000..94ea3b8e1d
--- /dev/null
+++ b/testing/plugins/project_import/imports.py
@@ -0,0 +1,17 @@
+from rdmo.projects.imports import Import
+from rdmo.projects.models import Project
+
+
+class SimpleImportPlugin(Import):
+
+ accept = {"text/plain": [".txt"]}
+ default_uri_prefix = "https://rdmorganiser.github.io/terms"
+ url_name = "simple"
+
+ def check(self) -> bool:
+ # Approve files ending with .txt
+ return str(self.file_name).endswith(".txt")
+
+ def process(self):
+ # Create a new Project for testing. You could populate values/tasks/etc.
+ self.project = Project(title="Imported test project")
diff --git a/testing/plugins/project_issue_providers/__init__.py b/testing/plugins/project_issue_providers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/testing/plugins/project_issue_providers/providers.py b/testing/plugins/project_issue_providers/providers.py
new file mode 100644
index 0000000000..cdbd508295
--- /dev/null
+++ b/testing/plugins/project_issue_providers/providers.py
@@ -0,0 +1,66 @@
+import json
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.http import Http404, HttpResponse
+from django.utils.translation import gettext_lazy as _
+
+from rdmo.projects.providers import OauthIssueProvider
+
+
+class SimpleIssueProvider(OauthIssueProvider):
+ default_uri_prefix = "https://rdmorganiser.github.io/terms"
+
+ add_label = _('Add Simple integration')
+ send_label = _('Send to Simple')
+ description = _('This integration allow the creation of issues in arbitrary Simple repositories. '
+ 'The upload of attachments is not supported.')
+
+ @property
+ def fields(self):
+ return [
+ {
+ 'key': 'project_url',
+ 'placeholder': 'https://example.com/projects/',
+ 'help': _('The URL of the project to send tasks to.')
+ },
+ {
+ 'key': 'secret',
+ 'placeholder': 'Secret (random) string',
+ 'help': _('The secret for a webhook to close a task (optional).'),
+ 'required': False,
+ 'secret': True
+ }
+ ]
+
+ def get(self, request, url):
+ raise NotImplementedError
+
+ def post(self, request, url, json=None, files=None, multipart=None):
+ raise NotImplementedError
+
+ def webhook(self, request, integration):
+ secret = integration.get_option_value('secret')
+ header_signature = request.headers.get('X-Secret')
+ if secret == header_signature:
+ try:
+ payload = json.loads(request.body.decode())
+ except json.decoder.JSONDecodeError as e:
+ return HttpResponse(e, status=400)
+
+ action = payload.get('action')
+ url = payload.get('url')
+
+ try:
+ issue_resource = integration.resources.get(url=url)
+ if action == 'closed':
+ issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_CLOSED
+ else:
+ issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_IN_PROGRESS
+
+ issue_resource.issue.save()
+ except ObjectDoesNotExist:
+ pass
+
+ return HttpResponse(status=200)
+
+ raise Http404
diff --git a/testing/plugins/project_snapshot_export/__init__.py b/testing/plugins/project_snapshot_export/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/testing/plugins/project_snapshot_export/exports.py b/testing/plugins/project_snapshot_export/exports.py
new file mode 100644
index 0000000000..f1279ba535
--- /dev/null
+++ b/testing/plugins/project_snapshot_export/exports.py
@@ -0,0 +1,5 @@
+from rdmo.projects.exports import RDMOXMLExport
+
+
+class SimpleSnapshotExportPlugin(RDMOXMLExport):
+ default_uri_prefix = "https://rdmorganiser.github.io/terms"
diff --git a/testing/xml/elements/optionsets.xml b/testing/xml/elements/optionsets.xml
index d72fb54d63..a97735e06f 100644
--- a/testing/xml/elements/optionsets.xml
+++ b/testing/xml/elements/optionsets.xml
@@ -4,7 +4,6 @@
http://example.com/terms
condition
-
@@ -26,7 +25,6 @@
http://example.com/terms
one_two_three
-
@@ -68,7 +66,6 @@
http://example.com/terms
one_two_three_other
-
@@ -132,8 +129,10 @@
http://example.com/terms
plugin
- simple
+
+
+
diff --git a/testing/xml/elements/plugins.xml b/testing/xml/elements/plugins.xml
new file mode 100644
index 0000000000..00b80a97f8
--- /dev/null
+++ b/testing/xml/elements/plugins.xml
@@ -0,0 +1,24 @@
+
+
+
+ https://example.com/terms
+ simple-export-plugin
+ Example export plugin imported via XML
+ Example Export Plugin
+ Beispiel Export Plugin
+ plugins.project_export.exports.SimpleExportPlugin
+ {"token": "abc123", "sandbox": true, "format": "csv"}
+ 0
+
+
+ https://example.com/terms
+ simple-import-plugin
+ Simple import plugin for testing
+ Simple Import Plugin
+ Einfaches Import Plugin
+ plugins.project_import.imports.SimpleImportPlugin
+ {"filetypes": ["xml", "json"], "enabled": false}
+ 1
+ simple
+
+
diff --git a/testing/xml/elements/updated-and-changed/optionsets-1.xml b/testing/xml/elements/updated-and-changed/optionsets-1.xml
index c072d471da..f9604fff2b 100644
--- a/testing/xml/elements/updated-and-changed/optionsets-1.xml
+++ b/testing/xml/elements/updated-and-changed/optionsets-1.xml
@@ -4,7 +4,6 @@
http://example.com/terms
condition
-
@@ -26,7 +25,6 @@
http://example.com/terms
one_two_three
-
@@ -70,7 +68,6 @@
http://example.com/terms
one_two_three_other
-
@@ -79,6 +76,7 @@
+