zou 1.0.3__py3-none-any.whl → 1.0.5__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/events/resources.py +24 -9
- 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/playlists/resources.py +8 -2
- zou/app/blueprints/previews/resources.py +46 -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 +14 -5
- zou/app/services/events_service.py +12 -2
- 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/preview_files_service.py +18 -3
- zou/app/services/schedule_service.py +2 -1
- zou/app/services/tasks_service.py +1 -1
- zou/app/services/user_service.py +3 -0
- zou/app/utils/commands.py +1 -1
- zou/app/utils/plugins.py +114 -10
- 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.3.dist-info → zou-1.0.5.dist-info}/METADATA +1 -1
- {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/RECORD +43 -40
- {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/WHEEL +0 -0
- {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/entry_points.txt +0 -0
- {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/licenses/LICENSE +0 -0
- {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/top_level.txt +0 -0
|
@@ -291,11 +291,12 @@ def clear_movie_files(preview_file_id):
|
|
|
291
291
|
Remove all files related to given preview file, supposing the original file
|
|
292
292
|
was a movie.
|
|
293
293
|
"""
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
294
|
+
for movie_type in ["previews", "lowdef", "source"]:
|
|
295
|
+
try:
|
|
296
|
+
file_store.remove_movie(movie_type, preview_file_id)
|
|
297
|
+
except BaseException:
|
|
298
|
+
pass
|
|
299
|
+
for image_type in ["thumbnails", "thumbnails-square", "previews", "tiles"]:
|
|
299
300
|
try:
|
|
300
301
|
file_store.remove_picture(image_type, preview_file_id)
|
|
301
302
|
except BaseException:
|
|
@@ -344,6 +345,14 @@ def remove_tasks_for_project_and_task_type(project_id, task_type_id):
|
|
|
344
345
|
def remove_project(project_id):
|
|
345
346
|
from zou.app.services import playlists_service
|
|
346
347
|
|
|
348
|
+
preview_files = (
|
|
349
|
+
PreviewFile.query.join(Task)
|
|
350
|
+
.filter(Task.project_id == project_id)
|
|
351
|
+
.all()
|
|
352
|
+
)
|
|
353
|
+
for preview_file in preview_files:
|
|
354
|
+
remove_preview_file(preview_file, force=True)
|
|
355
|
+
|
|
347
356
|
tasks = Task.query.filter_by(project_id=project_id)
|
|
348
357
|
for task in tasks:
|
|
349
358
|
remove_task(task.id, force=True)
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
from zou.app.models.event import ApiEvent
|
|
2
2
|
from zou.app.models.login_log import LoginLog
|
|
3
3
|
from zou.app.utils import fields
|
|
4
|
+
from zou.app.services.exception import WrongParameterException
|
|
4
5
|
from sqlalchemy import func
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def get_last_events(
|
|
8
9
|
after=None,
|
|
9
10
|
before=None,
|
|
11
|
+
cursor_event_id=None,
|
|
10
12
|
limit=100,
|
|
11
13
|
only_files=False,
|
|
12
14
|
project_id=None,
|
|
13
15
|
name=None,
|
|
14
16
|
):
|
|
15
17
|
"""
|
|
16
|
-
Return
|
|
17
|
-
|
|
18
|
+
Return paginated events using cursor-based pagination.
|
|
19
|
+
If cursor_event_id is set, it returns events older than this event.
|
|
18
20
|
"""
|
|
19
21
|
query = ApiEvent.query.order_by(ApiEvent.created_at.desc())
|
|
20
22
|
|
|
@@ -46,6 +48,14 @@ def get_last_events(
|
|
|
46
48
|
if name is not None:
|
|
47
49
|
query = query.filter(ApiEvent.name == name)
|
|
48
50
|
|
|
51
|
+
if cursor_event_id is not None:
|
|
52
|
+
cursor_event = ApiEvent.query.get(cursor_event_id)
|
|
53
|
+
if cursor_event is None:
|
|
54
|
+
raise WrongParameterException(
|
|
55
|
+
f"No event found with id: {cursor_event_id}"
|
|
56
|
+
)
|
|
57
|
+
query = query.filter(ApiEvent.created_at < cursor_event.created_at)
|
|
58
|
+
|
|
49
59
|
events = query.limit(limit).all()
|
|
50
60
|
return [
|
|
51
61
|
fields.serialize_dict(
|
|
@@ -595,13 +595,18 @@ def get_folder_from_sequence(entity, field="name"):
|
|
|
595
595
|
|
|
596
596
|
|
|
597
597
|
def get_folder_from_episode(entity, field="name"):
|
|
598
|
-
|
|
599
|
-
sequence = shots_service.get_sequence_from_shot(entity)
|
|
600
|
-
elif shots_service.is_sequence(entity):
|
|
601
|
-
sequence = entity
|
|
598
|
+
episode = None
|
|
602
599
|
|
|
603
|
-
|
|
600
|
+
if shots_service.is_episode(entity):
|
|
601
|
+
episode = entity
|
|
602
|
+
else:
|
|
603
|
+
if shots_service.is_shot(entity) or shots_service.is_scene(entity):
|
|
604
|
+
sequence = shots_service.get_sequence_from_shot(entity)
|
|
605
|
+
elif shots_service.is_sequence(entity):
|
|
606
|
+
sequence = entity
|
|
604
607
|
episode = shots_service.get_episode_from_sequence(sequence)
|
|
608
|
+
|
|
609
|
+
try:
|
|
605
610
|
episode_name = episode[field]
|
|
606
611
|
except BaseException:
|
|
607
612
|
episode_name = "e001"
|
|
@@ -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()]
|
|
@@ -551,12 +551,12 @@ def _clear_empty_annotations(annotations):
|
|
|
551
551
|
]
|
|
552
552
|
|
|
553
553
|
|
|
554
|
-
def get_running_preview_files():
|
|
554
|
+
def get_running_preview_files(cursor_preview_file_id=None, limit=None):
|
|
555
555
|
"""
|
|
556
556
|
Return preview files for all productions with status equals to broken
|
|
557
|
-
or processing.
|
|
557
|
+
or processing using cursor-based pagination.
|
|
558
558
|
"""
|
|
559
|
-
|
|
559
|
+
query = (
|
|
560
560
|
PreviewFile.query.join(Task)
|
|
561
561
|
.join(Project)
|
|
562
562
|
.join(ProjectStatus, ProjectStatus.id == Project.project_status_id)
|
|
@@ -566,6 +566,21 @@ def get_running_preview_files():
|
|
|
566
566
|
.order_by(PreviewFile.created_at.desc())
|
|
567
567
|
)
|
|
568
568
|
|
|
569
|
+
if cursor_preview_file_id is not None:
|
|
570
|
+
cursor_preview_file = PreviewFile.query.get(cursor_preview_file_id)
|
|
571
|
+
if cursor_preview_file is None:
|
|
572
|
+
raise WrongParameterException(
|
|
573
|
+
f"No preview file found with id: {cursor_preview_file_id}"
|
|
574
|
+
)
|
|
575
|
+
query = query.filter(
|
|
576
|
+
PreviewFile.created_at < cursor_preview_file.created_at
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
if limit is not None:
|
|
580
|
+
query = query.limit(limit)
|
|
581
|
+
|
|
582
|
+
entries = query.all()
|
|
583
|
+
|
|
569
584
|
results = []
|
|
570
585
|
for preview_file, project_id, task_type_id, entity_id in entries:
|
|
571
586
|
result = preview_file.serialize()
|
|
@@ -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)
|
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,
|
|
@@ -1670,6 +1672,7 @@ def get_context():
|
|
|
1670
1672
|
"search_filters": get_filters(),
|
|
1671
1673
|
"search_filter_groups": get_filter_groups(),
|
|
1672
1674
|
"preview_background_files": files_service.get_preview_background_files(),
|
|
1675
|
+
"plugins": plugins_service.get_plugins(),
|
|
1673
1676
|
}
|
|
1674
1677
|
|
|
1675
1678
|
if permissions.has_admin_permissions():
|
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/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
|
|
|
@@ -338,3 +391,54 @@ def uninstall_plugin_files(plugin_path):
|
|
|
338
391
|
shutil.rmtree(plugin_path)
|
|
339
392
|
return True
|
|
340
393
|
return False
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def clone_git_repo(git_url, temp_dir=None):
|
|
397
|
+
"""
|
|
398
|
+
Clone a git repository to a temporary directory.
|
|
399
|
+
Returns the path to the cloned directory.
|
|
400
|
+
"""
|
|
401
|
+
if temp_dir is None:
|
|
402
|
+
temp_dir = tempfile.mkdtemp(prefix="zou_plugin_")
|
|
403
|
+
|
|
404
|
+
temp_dir = Path(temp_dir)
|
|
405
|
+
repo_name = git_url.rstrip("/").split("/")[-1].replace(".git", "")
|
|
406
|
+
clone_path = temp_dir / repo_name
|
|
407
|
+
|
|
408
|
+
print(f"[Plugins] Cloning {git_url}...")
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
subprocess.run(
|
|
412
|
+
["git", "clone", git_url, str(clone_path)],
|
|
413
|
+
check=True,
|
|
414
|
+
capture_output=True,
|
|
415
|
+
timeout=300,
|
|
416
|
+
)
|
|
417
|
+
print(f"[Plugins] Successfully cloned {git_url}")
|
|
418
|
+
return clone_path
|
|
419
|
+
except subprocess.CalledProcessError as e:
|
|
420
|
+
error_msg = e.stderr.decode() if e.stderr else str(e)
|
|
421
|
+
raise ValueError(f"Failed to clone repository {git_url}: {error_msg}")
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
raise ValueError(
|
|
424
|
+
"git is not available. Please install git to clone repositories."
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def add_static_routes(manifest, routes):
|
|
429
|
+
"""
|
|
430
|
+
Add static routes to the manifest.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
class PluginStaticResource(StaticResource):
|
|
434
|
+
|
|
435
|
+
def __init__(self):
|
|
436
|
+
self.plugin_id = manifest.id
|
|
437
|
+
super().__init__()
|
|
438
|
+
|
|
439
|
+
if (
|
|
440
|
+
manifest["frontend_project_enabled"]
|
|
441
|
+
or manifest["frontend_studio_enabled"]
|
|
442
|
+
):
|
|
443
|
+
routes.append((f"/frontend/<path:filename>", PluginStaticResource))
|
|
444
|
+
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 ###
|