zou 1.0.4__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 (40) 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/playlists/resources.py +8 -2
  8. zou/app/blueprints/previews/resources.py +11 -2
  9. zou/app/config.py +2 -0
  10. zou/app/file_trees/default.json +6 -0
  11. zou/app/file_trees/simple.json +6 -0
  12. zou/app/mixin.py +9 -0
  13. zou/app/models/plugin.py +21 -2
  14. zou/app/models/preview_file.py +2 -0
  15. zou/app/services/comments_service.py +2 -1
  16. zou/app/services/deletion_service.py +14 -5
  17. zou/app/services/file_tree_service.py +10 -5
  18. zou/app/services/notifications_service.py +11 -2
  19. zou/app/services/persons_service.py +1 -0
  20. zou/app/services/plugins_service.py +83 -11
  21. zou/app/services/schedule_service.py +2 -1
  22. zou/app/services/tasks_service.py +1 -1
  23. zou/app/services/user_service.py +3 -0
  24. zou/app/utils/commands.py +1 -1
  25. zou/app/utils/plugins.py +114 -10
  26. zou/cli.py +15 -5
  27. zou/migrations/versions/12208e50bf18_add_json_data_field_to_preview_files.py +38 -0
  28. zou/migrations/versions/35ebb38695cd_add_icon_field_to_plugins.py +36 -0
  29. zou/migrations/versions/9a9df20ea5a7_add_frontend_options_to_plugins.py +40 -0
  30. zou/migrations/versions/a0f668430352_add_created_by_field_to_playlists.py +16 -8
  31. zou/plugin_template/__init__.py +2 -13
  32. zou/plugin_template/migrations/env.py +9 -0
  33. zou/plugin_template/models.py +2 -2
  34. zou/plugin_template/{routes.py → resources.py} +4 -0
  35. {zou-1.0.4.dist-info → zou-1.0.5.dist-info}/METADATA +1 -1
  36. {zou-1.0.4.dist-info → zou-1.0.5.dist-info}/RECORD +40 -37
  37. {zou-1.0.4.dist-info → zou-1.0.5.dist-info}/WHEEL +0 -0
  38. {zou-1.0.4.dist-info → zou-1.0.5.dist-info}/entry_points.txt +0 -0
  39. {zou-1.0.4.dist-info → zou-1.0.5.dist-info}/licenses/LICENSE +0 -0
  40. {zou-1.0.4.dist-info → zou-1.0.5.dist-info}/top_level.txt +0 -0
zou/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.0.4"
1
+ __version__ = "1.0.5"
@@ -604,6 +604,7 @@ class PersonResource(BaseModelResource, ArgsMixin):
604
604
  if (
605
605
  instance_dict["email"] in config.PROTECTED_ACCOUNTS
606
606
  and instance_dict["id"] != persons_service.get_current_user()["id"]
607
+ and instance_dict.get("is_bot", False) == False
607
608
  ):
608
609
  message = None
609
610
  if data.get("active") is False:
@@ -1,6 +1,7 @@
1
1
  from flask_jwt_extended import jwt_required
2
2
 
3
3
  from zou.app.models.task_type import TaskType
4
+ from zou.app.models.schedule_item import ScheduleItem
4
5
  from zou.app.services.exception import WrongParameterException
5
6
  from zou.app.services import tasks_service
6
7
 
