zou 1.0.4__py3-none-any.whl → 1.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zou/__init__.py +1 -1
- zou/app/blueprints/crud/person.py +1 -0
- zou/app/blueprints/crud/task_type.py +10 -0
- zou/app/blueprints/files/resources.py +2 -0
- zou/app/blueprints/index/resources.py +76 -66
- zou/app/blueprints/news/resources.py +2 -2
- zou/app/blueprints/persons/resources.py +7 -5
- zou/app/blueprints/playlists/resources.py +8 -2
- zou/app/blueprints/previews/resources.py +11 -2
- zou/app/blueprints/source/csv/base.py +4 -3
- zou/app/config.py +2 -0
- zou/app/file_trees/default.json +6 -0
- zou/app/file_trees/simple.json +6 -0
- zou/app/mixin.py +9 -0
- zou/app/models/plugin.py +21 -2
- zou/app/models/preview_file.py +2 -0
- zou/app/services/comments_service.py +2 -1
- zou/app/services/deletion_service.py +17 -5
- zou/app/services/file_tree_service.py +10 -5
- zou/app/services/notifications_service.py +11 -2
- zou/app/services/persons_service.py +1 -0
- zou/app/services/plugins_service.py +83 -11
- zou/app/services/schedule_service.py +2 -1
- zou/app/services/tasks_service.py +1 -1
- zou/app/services/time_spents_service.py +4 -1
- zou/app/services/user_service.py +5 -0
- zou/app/stores/file_store.py +29 -2
- zou/app/utils/commands.py +1 -1
- zou/app/utils/fs.py +27 -5
- zou/app/utils/plugins.py +122 -11
- zou/cli.py +15 -5
- zou/migrations/versions/12208e50bf18_add_json_data_field_to_preview_files.py +38 -0
- zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py +36 -0
- zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py +40 -0
- zou/migrations/versions/a0f668430352_add_created_by_field_to_playlists.py +16 -8
- zou/plugin_template/__init__.py +2 -13
- zou/plugin_template/migrations/env.py +9 -0
- zou/plugin_template/models.py +2 -2
- zou/plugin_template/{routes.py → resources.py} +4 -0
- {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/METADATA +1 -1
- {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/RECORD +45 -42
- {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/WHEEL +1 -1
- {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/entry_points.txt +0 -0
- {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/licenses/LICENSE +0 -0
- {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/top_level.txt +0 -0
|
@@ -3,7 +3,7 @@ from sqlalchemy.exc import StatementError
|
|
|
3
3
|
from zou.app.models.project import Project, ProjectPersonLink
|
|
4
4
|
from zou.app.models.entity import Entity
|
|
5
5
|
from zou.app.models.notification import Notification
|
|
6
|
-
from zou.app.models.person import Person
|
|
6
|
+
from zou.app.models.person import Person, DepartmentLink
|
|
7
7
|
from zou.app.models.subscription import Subscription
|
|
8
8
|
from zou.app.models.task import Task
|
|
9
9
|
from zou.app.models.task_type import TaskType
|
|
@@ -497,7 +497,9 @@ def get_subscriptions_for_user(project_id, entity_type_id=None):
|
|
|
497
497
|
return subscription_map
|
|
498
498
|
|
|
499
499
|
|
|
500
|
-
def notify_clients_playlist_ready(
|
|
500
|
+
def notify_clients_playlist_ready(
|
|
501
|
+
playlist, studio_id=None, department_id=None
|
|
502
|
+
):
|
|
501
503
|
"""
|
|
502
504
|
Notify clients that given playlist is ready.
|
|
503
505
|
"""
|
|
@@ -514,6 +516,13 @@ def notify_clients_playlist_ready(playlist, studio_id=None):
|
|
|
514
516
|
if studio_id is not None and studio_id != "":
|
|
515
517
|
query = query.filter(Person.studio_id == studio_id)
|
|
516
518
|
|
|
519
|
+
if department_id is not None and department_id != "":
|
|
520
|
+
query = (
|
|
521
|
+
query.join(DepartmentLink)
|
|
522
|
+
.filter(DepartmentLink.department_id == department_id)
|
|
523
|
+
.distinct()
|
|
524
|
+
)
|
|
525
|
+
|
|
517
526
|
for client in query.all():
|
|
518
527
|
recipient_id = str(client.id)
|
|
519
528
|
author_id = author["id"]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import semver
|
|
2
|
+
import shutil
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
from zou.app import config, db
|
|
@@ -9,16 +10,32 @@ from zou.app.utils.plugins import (
|
|
|
9
10
|
downgrade_plugin_migrations,
|
|
10
11
|
uninstall_plugin_files,
|
|
11
12
|
install_plugin_files,
|
|
13
|
+
clone_git_repo,
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def install_plugin(path, force=False):
|
|
16
18
|
"""
|
|
17
|
-
Install a plugin.
|
|
19
|
+
Install a plugin: create folder, copy files, run migrations.
|
|
20
|
+
Supports local paths, zip files, and git repository URLs.
|
|
18
21
|
"""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
is_git_url = (
|
|
23
|
+
path.startswith("http://")
|
|
24
|
+
or path.startswith("https://")
|
|
25
|
+
or path.startswith("git://")
|
|
26
|
+
or path.startswith("ssh://")
|
|
27
|
+
or path.startswith("git@")
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
temp_dir = None
|
|
31
|
+
if is_git_url:
|
|
32
|
+
cloned_path = clone_git_repo(path)
|
|
33
|
+
temp_dir = cloned_path.parent
|
|
34
|
+
path = cloned_path
|
|
35
|
+
else:
|
|
36
|
+
path = Path(path)
|
|
37
|
+
if not path.exists():
|
|
38
|
+
raise FileNotFoundError(f"Plugin path '{path}' does not exist.")
|
|
22
39
|
|
|
23
40
|
manifest = PluginManifest.from_plugin_path(path)
|
|
24
41
|
plugin = Plugin.query.filter_by(plugin_id=manifest.id).one_or_none()
|
|
@@ -27,32 +44,46 @@ def install_plugin(path, force=False):
|
|
|
27
44
|
if plugin:
|
|
28
45
|
current = semver.Version.parse(plugin.version)
|
|
29
46
|
new = semver.Version.parse(str(manifest.version))
|
|
47
|
+
print(
|
|
48
|
+
f"[Plugins] Upgrading plugin {manifest.id} from version {current} to {new}..."
|
|
49
|
+
)
|
|
30
50
|
if not force and new <= current:
|
|
31
|
-
|
|
32
|
-
f"Plugin version {new} is not newer than {current}."
|
|
33
|
-
)
|
|
51
|
+
print(f"⚠️ Plugin version {new} is not newer than {current}.")
|
|
34
52
|
plugin.update_no_commit(manifest.to_model_dict())
|
|
53
|
+
print(f"[Plugins] Plugin {manifest.id} upgraded.")
|
|
35
54
|
else:
|
|
55
|
+
print(f"[Plugins] Installing plugin {manifest.id}...")
|
|
36
56
|
plugin = Plugin.create_no_commit(**manifest.to_model_dict())
|
|
57
|
+
print(f"[Plugins] Plugin {manifest.id} installed.")
|
|
37
58
|
|
|
59
|
+
print(f"[Plugins] Running database migrations for {manifest.id}...")
|
|
38
60
|
plugin_path = install_plugin_files(
|
|
39
61
|
path, Path(config.PLUGIN_FOLDER) / manifest.id
|
|
40
62
|
)
|
|
41
63
|
run_plugin_migrations(plugin_path, plugin)
|
|
64
|
+
print(f"[Plugins] Database migrations for {manifest.id} applied.")
|
|
42
65
|
except Exception:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
print(
|
|
67
|
+
f"❌ [Plugins] An error occurred while installing/updating {manifest.id}..."
|
|
68
|
+
)
|
|
46
69
|
raise
|
|
47
70
|
|
|
48
71
|
Plugin.commit()
|
|
72
|
+
print_added_routes(plugin, plugin_path)
|
|
73
|
+
|
|
74
|
+
if is_git_url:
|
|
75
|
+
if temp_dir and temp_dir.exists():
|
|
76
|
+
shutil.rmtree(temp_dir)
|
|
77
|
+
|
|
49
78
|
return plugin.serialize()
|
|
50
79
|
|
|
51
80
|
|
|
52
81
|
def uninstall_plugin(plugin_id):
|
|
53
82
|
"""
|
|
54
|
-
Uninstall a plugin
|
|
83
|
+
Uninstall a plugin: downgrade migrations, remove files,
|
|
84
|
+
delete from database and remove folder.
|
|
55
85
|
"""
|
|
86
|
+
print(f"[Plugins] Uninstalling plugin {plugin_id}...")
|
|
56
87
|
plugin_path = Path(config.PLUGIN_FOLDER) / plugin_id
|
|
57
88
|
downgrade_plugin_migrations(plugin_path)
|
|
58
89
|
installed = uninstall_plugin_files(plugin_path)
|
|
@@ -63,4 +94,45 @@ def uninstall_plugin(plugin_id):
|
|
|
63
94
|
|
|
64
95
|
if not installed:
|
|
65
96
|
raise ValueError(f"Plugin '{plugin_id}' is not installed.")
|
|
97
|
+
|
|
98
|
+
print(f"[Plugins] Plugin {plugin_id} uninstalled.")
|
|
66
99
|
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def print_added_routes(plugin, plugin_path):
|
|
103
|
+
"""
|
|
104
|
+
Print the added routes for a plugin.
|
|
105
|
+
"""
|
|
106
|
+
import importlib
|
|
107
|
+
import sys
|
|
108
|
+
|
|
109
|
+
print(f"[Plugins] Routes added by {plugin.plugin_id}:")
|
|
110
|
+
plugin_path = Path(plugin_path)
|
|
111
|
+
|
|
112
|
+
plugin_folder = plugin_path.parent
|
|
113
|
+
abs_plugin_path = str(plugin_folder.absolute())
|
|
114
|
+
if abs_plugin_path not in sys.path:
|
|
115
|
+
sys.path.insert(0, abs_plugin_path)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
plugin_module = importlib.import_module(plugin.plugin_id)
|
|
119
|
+
if hasattr(plugin_module, "routes"):
|
|
120
|
+
routes = plugin_module.routes
|
|
121
|
+
for route in routes:
|
|
122
|
+
print(f" - /plugins/{plugin.plugin_id}{route[0]}")
|
|
123
|
+
else:
|
|
124
|
+
print(" (No routes variable found in plugin)")
|
|
125
|
+
except ImportError as e:
|
|
126
|
+
print(f" ⚠️ Could not import plugin module: {e}")
|
|
127
|
+
finally:
|
|
128
|
+
if abs_plugin_path in sys.path:
|
|
129
|
+
sys.path.remove(abs_plugin_path)
|
|
130
|
+
|
|
131
|
+
print("--------------------------------")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_plugins():
|
|
135
|
+
"""
|
|
136
|
+
Get all plugins.
|
|
137
|
+
"""
|
|
138
|
+
return [plugin.present() for plugin in Plugin.query.all()]
|
|
@@ -55,7 +55,8 @@ def get_task_types_schedule_items(project_id):
|
|
|
55
55
|
task_types = [
|
|
56
56
|
task_type
|
|
57
57
|
for task_type in task_types
|
|
58
|
-
if task_type["for_entity"]
|
|
58
|
+
if task_type["for_entity"]
|
|
59
|
+
in ["Asset", "Shot", "Sequence", "Episode", "Edit"]
|
|
59
60
|
]
|
|
60
61
|
task_type_map = base_service.get_model_map_from_array(task_types)
|
|
61
62
|
schedule_items = set(
|
|
@@ -1815,7 +1815,7 @@ def reset_task_data(task_id):
|
|
|
1815
1815
|
if task_status_is_wip and real_start_date is None:
|
|
1816
1816
|
real_start_date = comment.created_at
|
|
1817
1817
|
|
|
1818
|
-
if task_status_is_feedback_request:
|
|
1818
|
+
if task_status_is_feedback_request and end_date is None:
|
|
1819
1819
|
end_date = comment.created_at
|
|
1820
1820
|
|
|
1821
1821
|
print("ok", task_status_is_done)
|
|
@@ -184,7 +184,10 @@ def get_time_spents_for_month(
|
|
|
184
184
|
"""
|
|
185
185
|
Return all time spents for given month.
|
|
186
186
|
"""
|
|
187
|
-
|
|
187
|
+
month_int = int(month)
|
|
188
|
+
if month_int < 1 or month_int > 12:
|
|
189
|
+
raise WrongDateFormatException
|
|
190
|
+
date = datetime.datetime(int(year), month_int, 1)
|
|
188
191
|
next_month = date + relativedelta.relativedelta(months=1)
|
|
189
192
|
query = TimeSpent.query.filter(TimeSpent.date >= date).filter(
|
|
190
193
|
TimeSpent.date < next_month
|
zou/app/services/user_service.py
CHANGED
|
@@ -15,6 +15,8 @@ from zou.app.models.search_filter_group import SearchFilterGroup
|
|
|
15
15
|
from zou.app.models.task import Task
|
|
16
16
|
from zou.app.models.task_type import TaskType
|
|
17
17
|
|
|
18
|
+
from zou.app.services import plugins_service
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
from zou.app.services import (
|
|
20
22
|
assets_service,
|
|
@@ -1334,6 +1336,8 @@ def update_notification(notification_id, read):
|
|
|
1334
1336
|
notification = Notification.get_by(
|
|
1335
1337
|
id=notification_id, person_id=current_user["id"]
|
|
1336
1338
|
)
|
|
1339
|
+
if notification is None:
|
|
1340
|
+
raise NotificationNotFoundException
|
|
1337
1341
|
notification.update({"read": read})
|
|
1338
1342
|
if read:
|
|
1339
1343
|
events.emit(
|
|
@@ -1670,6 +1674,7 @@ def get_context():
|
|
|
1670
1674
|
"search_filters": get_filters(),
|
|
1671
1675
|
"search_filter_groups": get_filter_groups(),
|
|
1672
1676
|
"preview_background_files": files_service.get_preview_background_files(),
|
|
1677
|
+
"plugins": plugins_service.get_plugins(),
|
|
1673
1678
|
}
|
|
1674
1679
|
|
|
1675
1680
|
if permissions.has_admin_permissions():
|
zou/app/stores/file_store.py
CHANGED
|
@@ -47,11 +47,38 @@ def make_key(prefix, id):
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def make_read_generator(bucket, key):
|
|
50
|
+
"""
|
|
51
|
+
Create a generator that yields chunks from the storage bucket.
|
|
52
|
+
This function ensures proper cleanup of the underlying stream to avoid
|
|
53
|
+
reentrant call errors when the stream is accessed concurrently.
|
|
54
|
+
"""
|
|
50
55
|
read_stream = bucket.read_chunks(key)
|
|
51
56
|
|
|
52
57
|
def read_generator(read_stream):
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
try:
|
|
59
|
+
for chunk in read_stream:
|
|
60
|
+
yield chunk
|
|
61
|
+
except GeneratorExit:
|
|
62
|
+
try:
|
|
63
|
+
if hasattr(read_stream, 'close'):
|
|
64
|
+
read_stream.close()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
raise
|
|
68
|
+
except StopIteration:
|
|
69
|
+
try:
|
|
70
|
+
if hasattr(read_stream, 'close'):
|
|
71
|
+
read_stream.close()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
raise
|
|
75
|
+
except Exception:
|
|
76
|
+
try:
|
|
77
|
+
if hasattr(read_stream, 'close'):
|
|
78
|
+
read_stream.close()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
raise
|
|
55
82
|
|
|
56
83
|
return read_generator(read_stream)
|
|
57
84
|
|
zou/app/utils/commands.py
CHANGED
|
@@ -848,7 +848,6 @@ def list_plugins(output_format, verbose, filter_field, filter_value):
|
|
|
848
848
|
with app.app_context():
|
|
849
849
|
query = Plugin.query
|
|
850
850
|
|
|
851
|
-
# Apply filter if needed
|
|
852
851
|
if filter_field and filter_value:
|
|
853
852
|
if filter_field == "maintainer":
|
|
854
853
|
query = query.filter(
|
|
@@ -881,6 +880,7 @@ def list_plugins(output_format, verbose, filter_field, filter_value):
|
|
|
881
880
|
if verbose:
|
|
882
881
|
plugin_data["Description"] = plugin.description or "-"
|
|
883
882
|
plugin_data["Website"] = plugin.website or "-"
|
|
883
|
+
plugin_data["Icon"] = plugin.icon or "-"
|
|
884
884
|
plugin_data["Revision"] = plugin.revision or "-"
|
|
885
885
|
plugin_data["Installation Date"] = plugin.created_at
|
|
886
886
|
plugin_data["Last Update"] = plugin.updated_at
|
zou/app/utils/fs.py
CHANGED
|
@@ -57,20 +57,42 @@ def get_file_path_and_file(
|
|
|
57
57
|
exception = None
|
|
58
58
|
try:
|
|
59
59
|
with open(file_path, "wb") as tmp_file:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
file_generator = open_file(prefix, instance_id)
|
|
61
|
+
try:
|
|
62
|
+
for chunk in file_generator:
|
|
63
|
+
tmp_file.write(chunk)
|
|
64
|
+
finally:
|
|
65
|
+
try:
|
|
66
|
+
file_generator.close()
|
|
67
|
+
except StopIteration:
|
|
68
|
+
# Normal end of iteration, expected
|
|
69
|
+
pass
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
except Exception as e:
|
|
63
73
|
download_failed = True
|
|
74
|
+
exception = e
|
|
64
75
|
|
|
65
76
|
if is_unvalid_file(
|
|
66
77
|
file_path, file_size, download_failed
|
|
67
78
|
): # download failed
|
|
68
79
|
time.sleep(3)
|
|
69
80
|
download_failed = False
|
|
81
|
+
exception = None
|
|
70
82
|
try:
|
|
71
83
|
with open(file_path, "wb") as tmp_file:
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
file_generator = open_file(prefix, instance_id)
|
|
85
|
+
try:
|
|
86
|
+
for chunk in file_generator:
|
|
87
|
+
tmp_file.write(chunk)
|
|
88
|
+
finally:
|
|
89
|
+
try:
|
|
90
|
+
file_generator.close()
|
|
91
|
+
except StopIteration:
|
|
92
|
+
# Normal end of iteration, expected
|
|
93
|
+
pass
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
74
96
|
except Exception as e:
|
|
75
97
|
download_failed = True
|
|
76
98
|
exception = e
|
zou/app/utils/plugins.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import tomlkit
|
|
2
|
-
import semver
|
|
3
1
|
import email.utils
|
|
4
2
|
import spdx_license_list
|
|
5
3
|
import zipfile
|
|
@@ -7,17 +5,52 @@ import importlib
|
|
|
7
5
|
import importlib.util
|
|
8
6
|
import sys
|
|
9
7
|
import os
|
|
8
|
+
import tomlkit
|
|
10
9
|
import traceback
|
|
10
|
+
import semver
|
|
11
11
|
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
12
14
|
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from flask import current_app
|
|
15
15
|
from alembic import command
|
|
16
16
|
from alembic.config import Config
|
|
17
|
+
from collections.abc import MutableMapping
|
|
18
|
+
from flask import Blueprint, current_app
|
|
19
|
+
from flask_restful import Resource
|
|
20
|
+
from pathlib import Path
|
|
17
21
|
|
|
22
|
+
from zou.app.utils.api import configure_api_from_blueprint
|
|
18
23
|
|
|
19
|
-
from
|
|
20
|
-
|
|
24
|
+
from flask import send_from_directory, abort, current_app
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StaticResource(Resource):
|
|
28
|
+
|
|
29
|
+
plugin_id = None
|
|
30
|
+
|
|
31
|
+
def get(self, filename="index.html"):
|
|
32
|
+
|
|
33
|
+
print(self.plugin_id)
|
|
34
|
+
static_folder = (
|
|
35
|
+
Path(current_app.config.get("PLUGIN_FOLDER", "plugins"))
|
|
36
|
+
/ self.plugin_id
|
|
37
|
+
/ "frontend"
|
|
38
|
+
/ "dist"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if filename == "":
|
|
42
|
+
filename = "index.html"
|
|
43
|
+
|
|
44
|
+
file_path = static_folder / filename
|
|
45
|
+
if not file_path.exists() or not file_path.is_file():
|
|
46
|
+
abort(404)
|
|
47
|
+
|
|
48
|
+
if filename == "":
|
|
49
|
+
filename = "index.html"
|
|
50
|
+
|
|
51
|
+
return send_from_directory(
|
|
52
|
+
str(static_folder), filename, conditional=True, max_age=0
|
|
53
|
+
)
|
|
21
54
|
|
|
22
55
|
|
|
23
56
|
class PluginManifest(MutableMapping):
|
|
@@ -57,6 +90,11 @@ class PluginManifest(MutableMapping):
|
|
|
57
90
|
self.data["maintainer_name"] = name
|
|
58
91
|
self.data["maintainer_email"] = email_addr
|
|
59
92
|
|
|
93
|
+
if "frontend_project_enabled" not in self.data:
|
|
94
|
+
self.data["frontend_project_enabled"] = False
|
|
95
|
+
if "frontend_studio_enabled" not in self.data:
|
|
96
|
+
self.data["frontend_studio_enabled"] = False
|
|
97
|
+
|
|
60
98
|
def to_model_dict(self):
|
|
61
99
|
return {
|
|
62
100
|
"plugin_id": self.data["id"],
|
|
@@ -67,6 +105,13 @@ class PluginManifest(MutableMapping):
|
|
|
67
105
|
"maintainer_email": self.data.get("maintainer_email"),
|
|
68
106
|
"website": self.data.get("website"),
|
|
69
107
|
"license": self.data["license"],
|
|
108
|
+
"frontend_project_enabled": self.data.get(
|
|
109
|
+
"frontend_project_enabled", False
|
|
110
|
+
),
|
|
111
|
+
"frontend_studio_enabled": self.data.get(
|
|
112
|
+
"frontend_studio_enabled", False
|
|
113
|
+
),
|
|
114
|
+
"icon": self.data.get("icon", ""),
|
|
70
115
|
}
|
|
71
116
|
|
|
72
117
|
def __getitem__(self, key):
|
|
@@ -106,10 +151,16 @@ def load_plugin(app, plugin_path, init_plugin=True):
|
|
|
106
151
|
"""
|
|
107
152
|
plugin_path = Path(plugin_path)
|
|
108
153
|
manifest = PluginManifest.from_plugin_path(plugin_path)
|
|
109
|
-
|
|
110
154
|
plugin_module = importlib.import_module(manifest["id"])
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
|
|
156
|
+
if not hasattr(plugin_module, "routes"):
|
|
157
|
+
raise Exception(f"Plugin {manifest['id']} has no routes.")
|
|
158
|
+
|
|
159
|
+
routes = plugin_module.routes
|
|
160
|
+
add_static_routes(manifest, routes)
|
|
161
|
+
blueprint = Blueprint(manifest["id"], manifest["id"])
|
|
162
|
+
configure_api_from_blueprint(blueprint, routes)
|
|
163
|
+
app.register_blueprint(blueprint, url_prefix=f"/plugins/{manifest['id']}")
|
|
113
164
|
|
|
114
165
|
return plugin_module
|
|
115
166
|
|
|
@@ -270,6 +321,7 @@ def create_plugin_skeleton(
|
|
|
270
321
|
maintainer=None,
|
|
271
322
|
website=None,
|
|
272
323
|
license=None,
|
|
324
|
+
icon=None,
|
|
273
325
|
force=False,
|
|
274
326
|
):
|
|
275
327
|
plugin_template_path = (
|
|
@@ -301,7 +353,8 @@ def create_plugin_skeleton(
|
|
|
301
353
|
manifest.website = website
|
|
302
354
|
if license:
|
|
303
355
|
manifest.license = license
|
|
304
|
-
|
|
356
|
+
if icon:
|
|
357
|
+
manifest.icon = icon
|
|
305
358
|
manifest.validate()
|
|
306
359
|
manifest.write_to_path(plugin_path)
|
|
307
360
|
|
|
@@ -318,7 +371,14 @@ def install_plugin_files(files_path, installation_path):
|
|
|
318
371
|
installation_path.mkdir(parents=True, exist_ok=True)
|
|
319
372
|
|
|
320
373
|
if files_path.is_dir():
|
|
321
|
-
|
|
374
|
+
def ignore_git(dir, names):
|
|
375
|
+
ignored = []
|
|
376
|
+
if ".git" in names:
|
|
377
|
+
ignored.append(".git")
|
|
378
|
+
return ignored
|
|
379
|
+
shutil.copytree(
|
|
380
|
+
files_path, installation_path, dirs_exist_ok=True, ignore=ignore_git
|
|
381
|
+
)
|
|
322
382
|
elif zipfile.is_zipfile(files_path):
|
|
323
383
|
shutil.unpack_archive(files_path, installation_path, format="zip")
|
|
324
384
|
else:
|
|
@@ -338,3 +398,54 @@ def uninstall_plugin_files(plugin_path):
|
|
|
338
398
|
shutil.rmtree(plugin_path)
|
|
339
399
|
return True
|
|
340
400
|
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def clone_git_repo(git_url, temp_dir=None):
|
|
404
|
+
"""
|
|
405
|
+
Clone a git repository to a temporary directory.
|
|
406
|
+
Returns the path to the cloned directory.
|
|
407
|
+
"""
|
|
408
|
+
if temp_dir is None:
|
|
409
|
+
temp_dir = tempfile.mkdtemp(prefix="zou_plugin_")
|
|
410
|
+
|
|
411
|
+
temp_dir = Path(temp_dir)
|
|
412
|
+
repo_name = git_url.rstrip("/").split("/")[-1].replace(".git", "")
|
|
413
|
+
clone_path = temp_dir / repo_name
|
|
414
|
+
|
|
415
|
+
print(f"[Plugins] Cloning {git_url}...")
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
subprocess.run(
|
|
419
|
+
["git", "clone", git_url, str(clone_path)],
|
|
420
|
+
check=True,
|
|
421
|
+
capture_output=True,
|
|
422
|
+
timeout=300,
|
|
423
|
+
)
|
|
424
|
+
print(f"[Plugins] Successfully cloned {git_url}")
|
|
425
|
+
return clone_path
|
|
426
|
+
except subprocess.CalledProcessError as e:
|
|
427
|
+
error_msg = e.stderr.decode() if e.stderr else str(e)
|
|
428
|
+
raise ValueError(f"Failed to clone repository {git_url}: {error_msg}")
|
|
429
|
+
except FileNotFoundError:
|
|
430
|
+
raise ValueError(
|
|
431
|
+
"git is not available. Please install git to clone repositories."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def add_static_routes(manifest, routes):
|
|
436
|
+
"""
|
|
437
|
+
Add static routes to the manifest.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
class PluginStaticResource(StaticResource):
|
|
441
|
+
|
|
442
|
+
def __init__(self):
|
|
443
|
+
self.plugin_id = manifest.id
|
|
444
|
+
super().__init__()
|
|
445
|
+
|
|
446
|
+
if (
|
|
447
|
+
manifest["frontend_project_enabled"]
|
|
448
|
+
or manifest["frontend_studio_enabled"]
|
|
449
|
+
):
|
|
450
|
+
routes.append((f"/frontend/<path:filename>", PluginStaticResource))
|
|
451
|
+
routes.append((f"/frontend", PluginStaticResource))
|
zou/cli.py
CHANGED
|
@@ -660,6 +660,7 @@ def renormalize_movie_preview_files(
|
|
|
660
660
|
@click.option(
|
|
661
661
|
"--path",
|
|
662
662
|
required=True,
|
|
663
|
+
help="Plugin path: local directory, zip file, or git repository URL",
|
|
663
664
|
)
|
|
664
665
|
@click.option(
|
|
665
666
|
"--force",
|
|
@@ -669,11 +670,12 @@ def renormalize_movie_preview_files(
|
|
|
669
670
|
)
|
|
670
671
|
def install_plugin(path, force=False):
|
|
671
672
|
"""
|
|
672
|
-
Install a plugin.
|
|
673
|
+
Install a plugin and apply the migrations.
|
|
674
|
+
Supports local paths, zip files, and git repository URLs.
|
|
673
675
|
"""
|
|
674
676
|
with app.app_context():
|
|
675
677
|
plugins_service.install_plugin(path, force)
|
|
676
|
-
print(f"Plugin {path} installed. Restart the server to apply changes.")
|
|
678
|
+
print(f"✅ Plugin {path} installed. Restart the server to apply changes.")
|
|
677
679
|
|
|
678
680
|
|
|
679
681
|
@cli.command()
|
|
@@ -687,7 +689,7 @@ def uninstall_plugin(id):
|
|
|
687
689
|
"""
|
|
688
690
|
with app.app_context():
|
|
689
691
|
plugins_service.uninstall_plugin(id)
|
|
690
|
-
print(f"Plugin {id} uninstalled.")
|
|
692
|
+
print(f"Plugin {id} uninstalled. Restart the server to apply changes.")
|
|
691
693
|
|
|
692
694
|
|
|
693
695
|
@cli.command()
|
|
@@ -730,6 +732,12 @@ def uninstall_plugin(id):
|
|
|
730
732
|
default="GPL-3.0-only",
|
|
731
733
|
show_default=True,
|
|
732
734
|
)
|
|
735
|
+
@click.option(
|
|
736
|
+
"--icon",
|
|
737
|
+
help="Plugin icon (lucide-vue icon name).",
|
|
738
|
+
default=None,
|
|
739
|
+
show_default=True,
|
|
740
|
+
)
|
|
733
741
|
@click.option(
|
|
734
742
|
"--force",
|
|
735
743
|
is_flag=True,
|
|
@@ -745,10 +753,11 @@ def create_plugin_skeleton(
|
|
|
745
753
|
maintainer,
|
|
746
754
|
website,
|
|
747
755
|
license,
|
|
756
|
+
icon,
|
|
748
757
|
force=False,
|
|
749
758
|
):
|
|
750
759
|
"""
|
|
751
|
-
Create a plugin
|
|
760
|
+
Create a plugin template in the given path.
|
|
752
761
|
"""
|
|
753
762
|
plugin_path = plugin_utils.create_plugin_skeleton(
|
|
754
763
|
path,
|
|
@@ -759,9 +768,10 @@ def create_plugin_skeleton(
|
|
|
759
768
|
maintainer,
|
|
760
769
|
website,
|
|
761
770
|
license,
|
|
771
|
+
icon,
|
|
762
772
|
force,
|
|
763
773
|
)
|
|
764
|
-
print(f"Plugin skeleton created in '{plugin_path}'.")
|
|
774
|
+
print(f"Plugin file tree skeleton created in '{plugin_path}'.")
|
|
765
775
|
|
|
766
776
|
|
|
767
777
|
@cli.command()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""add JSON data field to preview files
|
|
2
|
+
|
|
3
|
+
Revision ID: 12208e50bf18
|
|
4
|
+
Revises: a0f668430352
|
|
5
|
+
Create Date: 2025-12-19 11:27:57.477880
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from alembic import op
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
import sqlalchemy_utils
|
|
12
|
+
from sqlalchemy.dialects import postgresql
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision = "12208e50bf18"
|
|
16
|
+
down_revision = "a0f668430352"
|
|
17
|
+
branch_labels = None
|
|
18
|
+
depends_on = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade():
|
|
22
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
23
|
+
with op.batch_alter_table("preview_file", schema=None) as batch_op:
|
|
24
|
+
batch_op.add_column(
|
|
25
|
+
sa.Column(
|
|
26
|
+
"data", postgresql.JSONB(astext_type=sa.Text()), nullable=True
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ### end Alembic commands ###
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def downgrade():
|
|
34
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
35
|
+
with op.batch_alter_table("preview_file", schema=None) as batch_op:
|
|
36
|
+
batch_op.drop_column("data")
|
|
37
|
+
|
|
38
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""add icon field to plugins
|
|
2
|
+
|
|
3
|
+
Revision ID: 35ebb38695cd
|
|
4
|
+
Revises: 9a9df20ea5a7
|
|
5
|
+
Create Date: 2025-12-24 11:39:42.229109
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from alembic import op
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
import sqlalchemy_utils
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision = "35ebb38695cd"
|
|
16
|
+
down_revision = "9a9df20ea5a7"
|
|
17
|
+
branch_labels = None
|
|
18
|
+
depends_on = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade():
|
|
22
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
23
|
+
with op.batch_alter_table("plugin", schema=None) as batch_op:
|
|
24
|
+
batch_op.add_column(
|
|
25
|
+
sa.Column("icon", sa.String(length=255), nullable=True)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ### end Alembic commands ###
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def downgrade():
|
|
32
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
33
|
+
with op.batch_alter_table("plugin", schema=None) as batch_op:
|
|
34
|
+
batch_op.drop_column("icon")
|
|
35
|
+
|
|
36
|
+
# ### end Alembic commands ###
|