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.
Files changed (45) 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/files/resources.py +2 -0
  5. zou/app/blueprints/index/resources.py +76 -66
  6. zou/app/blueprints/news/resources.py +2 -2
  7. zou/app/blueprints/persons/resources.py +7 -5
  8. zou/app/blueprints/playlists/resources.py +8 -2
  9. zou/app/blueprints/previews/resources.py +11 -2
  10. zou/app/blueprints/source/csv/base.py +4 -3
  11. zou/app/config.py +2 -0
  12. zou/app/file_trees/default.json +6 -0
  13. zou/app/file_trees/simple.json +6 -0
  14. zou/app/mixin.py +9 -0
  15. zou/app/models/plugin.py +21 -2
  16. zou/app/models/preview_file.py +2 -0
  17. zou/app/services/comments_service.py +2 -1
  18. zou/app/services/deletion_service.py +17 -5
  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/schedule_service.py +2 -1
  24. zou/app/services/tasks_service.py +1 -1
  25. zou/app/services/time_spents_service.py +4 -1
  26. zou/app/services/user_service.py +5 -0
  27. zou/app/stores/file_store.py +29 -2
  28. zou/app/utils/commands.py +1 -1
  29. zou/app/utils/fs.py +27 -5
  30. zou/app/utils/plugins.py +122 -11
  31. zou/cli.py +15 -5
  32. zou/migrations/versions/12208e50bf18_add_json_data_field_to_preview_files.py +38 -0
  33. zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py +36 -0
  34. zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py +40 -0
  35. zou/migrations/versions/a0f668430352_add_created_by_field_to_playlists.py +16 -8
  36. zou/plugin_template/__init__.py +2 -13
  37. zou/plugin_template/migrations/env.py +9 -0
  38. zou/plugin_template/models.py +2 -2
  39. zou/plugin_template/{routes.py → resources.py} +4 -0
  40. {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/METADATA +1 -1
  41. {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/RECORD +45 -42
  42. {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/WHEEL +1 -1
  43. {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/entry_points.txt +0 -0
  44. {zou-1.0.4.dist-info → zou-1.0.6.dist-info}/licenses/LICENSE +0 -0
  45. {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(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()]
@@ -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)
@@ -184,7 +184,10 @@ def get_time_spents_for_month(
184
184
  """
185
185
  Return all time spents for given month.
186
186
  """
187
- date = datetime.datetime(int(year), int(month), 1)
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
@@ -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():
@@ -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
- for chunk in read_stream:
54
- yield chunk
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
- for chunk in open_file(prefix, instance_id):
61
- tmp_file.write(chunk)
62
- except:
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
- for chunk in open_file(prefix, instance_id):
73
- tmp_file.write(chunk)
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 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
 
@@ -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
- shutil.copytree(files_path, installation_path, dirs_exist_ok=True)
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 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 ###
@@ -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 ###