@@ -317,6 +318,15 @@ class TaskTypeResource(BaseModelResource):
317
318
  """
318
319
  return super().delete(instance_id)
319
320
 
321
+ def pre_delete(self, instance_dict):
322
+ """
323
+ Delete related ScheduleItems before deleting the TaskType.
324
+ This runs AFTER check_delete_permissions, preventing foreign key
325
+ constraint violations while maintaining security.
326
+ """
327
+ ScheduleItem.query.filter_by(task_type_id=instance_dict["id"]).delete()
328
+ return instance_dict
329
+
320
330
  def update_data(self, data, instance_id):
321
331
  data = super().update_data(data, instance_id)
322
332
  name = data.get("name", None)
@@ -1303,6 +1303,8 @@ class NewEntityOutputFileResource(Resource, ArgsMixin):
1303
1303
  return {"error": "Cannot find given person."}, 400
1304
1304
  except EntryAlreadyExistsException:
1305
1305
  return {"error": "The given output file already exists."}, 400
1306
+ except MalformedFileTreeException as exception:
1307
+ return {"error": str(exception)}, 400
1306
1308
 
1307
1309
  return output_file_dict, 201
1308
1310
 
@@ -1,16 +1,21 @@
1
+ import datetime
2
+
1
3
  import psutil
2
4
  import redis
3
5
  import requests
4
- import datetime
5
-
6
6
  from flask import Response, abort
7
+ from flask_jwt_extended import jwt_required
7
8
  from flask_restful import Resource
8
- from zou import __version__
9
9
 
10
+ from zou import __version__
10
11
  from zou.app import app, config
11
- from zou.app.utils import permissions, shell, date_helpers
12
- from zou.app.services import projects_service, stats_service, persons_service
13
- from flask_jwt_extended import jwt_required
12
+ from zou.app.indexer import indexing
13
+ from zou.app.services import (
14
+ persons_service,
15
+ projects_service,
16
+ stats_service,
17
+ )
18
+ from zou.app.utils import date_helpers, permissions, shell
14
19
  from zou.app.utils.redis import get_redis_url
15
20
 
16
21
 
@@ -40,14 +45,32 @@ class IndexResource(Resource):
40
45
 
41
46
 
42
47
  class BaseStatusResource(Resource):
48
+
43
49
  def get_status(self):
44
- is_db_up = True
50
+ is_db_up = self._check_database()
51
+ is_kv_up = self._check_key_value_store()
52
+ is_es_up = self._check_event_stream()
53
+ is_jq_up = self._check_job_queue()
54
+ is_indexer_up = self._check_indexer()
55
+
56
+ return (
57
+ config.APP_NAME,
58
+ __version__,
59
+ is_db_up,
60
+ is_kv_up,
61
+ is_es_up,
62
+ is_jq_up,
63
+ is_indexer_up,
64
+ )
65
+
66
+ def _check_database(self):
45
67
  try:
46
68
  projects_service.get_or_create_status("Open")
69
+ return True
47
70
  except Exception:
48
- is_db_up = False
71
+ return False
49
72
 
50
- is_kv_up = True
73
+ def _check_key_value_store(self):
51
74
  try:
52
75
  store = redis.StrictRedis(
53
76
  host=config.KEY_VALUE_STORE["host"],
@@ -57,18 +80,21 @@ class BaseStatusResource(Resource):
57
80
  decode_responses=True,
58
81
  )
59
82
  store.get("test")
83
+ return True
60
84
  except redis.ConnectionError:
61
- is_kv_up = False
85
+ return False
62
86
 
63
- is_es_up = True
87
+ def _check_event_stream(self):
64
88
  try:
65
89
  requests.get(
66
- f"http://{config.EVENT_STREAM_HOST}:{config.EVENT_STREAM_PORT}"
90
+ f"http://{config.EVENT_STREAM_HOST}:{config.EVENT_STREAM_PORT}",
91
+ timeout=5,
67
92
  )
93
+ return True
68
94
  except Exception:
69
- is_es_up = False
95
+ return False
70
96
 
71
- is_jq_up = True
97
+ def _check_job_queue(self):
72
98
  try:
73
99
  args = [
74
100
  "rq",
@@ -77,30 +103,20 @@ class BaseStatusResource(Resource):
77
103
  get_redis_url(config.KV_JOB_DB_INDEX),
78
104
  ]
79
105
  out = shell.run_command(args)
80
- is_jq_up = b"0 workers" not in out
106
+ return b"0 workers" not in out
81
107
  except Exception:
82
108
  app.logger.error("Job queue is not accessible", exc_info=1)
83
- is_jq_up = False
109
+ return False
84
110
 
85
- is_indexer_up = True
111
+ def _check_indexer(self):
86
112
  try:
87
- requests.get(
88
- f"{config.INDEXER['protocol']}://{config.INDEXER['host']}:{config.INDEXER['port']}"
89
- )
113
+ client = indexing.get_client()
114
+ client.get_indexes()
115
+ return True
116
+ except indexing.IndexerNotInitializedError:
117
+ return False
90
118
  except Exception:
91
- is_indexer_up = False
92
-
93
- version = __version__
94
-
95
- return (
96
- config.APP_NAME,
97
- version,
98
- is_db_up,
99
- is_kv_up,
100
- is_es_up,
101
- is_jq_up,
102
- is_indexer_up,
103
- )
119
+ return False
104
120
 
105
121
 
106
122
  class StatusResource(BaseStatusResource):
@@ -224,9 +240,16 @@ class StatusResourcesResource(BaseStatusResource):
224
240
  type: integer
225
241
  example: 3
226
242
  """
