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
zou/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.0.4"
1
+ __version__ = "1.0.6"
@@ -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
@@ -672,7 +672,7 @@ class PersonMonthAllTimeSpentsResource(Resource):
672
672
  )
673
673
  return fields.serialize_list(timespents)
674
674
  except WrongDateFormatException:
675
- abort(404)
675
+ raise WrongParameterException("Invalid month or year.")
676
676
 
677
677
 
678
678
  class PersonWeekTimeSpentsResource(PersonDurationTimeSpentsResource):
@@ -1160,10 +1160,12 @@ class TimeSpentMonthResource(TimeSpentDurationResource):
1160
1160
  schema:
1161
1161
  type: object
1162
1162
  """
1163
-
1164
- return time_spents_service.get_day_table(
1165
- year, month, **self.get_person_project_department_arguments()
1166
- )
1163
+ try:
1164
+ return time_spents_service.get_day_table(
1165
+ year, month, **self.get_person_project_department_arguments()
1166
+ )
1167
+ except WrongDateFormatException:
1168
+ raise WrongParameterException("Invalid month or year.")
1167
1169
 
1168
1170
 
1169
1171
  class TimeSpentYearsResource(TimeSpentDurationResource):
@@ -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
 
@@ -68,7 +68,7 @@ class BaseCsvImportResource(Resource, ArgsMixin):
68
68
  result = []
69
69
  self.check_permissions(*args)
70
70
  self.prepare_import(*args)
71
- with open(file_path) as csvfile:
71
+ with open(file_path, newline="", encoding="utf-8") as csvfile:
72
72
  reader = csv.DictReader(csvfile, dialect=self.get_dialect(csvfile))
73
73
  line_number = 1
74
74
  for row in reader:
@@ -94,9 +94,10 @@ class BaseCsvImportResource(Resource, ArgsMixin):
94
94
 
95
95
  def get_dialect(self, csvfile):
96
96
  sniffer = csv.Sniffer()
97
- dialect = sniffer.sniff(csvfile.read())
98
- dialect.doublequote = True
97
+ sample = csvfile.read(8192)
99
98
  csvfile.seek(0)
99
+ dialect = sniffer.sniff(sample)
100
+ dialect.doublequote = True
100
101
  return dialect
101
102
 
102
103
  def prepare_import(self, *args):
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(
@@ -42,6 +42,7 @@ from zou.app.services.exception import (
42
42
  CommentNotFoundException,
43
43
  ModelWithRelationsDeletionException,
44
44
  PersonInProtectedAccounts,
45
+ PreviewFileNotFoundException,
45
46
  )
46
47
 
47
48
 
@@ -157,6 +158,8 @@ def remove_task(task_id, force=False):
157
158
 
158
159
  def remove_preview_file_by_id(preview_file_id, force=False):
159
160
  preview_file = PreviewFile.get(preview_file_id)
161
+ if preview_file is None:
162
+ raise PreviewFileNotFoundException
160
163
  return remove_preview_file(preview_file, force=force)
161
164
 
162
165
 
@@ -291,11 +294,12 @@ def clear_movie_files(preview_file_id):
291
294
  Remove all files related to given preview file, supposing the original file
292
295
  was a movie.
293
296
  """
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"]:
297
+ for movie_type in ["previews", "lowdef", "source"]:
298
+ try:
299
+ file_store.remove_movie(movie_type, preview_file_id)
300
+ except BaseException:
301
+ pass
302
+ for image_type in ["thumbnails", "thumbnails-square", "previews", "tiles"]:
299
303
  try:
300
304
  file_store.remove_picture(image_type, preview_file_id)
301
305
  except BaseException:
@@ -344,6 +348,14 @@ def remove_tasks_for_project_and_task_type(project_id, task_type_id):
344
348
  def remove_project(project_id):
345
349
  from zou.app.services import playlists_service
346
350
 
351
+ preview_files = (
352
+ PreviewFile.query.join(Task)
353
+ .filter(Task.project_id == project_id)
354
+ .all()
355
+ )
356
+ for preview_file in preview_files:
357
+ remove_preview_file(preview_file, force=True)
358
+
347
359
  tasks = Task.query.filter_by(project_id=project_id)
348
360
  for task in tasks:
349
361
  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"