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.
Files changed (43) hide show
  1. zou/__init__.py +1 -1
  2. zou/app/blueprints/crud/person.py +1 -0
  3. zou/app/blueprints/crud/task_type.py +10 -0
  4. zou/app/blueprints/events/resources.py +24 -9
  5. zou/app/blueprints/files/resources.py +2 -0
  6. zou/app/blueprints/index/resources.py +76 -66
  7. zou/app/blueprints/news/resources.py +2 -2
  8. zou/app/blueprints/playlists/resources.py +8 -2
  9. zou/app/blueprints/previews/resources.py +46 -3
  10. zou/app/config.py +2 -0
  11. zou/app/file_trees/default.json +6 -0
  12. zou/app/file_trees/simple.json +6 -0
  13. zou/app/mixin.py +9 -0
  14. zou/app/models/plugin.py +21 -2
  15. zou/app/models/preview_file.py +2 -0
  16. zou/app/services/comments_service.py +2 -1
  17. zou/app/services/deletion_service.py +14 -5
  18. zou/app/services/events_service.py +12 -2
  19. zou/app/services/file_tree_service.py +10 -5
  20. zou/app/services/notifications_service.py +11 -2
  21. zou/app/services/persons_service.py +1 -0
  22. zou/app/services/plugins_service.py +83 -11
  23. zou/app/services/preview_files_service.py +18 -3
  24. zou/app/services/schedule_service.py +2 -1
  25. zou/app/services/tasks_service.py +1 -1
  26. zou/app/services/user_service.py +3 -0
  27. zou/app/utils/commands.py +1 -1
  28. zou/app/utils/plugins.py +114 -10
  29. zou/cli.py +15 -5
  30. zou/migrations/versions/12208e50bf18_add_json_data_field_to_preview_files.py +38 -0
  31. zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py +36 -0
  32. zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py +40 -0
  33. zou/migrations/versions/a0f668430352_add_created_by_field_to_playlists.py +16 -8
  34. zou/plugin_template/__init__.py +2 -13
  35. zou/plugin_template/migrations/env.py +9 -0
  36. zou/plugin_template/models.py +2 -2
  37. zou/plugin_template/{routes.py → resources.py} +4 -0
  38. {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/METADATA +1 -1
  39. {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/RECORD +43 -40
  40. {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/WHEEL +0 -0
  41. {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/entry_points.txt +0 -0
  42. {zou-1.0.3.dist-info → zou-1.0.5.dist-info}/licenses/LICENSE +0 -0
  43. {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
- try:
295
- file_store.remove_movie("previews", preview_file_id)
296
- except BaseException:
297
- pass
298
- for image_type in ["thumbnails", "thumbnails-square", "previews"]:
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 last 100 events published. If before parameter is set, it returns
17
- last 100 events before this date.
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
- if shots_service.is_shot(entity) or shots_service.is_scene(entity):
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
- try:
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(playlist, studio_id=None):
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"]
@@ -287,6 +287,7 @@ def update_person(person_id, data, bypass_protected_accounts=False):
287
287
  if (
288
288
  not bypass_protected_accounts
289
289
  and person.email in config.PROTECTED_ACCOUNTS
290
+ and not person.is_bot
290
291
  ):
291
292
  message = None
292
293
  if data.get("active") is False:
@@ -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
- path = Path(path)
20
- if not path.exists():
21
- raise FileNotFoundError(f"Plugin path '{path}' does not exist.")
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
- raise ValueError(
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
- uninstall_plugin_files(manifest.id)
44
- db.session.rollback()
45
- db.session.remove()
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
- entries = (
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"] in ["Asset", "Shot"]
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)
@@ -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 pathlib import Path
20
- from collections.abc import MutableMapping
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
- if init_plugin and hasattr(plugin_module, "init_plugin"):
112
- plugin_module.init_plugin(app, manifest)
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 skeleton.
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 ###