227
- loadavg = list(psutil.getloadavg())
243
+ return {
244
+ "date": datetime.datetime.now().isoformat(),
245
+ "cpu": self._get_cpu_stats(),
246
+ "memory": self._get_memory_stats(),
247
+ "jobs": self._get_job_stats(),
248
+ }
228
249
 
229
- cpu_stats = {
250
+ def _get_cpu_stats(self):
251
+ loadavg = list(psutil.getloadavg())
252
+ return {
230
253
  "percent": psutil.cpu_percent(interval=1, percpu=True),
231
254
  "loadavg": {
232
255
  "last 1 min": loadavg[0],
@@ -235,29 +258,23 @@ class StatusResourcesResource(BaseStatusResource):
235
258
  },
236
259
  }
237
260
 
238
- memory_stats = {
239
- "total": psutil.virtual_memory().total,
240
- "used": psutil.virtual_memory().used,
241
- "available": psutil.virtual_memory().available,
242
- "percent": psutil.virtual_memory().percent,
261
+ def _get_memory_stats(self):
262
+ memory = psutil.virtual_memory()
263
+ return {
264
+ "total": memory.total,
265
+ "used": memory.used,
266
+ "available": memory.available,
267
+ "percent": memory.percent,
243
268
  }
244
269
 
270
+ def _get_job_stats(self):
245
271
  nb_jobs = 0
246
272
  if config.ENABLE_JOB_QUEUE:
247
273
  from zou.app.stores.queue_store import job_queue
248
274
 
249
275
  registry = job_queue.started_job_registry
250
276
  nb_jobs = registry.count
251
- job_stats = {
252
- "running_jobs": nb_jobs,
253
- }
254
-
255
- return {
256
- "date": datetime.datetime.now().isoformat(),
257
- "cpu": cpu_stats,
258
- "memory": memory_stats,
259
- "jobs": job_stats,
260
- }
277
+ return {"running_jobs": nb_jobs}
261
278
 
262
279
 
263
280
  class TxtStatusResource(BaseStatusResource):
@@ -294,22 +311,14 @@ class TxtStatusResource(BaseStatusResource):
294
311
  is_indexer_up,
295
312
  ) = self.get_status()
296
313
 
297
- text = """name: %s
298
- version: %s
299
- database-up: %s
300
- event-stream-up: %s
301
- key-value-store-up: %s
302
- job-queue-up: %s
303
- indexer-up: %s
304
- """ % (
305
- api_name,
306
- version,
307
- "up" if is_db_up else "down",
308
- "up" if is_kv_up else "down",
309
- "up" if is_es_up else "down",
310
- "up" if is_jq_up else "down",
311
- "up" if is_indexer_up else "down",
312
- )
314
+ text = f"""name: {api_name}
315
+ version: {version}
316
+ database-up: {"up" if is_db_up else "down"}
317
+ event-stream-up: {"up" if is_es_up else "down"}
318
+ key-value-store-up: {"up" if is_kv_up else "down"}
319
+ job-queue-up: {"up" if is_jq_up else "down"}
320
+ indexer-up: {"up" if is_indexer_up else "down"}
321
+ """
313
322
  return Response(text, mimetype="text")
314
323
 
315
324
 
@@ -469,6 +478,7 @@ class ConfigResource(Resource):
469
478
  "saml_idp_name": config.SAML_IDP_NAME,
470
479
  "default_locale": config.DEFAULT_LOCALE,
471
480
  "default_timezone": config.DEFAULT_TIMEZONE,
481
+ "enforce_2fa": config.ENFORCE_2FA,
472
482
  }
