From a81932ef5b645142449c7475b522e1e51013586d Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Tue, 23 Dec 2025 17:30:39 +0100 Subject: [PATCH 01/13] [plugins] Add more logging to plugin commands --- zou/app/services/plugins_service.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/zou/app/services/plugins_service.py b/zou/app/services/plugins_service.py index f080121f3..fba8a6c82 100644 --- a/zou/app/services/plugins_service.py +++ b/zou/app/services/plugins_service.py @@ -27,11 +27,11 @@ def install_plugin(path, force=False): if plugin: current = semver.Version.parse(plugin.version) new = semver.Version.parse(str(manifest.version)) - print(f"[Plugins] Upgrading plugin {manifest.id} from version {current} to {new}...") + print( + f"[Plugins] Upgrading plugin {manifest.id} from version {current} to {new}..." + ) if not force and new <= current: - print( - f"⚠️ Plugin version {new} is not newer than {current}." - ) + print(f"⚠️ Plugin version {new} is not newer than {current}.") plugin.update_no_commit(manifest.to_model_dict()) print(f"[Plugins] Plugin {manifest.id} upgraded.") else: @@ -46,7 +46,9 @@ def install_plugin(path, force=False): run_plugin_migrations(plugin_path, plugin) print(f"[Plugins] Database migrations for {manifest.id} applied.") except Exception: - print(f"❌ [Plugins] An error occurred while installing/updating {manifest.id}...") + print( + f"❌ [Plugins] An error occurred while installing/updating {manifest.id}..." + ) """" uninstall_plugin_files(manifest.id) print(f"[Plugins] Plugin {manifest.id} uninstalled.") @@ -56,7 +58,7 @@ def install_plugin(path, force=False): raise Plugin.commit() - print_added_routes(plugin,plugin_path) + print_added_routes(plugin, plugin_path) return plugin.serialize() @@ -98,7 +100,7 @@ def print_added_routes(plugin, plugin_path): try: plugin_module = importlib.import_module(plugin.plugin_id) - if hasattr(plugin_module, 'routes'): + if hasattr(plugin_module, "routes"): routes = plugin_module.routes for route in routes: print(f" - /plugins/{plugin.plugin_id}{route[0]}") @@ -110,4 +112,4 @@ def print_added_routes(plugin, plugin_path): if abs_plugin_path in sys.path: sys.path.remove(abs_plugin_path) - print("--------------------------------") \ No newline at end of file + print("--------------------------------") From bdb771e2932781d3f14934dcdcd6fff4d6d3f2c6 Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Tue, 23 Dec 2025 17:32:03 +0100 Subject: [PATCH 02/13] [qa] code scrub --- zou/app/services/schedule_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zou/app/services/schedule_service.py b/zou/app/services/schedule_service.py index 4afb2485a..7d6f83303 100644 --- a/zou/app/services/schedule_service.py +++ b/zou/app/services/schedule_service.py @@ -55,7 +55,8 @@ def get_task_types_schedule_items(project_id): task_types = [ task_type for task_type in task_types - if task_type["for_entity"] in ["Asset", "Shot", "Sequence", "Episode", "Edit"] + if task_type["for_entity"] + in ["Asset", "Shot", "Sequence", "Episode", "Edit"] ] task_type_map = base_service.get_model_map_from_array(task_types) schedule_items = set( From 5c645cb5f4a042042ef17e261c1441a67a279bd1 Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Tue, 23 Dec 2025 17:32:40 +0100 Subject: [PATCH 03/13] [qa] code scrub --- zou/plugin_template/migrations/env.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zou/plugin_template/migrations/env.py b/zou/plugin_template/migrations/env.py index e908b1ab3..bab7290ae 100644 --- a/zou/plugin_template/migrations/env.py +++ b/zou/plugin_template/migrations/env.py @@ -20,7 +20,8 @@ # Add zou tables module.plugin_metadata.tables = { - **db.metadata.tables, **module.plugin_metadata.tables + **db.metadata.tables, + **module.plugin_metadata.tables, } # Database URL (passed by Alembic) From c793fda314c01d981cc90304217a5774eacdd262 Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Tue, 23 Dec 2025 17:32:49 +0100 Subject: [PATCH 04/13] [plugins] Allow to serve static files (frontend) --- zou/app/models/plugin.py | 2 + zou/app/utils/plugins.py | 90 ++++++++++++++++++- ...20ea5a7_add_frontend_options_to_plugins.py | 40 +++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py diff --git a/zou/app/models/plugin.py b/zou/app/models/plugin.py index cf2e2e077..d3be75094 100644 --- a/zou/app/models/plugin.py +++ b/zou/app/models/plugin.py @@ -22,3 +22,5 @@ class Plugin(db.Model, BaseMixin, SerializerMixin): website = db.Column(URLType) license = db.Column(db.String(80), nullable=False) revision = db.Column(db.String(12), nullable=True) + frontend_project_enabled = db.Column(db.Boolean(), default=False) + frontend_studio_enabled = db.Column(db.Boolean(), default=False) diff --git a/zou/app/utils/plugins.py b/zou/app/utils/plugins.py index 630357689..076aa99ce 100644 --- a/zou/app/utils/plugins.py +++ b/zou/app/utils/plugins.py @@ -12,12 +12,59 @@ from alembic import command from alembic.config import Config +from collections.abc import MutableMapping from flask import Blueprint, current_app +from flask_restful import Resource from pathlib import Path -from collections.abc import MutableMapping from zou.app.utils.api import configure_api_from_blueprint +from flask import send_from_directory, abort, current_app + + +class StaticResource(Resource): + + plugin_id = None + location = None + + def get(self, filename): + """ + Serve static files + --- + tags: + - Static + parameters: + - in: path + name: filename + required: true + schema: + type: string + description: Name of the file to serve + responses: + 200: + description: File served successfully + 404: + description: File not found + """ + print(self.plugin_id) + print(self.location) + static_folder = ( + Path(current_app.config.get("PLUGIN_FOLDER", "plugins")) + / self.plugin_id + / "frontend" + / self.location + / "dist" + ) + file_path = static_folder / filename + print(file_path) + + if not file_path.exists() or not file_path.is_file(): + abort(404) + + return send_from_directory( + str(static_folder), filename, conditional=True, max_age=3600 + ) + class PluginManifest(MutableMapping): def __init__(self, data): @@ -56,6 +103,11 @@ def validate(self): self.data["maintainer_name"] = name self.data["maintainer_email"] = email_addr + if "frontend_project_enabled" not in self.data: + self.data["frontend_project_enabled"] = False + if "frontend_studio_enabled" not in self.data: + self.data["frontend_studio_enabled"] = False + def to_model_dict(self): return { "plugin_id": self.data["id"], @@ -66,6 +118,12 @@ def to_model_dict(self): "maintainer_email": self.data.get("maintainer_email"), "website": self.data.get("website"), "license": self.data["license"], + "frontend_project_enabled": self.data.get( + "frontend_project_enabled", False + ), + "frontend_studio_enabled": self.data.get( + "frontend_studio_enabled", False + ), } def __getitem__(self, key): @@ -111,6 +169,7 @@ def load_plugin(app, plugin_path, init_plugin=True): raise Exception(f"Plugin {manifest['id']} has no routes.") routes = plugin_module.routes + add_static_routes(manifest, routes) blueprint = Blueprint(manifest["id"], manifest["id"]) configure_api_from_blueprint(blueprint, routes) app.register_blueprint(blueprint, url_prefix=f"/plugins/{manifest['id']}") @@ -342,3 +401,32 @@ def uninstall_plugin_files(plugin_path): shutil.rmtree(plugin_path) return True return False + + +def add_static_routes(manifest, routes): + """ + Add static routes to the manifest. + """ + + class ProjectStaticResource(StaticResource): + + def __init__(self): + self.plugin_id = manifest.id + self.location = "project" + super().__init__() + + class StudioStaticResource(StaticResource): + + def __init__(self): + self.plugin_id = manifest.id + self.location = "studio" + super().__init__() + + if manifest["frontend_project_enabled"]: + routes.append( + (f"/frontend/project/", ProjectStaticResource) + ) + if manifest.frontend_studio_enabled: + routes.append( + (f"/frontend/studio/", StudioStaticResource) + ) diff --git a/zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py b/zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py new file mode 100644 index 000000000..9b3a91455 --- /dev/null +++ b/zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py @@ -0,0 +1,40 @@ +"""add frontend options to plugins + +Revision ID: 9a9df20ea5a7 +Revises: 12208e50bf18 +Create Date: 2025-12-23 00:47:50.621868 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = "9a9df20ea5a7" +down_revision = "12208e50bf18" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("plugin", schema=None) as batch_op: + batch_op.add_column( + sa.Column("frontend_project_enabled", sa.Boolean(), nullable=True) + ) + batch_op.add_column( + sa.Column("frontend_studio_enabled", sa.Boolean(), nullable=True) + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("plugin", schema=None) as batch_op: + batch_op.drop_column("frontend_studio_enabled") + batch_op.drop_column("frontend_project_enabled") + + # ### end Alembic commands ### From 4c55c19fd6069b76053a476ac5c8241d0373204b Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Wed, 24 Dec 2025 10:09:44 +0100 Subject: [PATCH 05/13] [persons] Fix edge case where is_bot flag is None --- zou/app/blueprints/crud/person.py | 2 +- zou/app/services/persons_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/zou/app/blueprints/crud/person.py b/zou/app/blueprints/crud/person.py index 727a3b3ea..79b732873 100644 --- a/zou/app/blueprints/crud/person.py +++ b/zou/app/blueprints/crud/person.py @@ -604,7 +604,7 @@ def pre_update(self, instance_dict, data): if ( instance_dict["email"] in config.PROTECTED_ACCOUNTS and instance_dict["id"] != persons_service.get_current_user()["id"] - and instance_dict["is_bot"] == False + and instance_dict.get("is_bot", False) == False ): message = None if data.get("active") is False: diff --git a/zou/app/services/persons_service.py b/zou/app/services/persons_service.py index f58b58d18..d11b4e1cb 100644 --- a/zou/app/services/persons_service.py +++ b/zou/app/services/persons_service.py @@ -287,7 +287,7 @@ def update_person(person_id, data, bypass_protected_accounts=False): if ( not bypass_protected_accounts and person.email in config.PROTECTED_ACCOUNTS - and person.is_bot == False + and not person.is_bot ): message = None if data.get("active") is False: From 4924127cff1d8180745a41b6670606fa434cca3d Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Wed, 24 Dec 2025 10:25:28 +0100 Subject: [PATCH 06/13] [qa] Fix sg import tests --- tests/source/shotgun/test_shotgun_import_teams.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/source/shotgun/test_shotgun_import_teams.py b/tests/source/shotgun/test_shotgun_import_teams.py index a09e2c938..baf8c8153 100644 --- a/tests/source/shotgun/test_shotgun_import_teams.py +++ b/tests/source/shotgun/test_shotgun_import_teams.py @@ -11,16 +11,18 @@ def test_import_project_connections(self): self.load_fixture("projects") self.load_fixture("projectconnections") projects = self.get("data/projects") + projects = sorted(projects, key=lambda x: x["name"]) project = projects_service.get_project( projects[0]["id"], relations=True, ) - self.assertEqual(len(project["team"]), 1) + self.assertEqual(project["name"], "Agent327") + self.assertEqual(len(project["team"]), 2) project = projects_service.get_project( projects[1]["id"], relations=True, ) - self.assertEqual(len(project["team"]), 2) + self.assertEqual(len(project["team"]), 1) def test_import_projects_twice(self): self.load_fixture("persons") From 9a30e75b54e2f60b995f59a7714d59829b7cb05b Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Wed, 24 Dec 2025 10:34:18 +0100 Subject: [PATCH 07/13] [plugins] Allow to install a plugin via a git repo --- zou/app/services/plugins_service.py | 41 ++++++++++++++++++++++------- zou/app/utils/plugins.py | 34 ++++++++++++++++++++++++ zou/cli.py | 2 ++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/zou/app/services/plugins_service.py b/zou/app/services/plugins_service.py index fba8a6c82..db804dec2 100644 --- a/zou/app/services/plugins_service.py +++ b/zou/app/services/plugins_service.py @@ -1,4 +1,5 @@ import semver +import shutil from pathlib import Path from zou.app import config, db @@ -9,16 +10,32 @@ downgrade_plugin_migrations, uninstall_plugin_files, install_plugin_files, + clone_git_repo, ) def install_plugin(path, force=False): """ Install a plugin: create folder, copy files, run migrations. + Supports local paths, zip files, and git repository URLs. """ - path = Path(path) - if not path.exists(): - raise FileNotFoundError(f"Plugin path '{path}' does not exist.") + is_git_url = ( + path.startswith("http://") + or path.startswith("https://") + or path.startswith("git://") + or path.startswith("ssh://") + or path.startswith("git@") + ) + + temp_dir = None + if is_git_url: + cloned_path = clone_git_repo(path) + temp_dir = cloned_path.parent + path = cloned_path + else: + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Plugin path '{path}' does not exist.") manifest = PluginManifest.from_plugin_path(path) plugin = Plugin.query.filter_by(plugin_id=manifest.id).one_or_none() @@ -49,16 +66,15 @@ def install_plugin(path, force=False): print( f"❌ [Plugins] An error occurred while installing/updating {manifest.id}..." ) - """" - uninstall_plugin_files(manifest.id) - print(f"[Plugins] Plugin {manifest.id} uninstalled.") - db.session.rollback() - db.session.remove() - """ raise Plugin.commit() print_added_routes(plugin, plugin_path) + + if is_git_url: + if temp_dir and temp_dir.exists(): + shutil.rmtree(temp_dir) + return plugin.serialize() @@ -113,3 +129,10 @@ def print_added_routes(plugin, plugin_path): sys.path.remove(abs_plugin_path) print("--------------------------------") + + +def get_plugins(): + """ + Get all plugins. + """ + return [plugin.present() for plugin in Plugin.query.all()] diff --git a/zou/app/utils/plugins.py b/zou/app/utils/plugins.py index 076aa99ce..31775e263 100644 --- a/zou/app/utils/plugins.py +++ b/zou/app/utils/plugins.py @@ -9,6 +9,8 @@ import traceback import semver import shutil +import subprocess +import tempfile from alembic import command from alembic.config import Config @@ -403,6 +405,38 @@ def uninstall_plugin_files(plugin_path): return False +def clone_git_repo(git_url, temp_dir=None): + """ + Clone a git repository to a temporary directory. + Returns the path to the cloned directory. + """ + if temp_dir is None: + temp_dir = tempfile.mkdtemp(prefix="zou_plugin_") + + temp_dir = Path(temp_dir) + repo_name = git_url.rstrip("/").split("/")[-1].replace(".git", "") + clone_path = temp_dir / repo_name + + print(f"[Plugins] Cloning {git_url}...") + + try: + subprocess.run( + ["git", "clone", git_url, str(clone_path)], + check=True, + capture_output=True, + timeout=300, + ) + print(f"[Plugins] Successfully cloned {git_url}") + return clone_path + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode() if e.stderr else str(e) + raise ValueError(f"Failed to clone repository {git_url}: {error_msg}") + except FileNotFoundError: + raise ValueError( + "git is not available. Please install git to clone repositories." + ) + + def add_static_routes(manifest, routes): """ Add static routes to the manifest. diff --git a/zou/cli.py b/zou/cli.py index 4c2ef2483..0fd6211da 100755 --- a/zou/cli.py +++ b/zou/cli.py @@ -660,6 +660,7 @@ def renormalize_movie_preview_files( @click.option( "--path", required=True, + help="Plugin path: local directory, zip file, or git repository URL", ) @click.option( "--force", @@ -670,6 +671,7 @@ def renormalize_movie_preview_files( def install_plugin(path, force=False): """ Install a plugin and apply the migrations. + Supports local paths, zip files, and git repository URLs. """ with app.app_context(): plugins_service.install_plugin(path, force) From e9ad3eee128212c19873e95e3697788c06d7ccf5 Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Wed, 24 Dec 2025 10:34:48 +0100 Subject: [PATCH 08/13] [plugins] Add installed plugin list to the user context --- zou/app/models/plugin.py | 13 +++++++++++++ zou/app/services/user_service.py | 3 +++ 2 files changed, 16 insertions(+) diff --git a/zou/app/models/plugin.py b/zou/app/models/plugin.py index d3be75094..8965b4fb5 100644 --- a/zou/app/models/plugin.py +++ b/zou/app/models/plugin.py @@ -24,3 +24,16 @@ class Plugin(db.Model, BaseMixin, SerializerMixin): revision = db.Column(db.String(12), nullable=True) frontend_project_enabled = db.Column(db.Boolean(), default=False) frontend_studio_enabled = db.Column(db.Boolean(), default=False) + + def present(self): + return { + "id": self.id, + "plugin_id": self.plugin_id, + "name": self.name, + "description": self.description, + "version": self.version, + "maintainer_name": self.maintainer_name, + "maintainer_email": self.maintainer_email, + "frontend_project_enabled": self.frontend_project_enabled, + "frontend_studio_enabled": self.frontend_studio_enabled, + } diff --git a/zou/app/services/user_service.py b/zou/app/services/user_service.py index 7dd01da1b..d81abdf9f 100644 --- a/zou/app/services/user_service.py +++ b/zou/app/services/user_service.py @@ -15,6 +15,8 @@ from zou.app.models.task import Task from zou.app.models.task_type import TaskType +from zou.app.services import plugins_service + from zou.app.services import ( assets_service, @@ -1670,6 +1672,7 @@ def get_context(): "search_filters": get_filters(), "search_filter_groups": get_filter_groups(), "preview_background_files": files_service.get_preview_background_files(), + "plugins": plugins_service.get_plugins(), } if permissions.has_admin_permissions(): From 8a77a117189ac2dfbdff694e092ef7bc7a65cb8e Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Wed, 24 Dec 2025 10:35:11 +0100 Subject: [PATCH 09/13] [plugins] Add UI variables to the template manifest --- zou/plugin_template/manifest.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zou/plugin_template/manifest.toml b/zou/plugin_template/manifest.toml index 711812261..7013973f0 100644 --- a/zou/plugin_template/manifest.toml +++ b/zou/plugin_template/manifest.toml @@ -5,3 +5,5 @@ version = "0.1.0" maintainer = "Author " website = "mywebsite.com" license = "GPL-3.0-only" +frontend_project_enabled = false +frontend_studio_enabled = false From a95a3ac564c2fb6f52d07d43d9581774ebb99f92 Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Mon, 29 Dec 2025 15:55:30 +0100 Subject: [PATCH 10/13] [plugins] Add tests --- tests/misc/test_utils_plugins.py | 413 +++++++++++++++++++++++++ tests/services/test_plugins_service.py | 157 ++++++++++ 2 files changed, 570 insertions(+) create mode 100644 tests/misc/test_utils_plugins.py create mode 100644 tests/services/test_plugins_service.py diff --git a/tests/misc/test_utils_plugins.py b/tests/misc/test_utils_plugins.py new file mode 100644 index 000000000..928c5267a --- /dev/null +++ b/tests/misc/test_utils_plugins.py @@ -0,0 +1,413 @@ +# -*- coding: UTF-8 -*- +import os +import tempfile +import shutil +import zipfile +import subprocess +from pathlib import Path +from unittest.mock import patch, MagicMock, Mock, call + +from tests.base import ApiDBTestCase + +from zou.app import app, config +from zou.app.utils.plugins import ( + PluginManifest, + install_plugin_files, + uninstall_plugin_files, + clone_git_repo, + create_plugin_package, + create_plugin_skeleton, + add_static_routes, +) + + +class PluginManifestTestCase(ApiDBTestCase): + def setUp(self): + super(PluginManifestTestCase, self).setUp() + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + super(PluginManifestTestCase, self).tearDown() + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_plugin_manifest_from_file(self): + manifest_path = Path(self.temp_dir) / "manifest.toml" + manifest_content = '''id = "test_plugin" +name = "Test Plugin" +description = "A test plugin" +version = "0.1.0" +maintainer = "Test Author " +website = "https://example.com" +license = "MIT" +frontend_project_enabled = false +frontend_studio_enabled = false +''' + manifest_path.write_text(manifest_content) + + manifest = PluginManifest.from_file(manifest_path) + + self.assertEqual(manifest["id"], "test_plugin") + self.assertEqual(manifest["name"], "Test Plugin") + self.assertEqual(manifest["version"], "0.1.0") + self.assertEqual(manifest["license"], "MIT") + + def test_plugin_manifest_from_plugin_path_directory(self): + plugin_dir = Path(self.temp_dir) / "test_plugin" + plugin_dir.mkdir() + + manifest_path = plugin_dir / "manifest.toml" + manifest_content = '''id = "test_plugin" +name = "Test Plugin" +version = "0.1.0" +maintainer = "Test Author " +license = "MIT" +''' + manifest_path.write_text(manifest_content) + + manifest = PluginManifest.from_plugin_path(plugin_dir) + + self.assertEqual(manifest["id"], "test_plugin") + self.assertEqual(manifest["name"], "Test Plugin") + + def test_plugin_manifest_from_plugin_path_zip(self): + plugin_dir = Path(self.temp_dir) / "test_plugin" + plugin_dir.mkdir() + + manifest_path = plugin_dir / "manifest.toml" + manifest_content = '''id = "test_plugin" +name = "Test Plugin" +version = "0.1.0" +maintainer = "Test Author " +license = "MIT" +''' + manifest_path.write_text(manifest_content) + + zip_path = Path(self.temp_dir) / "test_plugin.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.write(manifest_path, "manifest.toml") + + manifest = PluginManifest.from_plugin_path(zip_path) + + self.assertEqual(manifest["id"], "test_plugin") + self.assertEqual(manifest["name"], "Test Plugin") + + def test_plugin_manifest_from_plugin_path_invalid(self): + """Test creating PluginManifest from invalid path""" + invalid_path = Path(self.temp_dir) / "invalid.txt" + invalid_path.write_text("not a plugin") + + with self.assertRaises(ValueError) as context: + PluginManifest.from_plugin_path(invalid_path) + + self.assertIn("Invalid plugin path", str(context.exception)) + + def test_plugin_manifest_validate_version(self): + """Test that manifest validates version format""" + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "version": "invalid-version", + "maintainer": "Test Author ", + "license": "MIT" + } + + with self.assertRaises(Exception): # semver will raise an exception + PluginManifest(manifest_data) + + def test_plugin_manifest_validate_license(self): + """Test that manifest validates license""" + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "version": "0.1.0", + "maintainer": "Test Author ", + "license": "INVALID-LICENSE" + } + + with self.assertRaises(KeyError): + PluginManifest(manifest_data) + + def test_plugin_manifest_validate_maintainer(self): + """Test that manifest parses maintainer email""" + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "version": "0.1.0", + "maintainer": "Test Author ", + "license": "MIT" + } + + manifest = PluginManifest(manifest_data) + + self.assertEqual(manifest.data.get("maintainer_name"), "Test Author") + self.assertEqual(manifest.data.get("maintainer_email"), "test@example.com") + + def test_plugin_manifest_to_model_dict(self): + """Test converting manifest to model dictionary""" + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "description": "A test plugin", + "version": "0.1.0", + "maintainer": "Test Author ", + "website": "https://example.com", + "license": "MIT", + "frontend_project_enabled": True, + "frontend_studio_enabled": False, + "icon": "test-icon" + } + + manifest = PluginManifest(manifest_data) + model_dict = manifest.to_model_dict() + + self.assertEqual(model_dict["plugin_id"], "test_plugin") + self.assertEqual(model_dict["name"], "Test Plugin") + self.assertEqual(model_dict["version"], "0.1.0") + self.assertEqual(model_dict["license"], "MIT") + self.assertTrue(model_dict["frontend_project_enabled"]) + self.assertFalse(model_dict["frontend_studio_enabled"]) + self.assertEqual(model_dict["icon"], "test-icon") + + def test_plugin_manifest_write_to_path(self): + """Test writing manifest to a path""" + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "version": "0.1.0", + "maintainer": "Test Author ", + "license": "MIT" + } + + manifest = PluginManifest(manifest_data) + output_path = Path(self.temp_dir) / "output" + output_path.mkdir() + + manifest.write_to_path(output_path) + + written_manifest = PluginManifest.from_file(output_path / "manifest.toml") + self.assertEqual(written_manifest["id"], "test_plugin") + self.assertEqual(written_manifest["name"], "Test Plugin") + + +class PluginFilesTestCase(ApiDBTestCase): + def setUp(self): + super(PluginFilesTestCase, self).setUp() + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + super(PluginFilesTestCase, self).tearDown() + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_install_plugin_files_from_directory(self): + source_dir = Path(self.temp_dir) / "source" + source_dir.mkdir() + + (source_dir / "file1.txt").write_text("content1") + (source_dir / "file2.txt").write_text("content2") + subdir = source_dir / "subdir" + subdir.mkdir() + (subdir / "file3.txt").write_text("content3") + + install_path = Path(self.temp_dir) / "install" + + result = install_plugin_files(source_dir, install_path) + + self.assertEqual(result, install_path) + self.assertTrue((install_path / "file1.txt").exists()) + self.assertTrue((install_path / "file2.txt").exists()) + self.assertTrue((install_path / "subdir" / "file3.txt").exists()) + + self.assertEqual((install_path / "file1.txt").read_text(), "content1") + + def test_install_plugin_files_from_zip(self): + source_dir = Path(self.temp_dir) / "source" + source_dir.mkdir() + + (source_dir / "file1.txt").write_text("content1") + (source_dir / "file2.txt").write_text("content2") + + zip_path = Path(self.temp_dir) / "plugin.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.write(source_dir / "file1.txt", "file1.txt") + zf.write(source_dir / "file2.txt", "file2.txt") + + install_path = Path(self.temp_dir) / "install" + + result = install_plugin_files(zip_path, install_path) + + self.assertEqual(result, install_path) + self.assertTrue((install_path / "file1.txt").exists()) + self.assertTrue((install_path / "file2.txt").exists()) + + def test_install_plugin_files_invalid_path(self): + invalid_path = Path(self.temp_dir) / "invalid.txt" + invalid_path.write_text("not a directory or zip") + + install_path = Path(self.temp_dir) / "install" + + with self.assertRaises(ValueError) as context: + install_plugin_files(invalid_path, install_path) + + self.assertIn("not a valid zip file or a directory", str(context.exception)) + + def test_uninstall_plugin_files(self): + plugin_path = Path(self.temp_dir) / "plugin" + plugin_path.mkdir() + (plugin_path / "file1.txt").write_text("content1") + + result = uninstall_plugin_files(plugin_path) + + self.assertTrue(result) + self.assertFalse(plugin_path.exists()) + + def test_uninstall_plugin_files_nonexistent(self): + """Test uninstalling non-existent plugin files""" + nonexistent_path = Path(self.temp_dir) / "nonexistent" + + result = uninstall_plugin_files(nonexistent_path) + + self.assertFalse(result) + + +class PluginPackageTestCase(ApiDBTestCase): + def setUp(self): + super(PluginPackageTestCase, self).setUp() + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + super(PluginPackageTestCase, self).tearDown() + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_create_plugin_package(self): + """Test creating a plugin package""" + plugin_dir = Path(self.temp_dir) / "test_plugin" + plugin_dir.mkdir() + + manifest_path = plugin_dir / "manifest.toml" + manifest_content = '''id = "test_plugin" +name = "Test Plugin" +version = "0.1.0" +maintainer = "Test Author " +license = "MIT" +''' + manifest_path.write_text(manifest_content) + (plugin_dir / "file1.txt").write_text("content1") + + output_path = Path(self.temp_dir) / "output.zip" + + result = create_plugin_package(plugin_dir, output_path) + + self.assertTrue(result.exists()) + self.assertTrue(result.suffix == ".zip") + + with zipfile.ZipFile(result, 'r') as zf: + files = zf.namelist() + self.assertIn("manifest.toml", files) + self.assertIn("file1.txt", files) + + +class PluginSkeletonTestCase(ApiDBTestCase): + def setUp(self): + super(PluginSkeletonTestCase, self).setUp() + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + super(PluginSkeletonTestCase, self).tearDown() + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_create_plugin_skeleton(self): + output_dir = Path(self.temp_dir) / "output" + output_dir.mkdir() + + result = create_plugin_skeleton( + output_dir, + id="test_plugin", + name="Test Plugin", + description="A test plugin", + version="0.2.0", + maintainer="Test Author ", + website="https://example.com", + license="MIT", + icon="test-icon" + ) + + self.assertTrue(result.exists()) + self.assertEqual(result.name, "test_plugin") + + manifest = PluginManifest.from_file(result / "manifest.toml") + self.assertEqual(manifest.id, "test_plugin") + self.assertEqual(manifest.name, "Test Plugin") + self.assertEqual(manifest.version, "0.2.0") + self.assertEqual(manifest.license, "MIT") + + def test_create_plugin_skeleton_file_exists(self): + output_dir = Path(self.temp_dir) / "output" + output_dir.mkdir() + existing_plugin = output_dir / "test_plugin" + existing_plugin.mkdir() + + with self.assertRaises(FileExistsError): + create_plugin_skeleton( + output_dir, + id="test_plugin", + name="Test Plugin", + license="MIT" + ) + + result = create_plugin_skeleton( + output_dir, + id="test_plugin", + name="Test Plugin", + license="MIT", + force=True + ) + self.assertTrue(result.exists()) + + +class PluginStaticRoutesTestCase(ApiDBTestCase): + def test_add_static_routes_with_frontend_enabled(self): + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "version": "0.1.0", + "maintainer": "Test Author ", + "license": "MIT", + "frontend_project_enabled": True, + "frontend_studio_enabled": False + } + manifest = PluginManifest(manifest_data) + routes = [] + + add_static_routes(manifest, routes) + + self.assertEqual(len(routes), 2) + route_paths = [r[0] for r in routes] + self.assertIn("/frontend/", route_paths) + self.assertIn("/frontend", route_paths) + for route_path, resource_class in routes: + if route_path == "/frontend/": + instance = resource_class() + self.assertEqual(instance.plugin_id, "test_plugin") + + def test_add_static_routes_without_frontend(self): + """Test adding static routes when frontend is disabled""" + manifest_data = { + "id": "test_plugin", + "name": "Test Plugin", + "version": "0.1.0", + "maintainer": "Test Author ", + "license": "MIT", + "frontend_project_enabled": False, + "frontend_studio_enabled": False + } + manifest = PluginManifest(manifest_data) + routes = [] + + add_static_routes(manifest, routes) + + self.assertEqual(len(routes), 0) + diff --git a/tests/services/test_plugins_service.py b/tests/services/test_plugins_service.py new file mode 100644 index 000000000..7d9138708 --- /dev/null +++ b/tests/services/test_plugins_service.py @@ -0,0 +1,157 @@ +# -*- coding: UTF-8 -*- +import os +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch + +from tests.base import ApiDBTestCase + +from zou.app import config +from zou.app.models.plugin import Plugin +from zou.app.services import plugins_service + + +class PluginsServiceTestCase(ApiDBTestCase): + def setUp(self): + super(PluginsServiceTestCase, self).setUp() + self.temp_dir = tempfile.mkdtemp() + self.plugin_folder = Path(self.temp_dir) / "plugins" + self.plugin_folder.mkdir(parents=True, exist_ok=True) + + # Patch config.PLUGIN_FOLDER to use our temp directory + self.original_plugin_folder = config.PLUGIN_FOLDER + config.PLUGIN_FOLDER = str(self.plugin_folder) + + def tearDown(self): + super(PluginsServiceTestCase, self).tearDown() + # Restore original config + config.PLUGIN_FOLDER = self.original_plugin_folder + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def _create_test_plugin(self, plugin_id="test_plugin", version="0.1.0"): + """Create a test plugin by copying the plugin template and modifying it""" + plugin_template_path = Path(__file__).parent.parent.parent / "zou" / "plugin_template" + plugin_path = self.temp_dir / plugin_id + + # Copy the entire plugin template + shutil.copytree(plugin_template_path, plugin_path) + + # Update the manifest.toml with the test plugin details + from zou.app.utils.plugins import PluginManifest + + manifest = PluginManifest.from_file(plugin_path / "manifest.toml") + manifest.id = plugin_id + manifest.name = "Test Plugin" + manifest.description = "A test plugin" + manifest.version = version + manifest.maintainer = "Test Author " + manifest.website = "https://example.com" + manifest.license = "MIT" + manifest.validate() + manifest.write_to_path(plugin_path) + + return plugin_path + + def test_install_plugin_new(self): + plugin_path = self._create_test_plugin("test_plugin", "0.1.0") + + result = plugins_service.install_plugin(str(plugin_path)) + + self.assertIsNotNone(result) + self.assertEqual(result["plugin_id"], "test_plugin") + self.assertEqual(result["name"], "Test Plugin") + self.assertEqual(result["version"], "0.1.0") + + plugin = Plugin.query.filter_by(plugin_id="test_plugin").first() + self.assertIsNotNone(plugin) + self.assertEqual(plugin.version, "0.1.0") + + installed_path = self.plugin_folder / "test_plugin" + self.assertTrue(installed_path.exists()) + self.assertTrue((installed_path / "manifest.toml").exists()) + + def test_install_plugin_upgrade(self): + existing_plugin = Plugin.create( + plugin_id="test_plugin", + name="Test Plugin", + version="0.1.0", + maintainer_name="Test Author", + maintainer_email="test@example.com", + license="MIT" + ) + plugin_path = self._create_test_plugin("test_plugin", "0.2.0") + result = plugins_service.install_plugin(str(plugin_path), force=True) + self.assertIsNotNone(result) + self.assertEqual(result["version"], "0.2.0") + plugin = Plugin.query.filter_by(plugin_id="test_plugin").first() + self.assertEqual(plugin.version, "0.2.0") + + def test_install_plugin_same_version(self): + existing_plugin = Plugin.create( + plugin_id="test_plugin", + name="Test Plugin", + version="0.1.0", + maintainer_name="Test Author", + maintainer_email="test@example.com", + license="MIT" + ) + plugin_path = self._create_test_plugin("test_plugin", "0.1.0") + result = plugins_service.install_plugin(str(plugin_path), force=True) + self.assertIsNotNone(result) + self.assertEqual(result["version"], "0.1.0") + + def test_install_plugin_nonexistent_path(self): + with self.assertRaises(FileNotFoundError): + plugins_service.install_plugin("/nonexistent/path") + + def test_uninstall_plugin(self): + """Test uninstalling a plugin""" + plugin_path = self._create_test_plugin("test_plugin", "0.1.0") + plugins_service.install_plugin(str(plugin_path)) + + plugin = Plugin.query.filter_by(plugin_id="test_plugin").first() + self.assertIsNotNone(plugin) + + installed_path = self.plugin_folder / "test_plugin" + self.assertTrue(installed_path.exists()) + + result = plugins_service.uninstall_plugin("test_plugin") + + self.assertTrue(result) + + deleted_plugin = Plugin.query.filter_by(plugin_id="test_plugin").first() + self.assertIsNone(deleted_plugin) + self.assertFalse(installed_path.exists()) + + def test_uninstall_plugin_not_installed(self): + with self.assertRaises(ValueError) as context: + plugins_service.uninstall_plugin("nonexistent_plugin") + self.assertIn("not installed", str(context.exception)) + + def test_get_plugins(self): + """Test getting all plugins""" + plugin1 = Plugin.create( + plugin_id="plugin1", + name="Plugin 1", + version="0.1.0", + maintainer_name="Author 1", + maintainer_email="author1@example.com", + license="MIT" + ) + plugin2 = Plugin.create( + plugin_id="plugin2", + name="Plugin 2", + version="0.2.0", + maintainer_name="Author 2", + maintainer_email="author2@example.com", + license="GPL-3.0-only" + ) + + plugins = plugins_service.get_plugins() + + self.assertEqual(len(plugins), 2) + plugin_ids = [p["plugin_id"] for p in plugins] + self.assertIn("plugin1", plugin_ids) + self.assertIn("plugin2", plugin_ids) From 45b6911ac7e8c8ea6ae30cb780bf131c7a0d3bc3 Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Mon, 29 Dec 2025 15:56:07 +0100 Subject: [PATCH 11/13] [plugins] Add icon field (lucide name expected) --- zou/app/models/plugin.py | 2 + zou/app/utils/commands.py | 1 + zou/app/utils/plugins.py | 82 ++++++++++--------- zou/cli.py | 8 ++ .../35ebb38695cd_add_icon_field_to_plugins.py | 33 ++++++++ 5 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py diff --git a/zou/app/models/plugin.py b/zou/app/models/plugin.py index 8965b4fb5..a1b29532b 100644 --- a/zou/app/models/plugin.py +++ b/zou/app/models/plugin.py @@ -24,6 +24,7 @@ class Plugin(db.Model, BaseMixin, SerializerMixin): revision = db.Column(db.String(12), nullable=True) frontend_project_enabled = db.Column(db.Boolean(), default=False) frontend_studio_enabled = db.Column(db.Boolean(), default=False) + icon = db.Column(db.String(255), nullable=True) # lucide-vue icon name def present(self): return { @@ -36,4 +37,5 @@ def present(self): "maintainer_email": self.maintainer_email, "frontend_project_enabled": self.frontend_project_enabled, "frontend_studio_enabled": self.frontend_studio_enabled, + "icon": self.icon, } diff --git a/zou/app/utils/commands.py b/zou/app/utils/commands.py index e156db653..eb3eb688a 100644 --- a/zou/app/utils/commands.py +++ b/zou/app/utils/commands.py @@ -880,6 +880,7 @@ def list_plugins(output_format, verbose, filter_field, filter_value): if verbose: plugin_data["Description"] = plugin.description or "-" plugin_data["Website"] = plugin.website or "-" + plugin_data["Icon"] = plugin.icon or "-" plugin_data["Revision"] = plugin.revision or "-" plugin_data["Installation Date"] = plugin.created_at plugin_data["Last Update"] = plugin.updated_at diff --git a/zou/app/utils/plugins.py b/zou/app/utils/plugins.py index 31775e263..7154a96c0 100644 --- a/zou/app/utils/plugins.py +++ b/zou/app/utils/plugins.py @@ -27,47 +27,59 @@ class StaticResource(Resource): plugin_id = None - location = None - - def get(self, filename): - """ - Serve static files - --- - tags: - - Static - parameters: - - in: path - name: filename - required: true - schema: - type: string - description: Name of the file to serve - responses: - 200: - description: File served successfully - 404: - description: File not found - """ + + def get(self, filename="index.html"): + print(self.plugin_id) - print(self.location) static_folder = ( Path(current_app.config.get("PLUGIN_FOLDER", "plugins")) / self.plugin_id / "frontend" - / self.location / "dist" ) + + if filename == "": + filename = "index.html" + + print(static_folder) file_path = static_folder / filename print(file_path) if not file_path.exists() or not file_path.is_file(): abort(404) + if filename == "": + filename = "index.html" + return send_from_directory( - str(static_folder), filename, conditional=True, max_age=3600 + str(static_folder), filename, conditional=True, max_age=0 + ) + + +class IndexStaticResource(Resource): + + plugin_id = None + + def get(self): + print(self.plugin_id) + static_folder = ( + Path(current_app.config.get("PLUGIN_FOLDER", "plugins")) + / self.plugin_id + / "frontend" + / "dist" ) + file_path = static_folder / filename + + if not file_path.exists() or not file_path.is_file(): + abort(404) + + + return send_from_directory( + str(static_folder), filename, conditional=True, max_age=0 + ) + class PluginManifest(MutableMapping): def __init__(self, data): super().__setattr__("data", data) @@ -126,6 +138,7 @@ def to_model_dict(self): "frontend_studio_enabled": self.data.get( "frontend_studio_enabled", False ), + "icon": self.data.get("icon", ""), } def __getitem__(self, key): @@ -335,6 +348,7 @@ def create_plugin_skeleton( maintainer=None, website=None, license=None, + icon=None, force=False, ): plugin_template_path = ( @@ -366,7 +380,8 @@ def create_plugin_skeleton( manifest.website = website if license: manifest.license = license - + if icon: + manifest.icon = icon manifest.validate() manifest.write_to_path(plugin_path) @@ -442,25 +457,16 @@ def add_static_routes(manifest, routes): Add static routes to the manifest. """ - class ProjectStaticResource(StaticResource): - - def __init__(self): - self.plugin_id = manifest.id - self.location = "project" - super().__init__() - - class StudioStaticResource(StaticResource): + class PluginStaticResource(StaticResource): def __init__(self): self.plugin_id = manifest.id - self.location = "studio" super().__init__() - if manifest["frontend_project_enabled"]: + if manifest["frontend_project_enabled"] or manifest["frontend_studio_enabled"]: routes.append( - (f"/frontend/project/", ProjectStaticResource) + (f"/frontend/", PluginStaticResource) ) - if manifest.frontend_studio_enabled: routes.append( - (f"/frontend/studio/", StudioStaticResource) + (f"/frontend", PluginStaticResource) ) diff --git a/zou/cli.py b/zou/cli.py index 0fd6211da..2825c7418 100755 --- a/zou/cli.py +++ b/zou/cli.py @@ -732,6 +732,12 @@ def uninstall_plugin(id): default="GPL-3.0-only", show_default=True, ) +@click.option( + "--icon", + help="Plugin icon (lucide-vue icon name).", + default=None, + show_default=True, +) @click.option( "--force", is_flag=True, @@ -747,6 +753,7 @@ def create_plugin_skeleton( maintainer, website, license, + icon, force=False, ): """ @@ -761,6 +768,7 @@ def create_plugin_skeleton( maintainer, website, license, + icon, force, ) print(f"Plugin file tree skeleton created in '{plugin_path}'.") diff --git a/zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py b/zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py new file mode 100644 index 000000000..b5e9fed68 --- /dev/null +++ b/zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py @@ -0,0 +1,33 @@ +"""add icon field to plugins + +Revision ID: 35ebb38695cd +Revises: 9a9df20ea5a7 +Create Date: 2025-12-24 11:39:42.229109 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = '35ebb38695cd' +down_revision = '9a9df20ea5a7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('plugin', schema=None) as batch_op: + batch_op.add_column(sa.Column('icon', sa.String(length=255), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('plugin', schema=None) as batch_op: + batch_op.drop_column('icon') + + # ### end Alembic commands ### From 7e372d9cf38baff3d917c8686645ef699f1e6ffd Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Mon, 29 Dec 2025 15:57:42 +0100 Subject: [PATCH 12/13] [plugins] Add auth to template --- zou/plugin_template/resources.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zou/plugin_template/resources.py b/zou/plugin_template/resources.py index 0571c403c..6f6ad8c14 100644 --- a/zou/plugin_template/resources.py +++ b/zou/plugin_template/resources.py @@ -5,6 +5,8 @@ class HelloWorld(Resource): + + @jwt_required() def get(self): if not Count.query.first(): c = Count.create() From 4e465fb9eb56b0d6ce37c035e35cd2598a90b17f Mon Sep 17 00:00:00 2001 From: Frank Rousseau Date: Mon, 29 Dec 2025 22:45:25 +0100 Subject: [PATCH 13/13] [plugins] Fix plugin path --- tests/misc/test_plugins.py | 21 ------------ tests/misc/test_utils_plugins.py | 46 +++----------------------- tests/services/test_plugins_service.py | 19 ++++------- zou/app/utils/plugins.py | 27 --------------- zou/plugin_template/migrations/env.py | 3 ++ 5 files changed, 13 insertions(+), 103 deletions(-) delete mode 100644 tests/misc/test_plugins.py diff --git a/tests/misc/test_plugins.py b/tests/misc/test_plugins.py deleted file mode 100644 index 64a14a646..000000000 --- a/tests/misc/test_plugins.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: UTF-8 -*- -from tests.base import ApiTestCase - - -class PluginTestCase(ApiTestCase): - """ - def test__plugin_modules(self): - plugins = api.load_plugin_modules("tests/fixtures/plugins") - self.assertEqual(len(plugins), 1) - - plugin = plugins[0] - self.assertTrue(hasattr(plugin, "routes")) - """ - - """ - def test_load_plugin(self): - plugins = api.load_plugin_modules("tests/fixtures/plugins") - plugin = plugins[0] - api.load_plugin(app, plugin) - self.get("/plugins/hello") - """ diff --git a/tests/misc/test_utils_plugins.py b/tests/misc/test_utils_plugins.py index 928c5267a..471cfe267 100644 --- a/tests/misc/test_utils_plugins.py +++ b/tests/misc/test_utils_plugins.py @@ -3,18 +3,15 @@ import tempfile import shutil import zipfile -import subprocess + from pathlib import Path -from unittest.mock import patch, MagicMock, Mock, call from tests.base import ApiDBTestCase -from zou.app import app, config from zou.app.utils.plugins import ( PluginManifest, install_plugin_files, uninstall_plugin_files, - clone_git_repo, create_plugin_package, create_plugin_skeleton, add_static_routes, @@ -22,6 +19,7 @@ class PluginManifestTestCase(ApiDBTestCase): + def setUp(self): super(PluginManifestTestCase, self).setUp() self.temp_dir = tempfile.mkdtemp() @@ -70,30 +68,7 @@ def test_plugin_manifest_from_plugin_path_directory(self): self.assertEqual(manifest["id"], "test_plugin") self.assertEqual(manifest["name"], "Test Plugin") - def test_plugin_manifest_from_plugin_path_zip(self): - plugin_dir = Path(self.temp_dir) / "test_plugin" - plugin_dir.mkdir() - - manifest_path = plugin_dir / "manifest.toml" - manifest_content = '''id = "test_plugin" -name = "Test Plugin" -version = "0.1.0" -maintainer = "Test Author " -license = "MIT" -''' - manifest_path.write_text(manifest_content) - - zip_path = Path(self.temp_dir) / "test_plugin.zip" - with zipfile.ZipFile(zip_path, 'w') as zf: - zf.write(manifest_path, "manifest.toml") - - manifest = PluginManifest.from_plugin_path(zip_path) - - self.assertEqual(manifest["id"], "test_plugin") - self.assertEqual(manifest["name"], "Test Plugin") - def test_plugin_manifest_from_plugin_path_invalid(self): - """Test creating PluginManifest from invalid path""" invalid_path = Path(self.temp_dir) / "invalid.txt" invalid_path.write_text("not a plugin") @@ -103,7 +78,6 @@ def test_plugin_manifest_from_plugin_path_invalid(self): self.assertIn("Invalid plugin path", str(context.exception)) def test_plugin_manifest_validate_version(self): - """Test that manifest validates version format""" manifest_data = { "id": "test_plugin", "name": "Test Plugin", @@ -116,7 +90,6 @@ def test_plugin_manifest_validate_version(self): PluginManifest(manifest_data) def test_plugin_manifest_validate_license(self): - """Test that manifest validates license""" manifest_data = { "id": "test_plugin", "name": "Test Plugin", @@ -129,7 +102,6 @@ def test_plugin_manifest_validate_license(self): PluginManifest(manifest_data) def test_plugin_manifest_validate_maintainer(self): - """Test that manifest parses maintainer email""" manifest_data = { "id": "test_plugin", "name": "Test Plugin", @@ -144,7 +116,6 @@ def test_plugin_manifest_validate_maintainer(self): self.assertEqual(manifest.data.get("maintainer_email"), "test@example.com") def test_plugin_manifest_to_model_dict(self): - """Test converting manifest to model dictionary""" manifest_data = { "id": "test_plugin", "name": "Test Plugin", @@ -170,7 +141,6 @@ def test_plugin_manifest_to_model_dict(self): self.assertEqual(model_dict["icon"], "test-icon") def test_plugin_manifest_write_to_path(self): - """Test writing manifest to a path""" manifest_data = { "id": "test_plugin", "name": "Test Plugin", @@ -256,18 +226,13 @@ def test_uninstall_plugin_files(self): plugin_path = Path(self.temp_dir) / "plugin" plugin_path.mkdir() (plugin_path / "file1.txt").write_text("content1") - result = uninstall_plugin_files(plugin_path) - self.assertTrue(result) self.assertFalse(plugin_path.exists()) def test_uninstall_plugin_files_nonexistent(self): - """Test uninstalling non-existent plugin files""" nonexistent_path = Path(self.temp_dir) / "nonexistent" - result = uninstall_plugin_files(nonexistent_path) - self.assertFalse(result) @@ -282,10 +247,8 @@ def tearDown(self): shutil.rmtree(self.temp_dir) def test_create_plugin_package(self): - """Test creating a plugin package""" plugin_dir = Path(self.temp_dir) / "test_plugin" plugin_dir.mkdir() - manifest_path = plugin_dir / "manifest.toml" manifest_content = '''id = "test_plugin" name = "Test Plugin" @@ -295,10 +258,10 @@ def test_create_plugin_package(self): ''' manifest_path.write_text(manifest_content) (plugin_dir / "file1.txt").write_text("content1") - output_path = Path(self.temp_dir) / "output.zip" - result = create_plugin_package(plugin_dir, output_path) + target_path = create_plugin_package(plugin_dir, output_path) + result = Path(target_path) self.assertTrue(result.exists()) self.assertTrue(result.suffix == ".zip") @@ -394,7 +357,6 @@ def test_add_static_routes_with_frontend_enabled(self): self.assertEqual(instance.plugin_id, "test_plugin") def test_add_static_routes_without_frontend(self): - """Test adding static routes when frontend is disabled""" manifest_data = { "id": "test_plugin", "name": "Test Plugin", diff --git a/tests/services/test_plugins_service.py b/tests/services/test_plugins_service.py index 7d9138708..402983acf 100644 --- a/tests/services/test_plugins_service.py +++ b/tests/services/test_plugins_service.py @@ -2,6 +2,7 @@ import os import tempfile import shutil + from pathlib import Path from unittest.mock import patch @@ -10,37 +11,31 @@ from zou.app import config from zou.app.models.plugin import Plugin from zou.app.services import plugins_service +from zou.app.utils.plugins import PluginManifest class PluginsServiceTestCase(ApiDBTestCase): + def setUp(self): super(PluginsServiceTestCase, self).setUp() self.temp_dir = tempfile.mkdtemp() self.plugin_folder = Path(self.temp_dir) / "plugins" self.plugin_folder.mkdir(parents=True, exist_ok=True) - # Patch config.PLUGIN_FOLDER to use our temp directory self.original_plugin_folder = config.PLUGIN_FOLDER config.PLUGIN_FOLDER = str(self.plugin_folder) def tearDown(self): super(PluginsServiceTestCase, self).tearDown() - # Restore original config config.PLUGIN_FOLDER = self.original_plugin_folder if os.path.exists(self.temp_dir): shutil.rmtree(self.temp_dir) def _create_test_plugin(self, plugin_id="test_plugin", version="0.1.0"): - """Create a test plugin by copying the plugin template and modifying it""" plugin_template_path = Path(__file__).parent.parent.parent / "zou" / "plugin_template" - plugin_path = self.temp_dir / plugin_id + plugin_path = Path(self.temp_dir) / plugin_id - # Copy the entire plugin template shutil.copytree(plugin_template_path, plugin_path) - - # Update the manifest.toml with the test plugin details - from zou.app.utils.plugins import PluginManifest - manifest = PluginManifest.from_file(plugin_path / "manifest.toml") manifest.id = plugin_id manifest.name = "Test Plugin" @@ -107,7 +102,6 @@ def test_install_plugin_nonexistent_path(self): plugins_service.install_plugin("/nonexistent/path") def test_uninstall_plugin(self): - """Test uninstalling a plugin""" plugin_path = self._create_test_plugin("test_plugin", "0.1.0") plugins_service.install_plugin(str(plugin_path)) @@ -128,10 +122,9 @@ def test_uninstall_plugin(self): def test_uninstall_plugin_not_installed(self): with self.assertRaises(ValueError) as context: plugins_service.uninstall_plugin("nonexistent_plugin") - self.assertIn("not installed", str(context.exception)) + self.assertIn("Invalid plugin path", str(context.exception)) def test_get_plugins(self): - """Test getting all plugins""" plugin1 = Plugin.create( plugin_id="plugin1", name="Plugin 1", @@ -154,4 +147,4 @@ def test_get_plugins(self): self.assertEqual(len(plugins), 2) plugin_ids = [p["plugin_id"] for p in plugins] self.assertIn("plugin1", plugin_ids) - self.assertIn("plugin2", plugin_ids) + self.assertIn("plugin2", plugin_ids) \ No newline at end of file diff --git a/zou/app/utils/plugins.py b/zou/app/utils/plugins.py index 7154a96c0..9dfbbcda0 100644 --- a/zou/app/utils/plugins.py +++ b/zou/app/utils/plugins.py @@ -41,10 +41,7 @@ def get(self, filename="index.html"): if filename == "": filename = "index.html" - print(static_folder) file_path = static_folder / filename - print(file_path) - if not file_path.exists() or not file_path.is_file(): abort(404) @@ -56,30 +53,6 @@ def get(self, filename="index.html"): ) -class IndexStaticResource(Resource): - - plugin_id = None - - def get(self): - print(self.plugin_id) - static_folder = ( - Path(current_app.config.get("PLUGIN_FOLDER", "plugins")) - / self.plugin_id - / "frontend" - / "dist" - ) - - - file_path = static_folder / filename - - if not file_path.exists() or not file_path.is_file(): - abort(404) - - - return send_from_directory( - str(static_folder), filename, conditional=True, max_age=0 - ) - class PluginManifest(MutableMapping): def __init__(self, data): super().__setattr__("data", data) diff --git a/zou/plugin_template/migrations/env.py b/zou/plugin_template/migrations/env.py index bab7290ae..dca203051 100644 --- a/zou/plugin_template/migrations/env.py +++ b/zou/plugin_template/migrations/env.py @@ -1,11 +1,14 @@ import importlib.util import logging import sys + from alembic import context from sqlalchemy import create_engine, pool + from pathlib import Path from logging.config import fileConfig +from zou.app import db from zou.app.utils.plugins import PluginManifest plugin_path = Path(__file__).resolve().parents[1]