473
483
  if config.SENTRY_KITSU_ENABLED:
474
484
  conf["sentry"] = {
@@ -228,7 +228,7 @@ class NewsResource(Resource, NewsMixin, ArgsMixin):
228
228
  """
229
229
  Get open projects news
230
230
  ---
231
- description: Returns the latest news and activity feed from all
231
+ description: Returns the latest news and activity feed from all
232
232
  projects the user has access to.
233
233
  tags:
234
234
  - News
@@ -348,7 +348,7 @@ class ProjectSingleNewsResource(Resource):
348
348
  """
349
349
  Get news item
350
350
  ---
351
- description: Retrieves detailed information about a specific news item
351
+ description: Retrieves detailed information about a specific news item
352
352
  from a givenproject.
353
353
  tags:
354
354
  - News
@@ -835,6 +835,11 @@ class NotifyClientsResource(Resource, ArgsMixin):
835
835
  format: uuid
836
836
  description: Studio unique identifier to notify
837
837
  example: b35b7fb5-df86-5776-b181-68564193d36
838
+ department_id:
839
+ type: string
840
+ format: uuid
841
+ description: Department unique identifier to notify
842
+ example: c46c8gc6-eg97-6887-c292-79675204e47
838
843
  responses:
839
844
  200:
840
845
  description: Clients notified successfully
@@ -848,11 +853,12 @@ class NotifyClientsResource(Resource, ArgsMixin):
848
853
  description: Notification status
849
854
  example: "success"
850
855
  """
851
- studio_id = request.json.get("studio_id", None)
856
+ studio_id = self.get_id_parameter("studio_id", None)
857
+ department_id = self.get_id_parameter("department_id", None)
852
858
  playlist = playlists_service.get_playlist(playlist_id)
853
859
  project_id = playlist["project_id"]
854
860
  user_service.check_manager_project_access(project_id)
855
861
  notifications_service.notify_clients_playlist_ready(
856
- playlist, studio_id
862
+ playlist, studio_id, department_id
857
863
  )
858
864
  return {"status": "success"}
@@ -248,15 +248,24 @@ class BaseNewPreviewFilePicture:
248
248
  uploaded_movie_path = movie.save_file(
249
249
  tmp_folder, preview_file_id, uploaded_file
250
250
  )
251
+ save_source_file = config.PREVIEW_SAVE_SOURCE_FILE
251
252
  if normalize and config.ENABLE_JOB_QUEUE and not no_job:
252
253
  queue_store.job_queue.enqueue(
253
254
  preview_files_service.prepare_and_store_movie,
254
- args=(preview_file_id, uploaded_movie_path),
255
+ args=(
256
+ preview_file_id,
257
+ uploaded_movie_path,
258
+ True,
259
+ save_source_file,
260
+ ),
255
261
  job_timeout=int(config.JOB_QUEUE_TIMEOUT),
256
262
  )
257
263
  else:
258
264
  preview_files_service.prepare_and_store_movie(
259
- preview_file_id, uploaded_movie_path, normalize=normalize
265
+ preview_file_id,
266
+ uploaded_movie_path,
267
+ normalize=normalize,
268
+ add_source_to_file_store=save_source_file,
260
269
  )
261
270
  return preview_file_id
262
271
 
zou/app/config.py CHANGED
@@ -68,6 +68,7 @@ PREVIEW_FOLDER = os.getenv(
68
68
  "PREVIEW_FOLDER",
69
69
  os.getenv("THUMBNAIL_FOLDER", os.path.join(os.getcwd(), "previews")),
70
70
  )
71
+ PREVIEW_SAVE_SOURCE_FILE = envtobool("PREVIEW_SAVE_SOURCE_FILE", False)
71
72
  TMP_DIR = os.getenv("TMP_DIR", os.path.join(tempfile.gettempdir(), "zou"))
72
73
 
73
74
  EVENT_STREAM_HOST = os.getenv("EVENT_STREAM_HOST", "localhost")
@@ -165,6 +166,7 @@ DEFAULT_LOCALE = os.getenv("DEFAULT_LOCALE", "en_US")
165
166
  USER_LIMIT = int(os.getenv("USER_LIMIT", "100"))
166
167
  MIN_PASSWORD_LENGTH = int(os.getenv("MIN_PASSWORD_LENGTH", 8))
167
168
  PROTECTED_ACCOUNTS = env_with_semicolon_to_list("PROTECTED_ACCOUNTS")
169
+ ENFORCE_2FA = envtobool("ENFORCE_2FA", False)
168
170
 
169
171
  TELEMETRY_URL = os.getenv(
170
172
  "TELEMETRY_URL",
@@ -7,6 +7,7 @@
7
7
  "asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>",
8
8
  "sequence": "<Project>/sequences/<Sequence>/<TaskType>",
9
9
  "scene": "<Project>/scenes/<Sequence>/<Scene>/<TaskType>",
10
+ "episode": "<Project>/episodes/<Episode>/<TaskType>",
10
11
  "style": "lowercase"
11
12
  },
12
13
  "file_name": {
@@ -14,6 +15,7 @@
14
15
  "asset": "<Project>_<AssetType>_<Asset>_<TaskType>",
15
16
  "sequence": "<Project>_<Sequence>_<TaskType>",
16
17
  "scene": "<Project>_<Sequence>_<Scene>_<TaskType>",
18
+ "episode": "<Project>_<Episode>_<TaskType>",
17
19
  "style": "lowercase"
18
20
  }
19
21
  },
@@ -25,6 +27,7 @@
25
27
  "asset": "<Project>/assets/<AssetType>/<Asset>/<OutputType>",
26
28
  "sequence": "<Project>/sequences/<Sequence>/<OutputType>",
27
29
  "scene": "<Project>/scenes/<Sequence>/<Scene>/<OutputType>",
30
+ "episode": "<Project>/episodes/<Episode>/<OutputType>",
28
31
  "style": "lowercase"
29
32
  },
30
33
  "file_name": {
@@ -32,6 +35,7 @@
32
35
  "asset": "<Project>_<AssetType>_<Asset>_<OutputType>_<OutputFile>",
33
36
  "sequence": "<Project>_<Sequence>_<OuputType>_<OutputFile>",
34
37
  "scene": "<Project>_<Sequence>_<Scene>_<OutputType>_<OutputFile>",
38
+ "episode": "<Project>_<Episode>_<OutputType>_<OutputFile>",
35
39
  "style": "lowercase"
36
40
  }
37
41
  },
@@ -43,6 +47,7 @@
43
47
  "asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>",
44
48
  "sequence": "<Project>/sequences/<Sequence>/<TaskType>",
45
49
  "scene": "<Project>/scene/<Scene>/<TaskType>",
50
+ "episode": "<Project>/episodes/<Episode>/<TaskType>",
46
51
  "style": "lowercase"
47
52
  },
48
53
  "file_name": {
@@ -50,6 +55,7 @@
50
55
  "asset": "<Project>_<AssetType>_<Asset>_<TaskType>",
51
56
  "sequence": "<Project>_<Sequence>_<TaskType>",
52
57
  "scene": "<Project>_<Scene>_<TaskType>",
58
+ "episode": "<Project>_<Episode>_<TaskType>",
53
59
  "style": "lowercase"
54
60
  }
55
61
  }
@@ -7,6 +7,7 @@
7
7
  "asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>/<Software>",
8
8
  "sequence": "<Project>/sequences/<Sequence>/<TaskType>/<Software>",
9
9
  "scene": "<Project>/scenes/<Sequence>/<Scene>/<TaskType>/<Software>",
10
+ "episode": "<Project>/episodes/<Episode>/<TaskType>/<Software>",
10
11
  "style": "lowercase"
11
12
  },
12
13
  "file_name": {
@@ -14,6 +15,7 @@
14
15
  "asset": "<Project>_<AssetType>_<Asset>_<TaskType>_<Name>_v<Version>",
15
16
  "sequence": "<Project>_<Sequence>_<TaskType>_<Name>_v<Version>",
16
17
  "scene": "<Project>_<Scene>_<TaskType>_<Name>_v<Version>",
18
+ "episode": "<Project>_<Episode>_<TaskType>_<Name>_v<Version>",
17
19
  "style": "lowercase"
18
20
  }
19
21
  },
@@ -25,6 +27,7 @@
25
27
  "asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>/<OutputType>",
26
28
  "sequence": "<Project>/sequences/<Sequence>/<TaskType>/<OutputType>",
27
29
  "scene": "<Project>/scenes/<Sequence>/<Scene>/<TaskType>/<OutputType>",
30
+ "episode": "<Project>/episodes/<Episode>/<TaskType>/<OutputType>",
28
31
  "instance_asset": "<Project>/assets/<TemporalEntityType>/<TemporalEntity>/<TaskType>/<OutputType>/<AssetType>/<Asset>/instance_<Instance.number>/<Representation>",
29
32
  "instance": "<Project>/<TemporalEntityType>/<Sequence>/<TemporalEntity>/<TaskType>/<OutputType>/<AssetType>/<Asset>/instance_<Instance.number>/<Representation>",
30
33
  "style": "lowercase"
@@ -34,6 +37,7 @@
34
37
  "asset": "<Project>_<AssetType>_<Asset>_<TaskType>_<OutputType>_<Name>_v<Version>",
35
38
  "sequence": "<Project>_<Sequence>_<TaskType>_<OutputType>_<Name>_v<Version>",
36
39
  "scene": "<Project>_<Scene>_<TaskType>_<OutputType>_<Name>_v<Version>",
40
+ "episode": "<Project>_<Episode>_<TaskType>_<OutputType>_<Name>_v<Version>",
37
41
  "instance_asset": "<Project>_<TemporalEntityType>_<TemporalEntity>_<TaskType>_<OutputType>_<Name>_<Instance.name>_v<Version>",
38
42
  "instance": "<Project>_<Sequence>_<TemporalEntity>_<TaskType>_<OutputType>_<Name>_<Instance.name>_v<Version>",
39
43
  "style": "lowercase"
@@ -47,6 +51,7 @@
47
51
  "asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>",
48
52
  "sequence": "<Project>/sequences/<Sequence>/<TaskType>",
49
53
  "scene": "<Project>/scenes/<Sequence>/<Scene>/<TaskType>",
54
+ "episode": "<Project>/episodes/<Episode>/<TaskType>",
50
55
  "style": "lowercase"
51
56
  },
52
57
  "file_name": {
@@ -54,6 +59,7 @@
54
59
  "asset": "<Project>_<AssetType>_<Asset>_<TaskType>",
55
60
  "sequence": "<Project>_<Sequence>_<TaskType>",
56
61
  "scene": "<Project>_<Scene>_<TaskType>",
62
+ "episode": "<Project>_<Episode>_<TaskType>",
57
63
  "style": "lowercase"
58
64
  }
59
65
  }
zou/app/mixin.py CHANGED
@@ -168,3 +168,12 @@ class ArgsMixin(object):
168
168
  if not fields.is_valid_id(uuid):
169
169
  raise WrongParameterException("Wrong UUID format.")
170
170
  return True
171
+
172
+ def get_id_parameter(self, field_name, default=None):
173
+ """
174
+ Returns ID parameter value matching `field_name`.
175
+ """
176
+ entity_id = self.get_text_parameter(field_name + "_id", default)
177
+ if entity_id is not None and entity_id != "":
178
+ self.check_id_parameter(entity_id)
179
+ return entity_id
zou/app/models/plugin.py CHANGED
@@ -1,12 +1,14 @@
1
+ from sqlalchemy_utils import EmailType, URLType
2
+
1
3
  from zou.app import db
2
4
  from zou.app.models.serializer import SerializerMixin
3
5
  from zou.app.models.base import BaseMixin
4
- from sqlalchemy_utils import EmailType, URLType
5
6
 
6
7
 
7
8
  class Plugin(db.Model, BaseMixin, SerializerMixin):
8
9
  """
9
- Describe a plugin.
10
+ A plugin is a module used to extend the functionality of Zou.
11
+ You can extend the REST API routes and the database models.
10
12
  """
11
13
 
12
14
  plugin_id = db.Column(
@@ -20,3 +22,20 @@ class Plugin(db.Model, BaseMixin, SerializerMixin):
20
22
  website = db.Column(URLType)
21
23
  license = db.Column(db.String(80), nullable=False)
22
24
  revision = db.Column(db.String(12), nullable=True)
25
+ frontend_project_enabled = db.Column(db.Boolean(), default=False)
26
+ frontend_studio_enabled = db.Column(db.Boolean(), default=False)
27
+ icon = db.Column(db.String(255), nullable=True) # lucide-vue icon name
28
+
29
+ def present(self):
30
+ return {
31
+ "id": self.id,
32
+ "plugin_id": self.plugin_id,
33
+ "name": self.name,
34
+ "description": self.description,
35
+ "version": self.version,
36
+ "maintainer_name": self.maintainer_name,
37
+ "maintainer_email": self.maintainer_email,
38
+ "frontend_project_enabled": self.frontend_project_enabled,
39
+ "frontend_studio_enabled": self.frontend_studio_enabled,
40
+ "icon": self.icon,
41
+ }
@@ -1,4 +1,5 @@
1
1
  from sqlalchemy_utils import UUIDType, ChoiceType
2
+ from sqlalchemy.dialects.postgresql import JSONB
2
3
 
3
4
  from zou.app import db
4
5
  from zou.app.models.serializer import SerializerMixin
@@ -45,6 +46,7 @@ class PreviewFile(db.Model, BaseMixin, SerializerMixin):
45
46
  width = db.Column(db.Integer(), default=0)
46
47
  height = db.Column(db.Integer(), default=0)
47
48
  duration = db.Column(db.Float, default=0)
49
+ data = db.Column(JSONB)
48
50
 
49
51
  task_id = db.Column(
50
52
  UUIDType(binary=False), db.ForeignKey("task.id"), index=True
@@ -188,7 +188,8 @@ def _manage_status_change(task_status, task, comment):
188
188
  new_data["retake_count"] = retake_count + 1
189
189
 
190
190
  if task_status["is_feedback_request"]:
191
- new_data["end_date"] = date_helpers.get_utc_now_datetime()
191
+ if task.get("end_date") is None:
192
+ new_data["end_date"] = date_helpers.get_utc_now_datetime()
192
193
 
193
194
  if task_status["is_wip"] and task["real_start_date"] is None:
194
195
  new_data["real_start_date"] = datetime.datetime.now(
@@ -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)
@@ -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: