mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-25 05:14:40 +01:00
Merge branch 'develop' into bugfix/1587-hiero-published-whole-edit-mov
This commit is contained in:
commit
71fc0fceef
27 changed files with 992 additions and 202 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -1,23 +1,39 @@
|
|||
# Changelog
|
||||
|
||||
## [3.1.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD)
|
||||
## [3.1.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
|
||||
|
||||
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.0.0...HEAD)
|
||||
|
||||
#### 🚀 Enhancements
|
||||
|
||||
- Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689)
|
||||
- \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683)
|
||||
- Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682)
|
||||
- Project Manger: Default name column width [\#1669](https://github.com/pypeclub/OpenPype/pull/1669)
|
||||
- Remove outline in stylesheet [\#1667](https://github.com/pypeclub/OpenPype/pull/1667)
|
||||
- TVPaint: Creator take layer name as default value for subset variant [\#1663](https://github.com/pypeclub/OpenPype/pull/1663)
|
||||
- TVPaint custom subset template [\#1662](https://github.com/pypeclub/OpenPype/pull/1662)
|
||||
- Feature Slack integration [\#1657](https://github.com/pypeclub/OpenPype/pull/1657)
|
||||
- Nuke - Publish simplification [\#1653](https://github.com/pypeclub/OpenPype/pull/1653)
|
||||
- StandalonePublisher: adding exception for adding `delete` tag to repre [\#1650](https://github.com/pypeclub/OpenPype/pull/1650)
|
||||
- \#1333 - added tooltip hints to Pyblish buttons [\#1649](https://github.com/pypeclub/OpenPype/pull/1649)
|
||||
|
||||
#### 🐛 Bug fixes
|
||||
|
||||
- Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691)
|
||||
- Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675)
|
||||
- Settings list race condifiton and mutable dict list conversion [\#1671](https://github.com/pypeclub/OpenPype/pull/1671)
|
||||
- Mac launch arguments fix [\#1660](https://github.com/pypeclub/OpenPype/pull/1660)
|
||||
- Fix missing dbm python module [\#1652](https://github.com/pypeclub/OpenPype/pull/1652)
|
||||
- Transparent branches in view on Mac [\#1648](https://github.com/pypeclub/OpenPype/pull/1648)
|
||||
- Add asset on task item [\#1646](https://github.com/pypeclub/OpenPype/pull/1646)
|
||||
- Project manager save and queue [\#1645](https://github.com/pypeclub/OpenPype/pull/1645)
|
||||
- New project anatomy values [\#1644](https://github.com/pypeclub/OpenPype/pull/1644)
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686)
|
||||
- Add docstrings to Project manager tool [\#1556](https://github.com/pypeclub/OpenPype/pull/1556)
|
||||
|
||||
# Changelog
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -972,8 +972,12 @@ class BootstrapRepos:
|
|||
"openpype/version.py") as version_file:
|
||||
zip_version = {}
|
||||
exec(version_file.read(), zip_version)
|
||||
version_check = OpenPypeVersion(
|
||||
version=zip_version["__version__"])
|
||||
try:
|
||||
version_check = OpenPypeVersion(
|
||||
version=zip_version["__version__"])
|
||||
except ValueError as e:
|
||||
self._print(str(e), True)
|
||||
return False
|
||||
|
||||
version_main = version_check.get_main_version() # noqa: E501
|
||||
detected_main = detected_version.get_main_version() # noqa: E501
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ def get_track_items(
|
|||
if not item.isEnabled():
|
||||
continue
|
||||
if track_item_name:
|
||||
if item.name() in track_item_name:
|
||||
if track_item_name in item.name():
|
||||
return item
|
||||
# make sure only track items with correct track names are added
|
||||
if track_name and track_name in track.name():
|
||||
|
|
@ -949,6 +949,54 @@ def sync_clip_name_to_data_asset(track_items_list):
|
|||
print("asset was changed in clip: {}".format(ti_name))
|
||||
|
||||
|
||||
def check_inventory_versions():
|
||||
"""
|
||||
Actual version color idetifier of Loaded containers
|
||||
|
||||
Check all track items and filter only
|
||||
Loader nodes for its version. It will get all versions from database
|
||||
and check if the node is having actual version. If not then it will color
|
||||
it to red.
|
||||
"""
|
||||
from . import parse_container
|
||||
from avalon import io
|
||||
|
||||
# presets
|
||||
clip_color_last = "green"
|
||||
clip_color = "red"
|
||||
|
||||
# get all track items from current timeline
|
||||
for track_item in get_track_items():
|
||||
container = parse_container(track_item)
|
||||
|
||||
if container:
|
||||
# get representation from io
|
||||
representation = io.find_one({
|
||||
"type": "representation",
|
||||
"_id": io.ObjectId(container["representation"])
|
||||
})
|
||||
|
||||
# Get start frame from version data
|
||||
version = io.find_one({
|
||||
"type": "version",
|
||||
"_id": representation["parent"]
|
||||
})
|
||||
|
||||
# get all versions in list
|
||||
versions = io.find({
|
||||
"type": "version",
|
||||
"parent": version["parent"]
|
||||
}).distinct('name')
|
||||
|
||||
max_version = max(versions)
|
||||
|
||||
# set clip colour
|
||||
if version.get("name") == max_version:
|
||||
track_item.source().binItem().setColor(clip_color_last)
|
||||
else:
|
||||
track_item.source().binItem().setColor(clip_color)
|
||||
|
||||
|
||||
def selection_changed_timeline(event):
|
||||
"""Callback on timeline to check if asset in data is the same as clip name.
|
||||
|
||||
|
|
@ -958,9 +1006,15 @@ def selection_changed_timeline(event):
|
|||
timeline_editor = event.sender
|
||||
selection = timeline_editor.selection()
|
||||
|
||||
selection = [ti for ti in selection
|
||||
if isinstance(ti, hiero.core.TrackItem)]
|
||||
|
||||
# run checking function
|
||||
sync_clip_name_to_data_asset(selection)
|
||||
|
||||
# also mark old versions of loaded containers
|
||||
check_inventory_versions()
|
||||
|
||||
|
||||
def before_project_save(event):
|
||||
track_items = get_track_items(
|
||||
|
|
@ -972,3 +1026,6 @@ def before_project_save(event):
|
|||
|
||||
# run checking function
|
||||
sync_clip_name_to_data_asset(track_items)
|
||||
|
||||
# also mark old versions of loaded containers
|
||||
check_inventory_versions()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from avalon.api import CreatorError
|
||||
from avalon.tvpaint import (
|
||||
pipeline,
|
||||
lib,
|
||||
CommunicationWrapper
|
||||
)
|
||||
from openpype.hosts.tvpaint.api import plugin
|
||||
from openpype.lib import prepare_template_data
|
||||
|
||||
|
||||
class CreateRenderlayer(plugin.Creator):
|
||||
|
|
@ -15,13 +17,31 @@ class CreateRenderlayer(plugin.Creator):
|
|||
defaults = ["Main"]
|
||||
|
||||
rename_group = True
|
||||
render_pass = "beauty"
|
||||
|
||||
subset_template = "{family}_{name}"
|
||||
rename_script_template = (
|
||||
"tv_layercolor \"setcolor\""
|
||||
" {clip_id} {group_id} {r} {g} {b} \"{name}\""
|
||||
)
|
||||
|
||||
dynamic_subset_keys = ["render_pass", "render_layer", "group"]
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_data(
|
||||
cls, variant, task_name, asset_id, project_name, host_name
|
||||
):
|
||||
dynamic_data = super(CreateRenderlayer, cls).get_dynamic_data(
|
||||
variant, task_name, asset_id, project_name, host_name
|
||||
)
|
||||
# Use render pass name from creator's plugin
|
||||
dynamic_data["render_pass"] = cls.render_pass
|
||||
# Add variant to render layer
|
||||
dynamic_data["render_layer"] = variant
|
||||
# Change family for subset name fill
|
||||
dynamic_data["family"] = "render"
|
||||
|
||||
return dynamic_data
|
||||
|
||||
@classmethod
|
||||
def get_default_variant(cls):
|
||||
"""Default value for variant in Creator tool.
|
||||
|
|
@ -70,34 +90,44 @@ class CreateRenderlayer(plugin.Creator):
|
|||
|
||||
# Raise if there is no selection
|
||||
if not group_ids:
|
||||
raise AssertionError("Nothing is selected.")
|
||||
raise CreatorError("Nothing is selected.")
|
||||
|
||||
# This creator should run only on one group
|
||||
if len(group_ids) > 1:
|
||||
raise AssertionError("More than one group is in selection.")
|
||||
raise CreatorError("More than one group is in selection.")
|
||||
|
||||
group_id = tuple(group_ids)[0]
|
||||
# If group id is `0` it is `default` group which is invalid
|
||||
if group_id == 0:
|
||||
raise AssertionError(
|
||||
raise CreatorError(
|
||||
"Selection is not in group. Can't mark selection as Beauty."
|
||||
)
|
||||
|
||||
self.log.debug(f"Selected group id is \"{group_id}\".")
|
||||
self.data["group_id"] = group_id
|
||||
|
||||
family = self.data["family"]
|
||||
# Extract entered name
|
||||
name = self.data["subset"][len(family):]
|
||||
self.log.info(f"Extracted name from subset name \"{name}\".")
|
||||
self.data["name"] = name
|
||||
group_data = lib.groups_data()
|
||||
group_name = None
|
||||
for group in group_data:
|
||||
if group["group_id"] == group_id:
|
||||
group_name = group["name"]
|
||||
break
|
||||
|
||||
# Change subset name by template
|
||||
subset_name = self.subset_template.format(**{
|
||||
"family": self.family,
|
||||
"name": name
|
||||
})
|
||||
self.log.info(f"New subset name \"{subset_name}\".")
|
||||
if group_name is None:
|
||||
raise AssertionError(
|
||||
"Couldn't find group by id \"{}\"".format(group_id)
|
||||
)
|
||||
|
||||
subset_name_fill_data = {
|
||||
"group": group_name
|
||||
}
|
||||
|
||||
family = self.family = self.data["family"]
|
||||
|
||||
# Fill dynamic key 'group'
|
||||
subset_name = self.data["subset"].format(
|
||||
**prepare_template_data(subset_name_fill_data)
|
||||
)
|
||||
self.data["subset"] = subset_name
|
||||
|
||||
# Check for instances of same group
|
||||
|
|
@ -153,7 +183,7 @@ class CreateRenderlayer(plugin.Creator):
|
|||
|
||||
# Rename TVPaint group (keep color same)
|
||||
# - groups can't contain spaces
|
||||
new_group_name = name.replace(" ", "_")
|
||||
new_group_name = self.data["variant"].replace(" ", "_")
|
||||
rename_script = self.rename_script_template.format(
|
||||
clip_id=selected_group["clip_id"],
|
||||
group_id=selected_group["group_id"],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from avalon.api import CreatorError
|
||||
from avalon.tvpaint import (
|
||||
pipeline,
|
||||
lib,
|
||||
CommunicationWrapper
|
||||
)
|
||||
from openpype.hosts.tvpaint.api import plugin
|
||||
from openpype.lib import prepare_template_data
|
||||
|
||||
|
||||
class CreateRenderPass(plugin.Creator):
|
||||
|
|
@ -18,7 +20,19 @@ class CreateRenderPass(plugin.Creator):
|
|||
icon = "cube"
|
||||
defaults = ["Main"]
|
||||
|
||||
subset_template = "{family}_{render_layer}_{pass}"
|
||||
dynamic_subset_keys = ["render_pass", "render_layer"]
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_data(
|
||||
cls, variant, task_name, asset_id, project_name, host_name
|
||||
):
|
||||
dynamic_data = super(CreateRenderPass, cls).get_dynamic_data(
|
||||
variant, task_name, asset_id, project_name, host_name
|
||||
)
|
||||
dynamic_data["render_pass"] = variant
|
||||
dynamic_data["family"] = "render"
|
||||
|
||||
return dynamic_data
|
||||
|
||||
@classmethod
|
||||
def get_default_variant(cls):
|
||||
|
|
@ -66,11 +80,11 @@ class CreateRenderPass(plugin.Creator):
|
|||
|
||||
# Raise if nothing is selected
|
||||
if not selected_layers:
|
||||
raise AssertionError("Nothing is selected.")
|
||||
raise CreatorError("Nothing is selected.")
|
||||
|
||||
# Raise if layers from multiple groups are selected
|
||||
if len(group_ids) != 1:
|
||||
raise AssertionError("More than one group is in selection.")
|
||||
raise CreatorError("More than one group is in selection.")
|
||||
|
||||
group_id = tuple(group_ids)[0]
|
||||
self.log.debug(f"Selected group id is \"{group_id}\".")
|
||||
|
|
@ -87,34 +101,40 @@ class CreateRenderPass(plugin.Creator):
|
|||
|
||||
# Beauty is required for this creator so raise if was not found
|
||||
if beauty_instance is None:
|
||||
raise AssertionError("Beauty pass does not exist yet.")
|
||||
raise CreatorError("Beauty pass does not exist yet.")
|
||||
|
||||
render_layer = beauty_instance["name"]
|
||||
subset_name = self.data["subset"]
|
||||
|
||||
subset_name_fill_data = {}
|
||||
|
||||
# Backwards compatibility
|
||||
# - beauty may be created with older creator where variant was not
|
||||
# stored
|
||||
if "variant" not in beauty_instance:
|
||||
render_layer = beauty_instance["name"]
|
||||
else:
|
||||
render_layer = beauty_instance["variant"]
|
||||
|
||||
subset_name_fill_data["render_layer"] = render_layer
|
||||
|
||||
# Format dynamic keys in subset name
|
||||
new_subset_name = subset_name.format(
|
||||
**prepare_template_data(subset_name_fill_data)
|
||||
)
|
||||
self.data["subset"] = new_subset_name
|
||||
self.log.info(f"New subset name is \"{new_subset_name}\".")
|
||||
|
||||
# Extract entered name
|
||||
family = self.data["family"]
|
||||
name = self.data["subset"]
|
||||
# Is this right way how to get name?
|
||||
name = name[len(family):]
|
||||
self.log.info(f"Extracted name from subset name \"{name}\".")
|
||||
variant = self.data["variant"]
|
||||
|
||||
self.data["group_id"] = group_id
|
||||
self.data["pass"] = name
|
||||
self.data["pass"] = variant
|
||||
self.data["render_layer"] = render_layer
|
||||
|
||||
# Collect selected layer ids to be stored into instance
|
||||
layer_names = [layer["name"] for layer in selected_layers]
|
||||
self.data["layer_names"] = layer_names
|
||||
|
||||
# Replace `beauty` in beauty's subset name with entered name
|
||||
subset_name = self.subset_template.format(**{
|
||||
"family": family,
|
||||
"render_layer": render_layer,
|
||||
"pass": name
|
||||
})
|
||||
self.data["subset"] = subset_name
|
||||
self.log.info(f"New subset name is \"{subset_name}\".")
|
||||
|
||||
# Check if same instance already exists
|
||||
existing_instance = None
|
||||
existing_instance_idx = None
|
||||
|
|
@ -122,7 +142,7 @@ class CreateRenderPass(plugin.Creator):
|
|||
if (
|
||||
instance["family"] == family
|
||||
and instance["group_id"] == group_id
|
||||
and instance["pass"] == name
|
||||
and instance["pass"] == variant
|
||||
):
|
||||
existing_instance = instance
|
||||
existing_instance_idx = idx
|
||||
|
|
@ -131,7 +151,7 @@ class CreateRenderPass(plugin.Creator):
|
|||
if existing_instance is not None:
|
||||
self.log.info(
|
||||
f"Render pass instance for group id {group_id}"
|
||||
f" and name \"{name}\" already exists, overriding."
|
||||
f" and name \"{variant}\" already exists, overriding."
|
||||
)
|
||||
instances[existing_instance_idx] = self.data
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import copy
|
|||
import pyblish.api
|
||||
from avalon import io
|
||||
|
||||
from openpype.lib import get_subset_name
|
||||
|
||||
|
||||
class CollectInstances(pyblish.api.ContextPlugin):
|
||||
label = "Collect Instances"
|
||||
|
|
@ -62,9 +64,38 @@ class CollectInstances(pyblish.api.ContextPlugin):
|
|||
# Different instance creation based on family
|
||||
instance = None
|
||||
if family == "review":
|
||||
# Change subset name
|
||||
# Change subset name of review instance
|
||||
|
||||
# Collect asset doc to get asset id
|
||||
# - not sure if it's good idea to require asset id in
|
||||
# get_subset_name?
|
||||
asset_name = context.data["workfile_context"]["asset"]
|
||||
asset_doc = io.find_one(
|
||||
{
|
||||
"type": "asset",
|
||||
"name": asset_name
|
||||
},
|
||||
{"_id": 1}
|
||||
)
|
||||
asset_id = None
|
||||
if asset_doc:
|
||||
asset_id = asset_doc["_id"]
|
||||
|
||||
# Project name from workfile context
|
||||
project_name = context.data["workfile_context"]["project"]
|
||||
# Host name from environemnt variable
|
||||
host_name = os.environ["AVALON_APP"]
|
||||
# Use empty variant value
|
||||
variant = ""
|
||||
task_name = io.Session["AVALON_TASK"]
|
||||
new_subset_name = "{}{}".format(family, task_name.capitalize())
|
||||
new_subset_name = get_subset_name(
|
||||
family,
|
||||
variant,
|
||||
task_name,
|
||||
asset_id,
|
||||
project_name,
|
||||
host_name
|
||||
)
|
||||
instance_data["subset"] = new_subset_name
|
||||
|
||||
instance = context.create_instance(**instance_data)
|
||||
|
|
@ -119,19 +150,23 @@ class CollectInstances(pyblish.api.ContextPlugin):
|
|||
name = instance_data["name"]
|
||||
# Change label
|
||||
subset_name = instance_data["subset"]
|
||||
instance_data["label"] = "{}_Beauty".format(name)
|
||||
|
||||
# Change subset name
|
||||
# Final family of an instance will be `render`
|
||||
new_family = "render"
|
||||
task_name = io.Session["AVALON_TASK"]
|
||||
new_subset_name = "{}{}_{}_Beauty".format(
|
||||
new_family, task_name.capitalize(), name
|
||||
)
|
||||
instance_data["subset"] = new_subset_name
|
||||
self.log.debug("Changed subset name \"{}\"->\"{}\"".format(
|
||||
subset_name, new_subset_name
|
||||
))
|
||||
# Backwards compatibility
|
||||
# - subset names were not stored as final subset names during creation
|
||||
if "variant" not in instance_data:
|
||||
instance_data["label"] = "{}_Beauty".format(name)
|
||||
|
||||
# Change subset name
|
||||
# Final family of an instance will be `render`
|
||||
new_family = "render"
|
||||
task_name = io.Session["AVALON_TASK"]
|
||||
new_subset_name = "{}{}_{}_Beauty".format(
|
||||
new_family, task_name.capitalize(), name
|
||||
)
|
||||
instance_data["subset"] = new_subset_name
|
||||
self.log.debug("Changed subset name \"{}\"->\"{}\"".format(
|
||||
subset_name, new_subset_name
|
||||
))
|
||||
|
||||
# Get all layers for the layer
|
||||
layers_data = context.data["layersData"]
|
||||
|
|
@ -163,20 +198,23 @@ class CollectInstances(pyblish.api.ContextPlugin):
|
|||
)
|
||||
# Change label
|
||||
render_layer = instance_data["render_layer"]
|
||||
instance_data["label"] = "{}_{}".format(render_layer, pass_name)
|
||||
|
||||
# Change subset name
|
||||
# Final family of an instance will be `render`
|
||||
new_family = "render"
|
||||
old_subset_name = instance_data["subset"]
|
||||
task_name = io.Session["AVALON_TASK"]
|
||||
new_subset_name = "{}{}_{}_{}".format(
|
||||
new_family, task_name.capitalize(), render_layer, pass_name
|
||||
)
|
||||
instance_data["subset"] = new_subset_name
|
||||
self.log.debug("Changed subset name \"{}\"->\"{}\"".format(
|
||||
old_subset_name, new_subset_name
|
||||
))
|
||||
# Backwards compatibility
|
||||
# - subset names were not stored as final subset names during creation
|
||||
if "variant" not in instance_data:
|
||||
instance_data["label"] = "{}_{}".format(render_layer, pass_name)
|
||||
# Change subset name
|
||||
# Final family of an instance will be `render`
|
||||
new_family = "render"
|
||||
old_subset_name = instance_data["subset"]
|
||||
task_name = io.Session["AVALON_TASK"]
|
||||
new_subset_name = "{}{}_{}_{}".format(
|
||||
new_family, task_name.capitalize(), render_layer, pass_name
|
||||
)
|
||||
instance_data["subset"] = new_subset_name
|
||||
self.log.debug("Changed subset name \"{}\"->\"{}\"".format(
|
||||
old_subset_name, new_subset_name
|
||||
))
|
||||
|
||||
layers_data = context.data["layersData"]
|
||||
layers_by_name = {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import json
|
|||
import pyblish.api
|
||||
from avalon import io
|
||||
|
||||
from openpype.lib import get_subset_name
|
||||
|
||||
|
||||
class CollectWorkfile(pyblish.api.ContextPlugin):
|
||||
label = "Collect Workfile"
|
||||
|
|
@ -20,8 +22,38 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
|
|||
basename, ext = os.path.splitext(filename)
|
||||
instance = context.create_instance(name=basename)
|
||||
|
||||
# Get subset name of workfile instance
|
||||
# Collect asset doc to get asset id
|
||||
# - not sure if it's good idea to require asset id in
|
||||
# get_subset_name?
|
||||
family = "workfile"
|
||||
asset_name = context.data["workfile_context"]["asset"]
|
||||
asset_doc = io.find_one(
|
||||
{
|
||||
"type": "asset",
|
||||
"name": asset_name
|
||||
},
|
||||
{"_id": 1}
|
||||
)
|
||||
asset_id = None
|
||||
if asset_doc:
|
||||
asset_id = asset_doc["_id"]
|
||||
|
||||
# Project name from workfile context
|
||||
project_name = context.data["workfile_context"]["project"]
|
||||
# Host name from environemnt variable
|
||||
host_name = os.environ["AVALON_APP"]
|
||||
# Use empty variant value
|
||||
variant = ""
|
||||
task_name = io.Session["AVALON_TASK"]
|
||||
subset_name = "workfile" + task_name.capitalize()
|
||||
subset_name = get_subset_name(
|
||||
family,
|
||||
variant,
|
||||
task_name,
|
||||
asset_id,
|
||||
project_name,
|
||||
host_name
|
||||
)
|
||||
|
||||
# Create Workfile instance
|
||||
instance.data.update({
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ def get_subset_name(
|
|||
asset_id,
|
||||
project_name=None,
|
||||
host_name=None,
|
||||
default_template=None
|
||||
default_template=None,
|
||||
dynamic_data=None
|
||||
):
|
||||
if not family:
|
||||
return ""
|
||||
|
|
@ -68,11 +69,16 @@ def get_subset_name(
|
|||
if not task_name and "{task" in template.lower():
|
||||
raise TaskNotSetError()
|
||||
|
||||
fill_pairs = (
|
||||
("variant", variant),
|
||||
("family", family),
|
||||
("task", task_name)
|
||||
)
|
||||
fill_pairs = {
|
||||
"variant": variant,
|
||||
"family": family,
|
||||
"task": task_name
|
||||
}
|
||||
if dynamic_data:
|
||||
# Dynamic data may override default values
|
||||
for key, value in dynamic_data.items():
|
||||
fill_pairs[key] = value
|
||||
|
||||
return template.format(**prepare_template_data(fill_pairs))
|
||||
|
||||
|
||||
|
|
@ -91,7 +97,8 @@ def prepare_template_data(fill_pairs):
|
|||
|
||||
"""
|
||||
fill_data = {}
|
||||
for key, value in fill_pairs:
|
||||
regex = re.compile(r"[a-zA-Z0-9]")
|
||||
for key, value in dict(fill_pairs).items():
|
||||
# Handle cases when value is `None` (standalone publisher)
|
||||
if value is None:
|
||||
continue
|
||||
|
|
@ -102,13 +109,18 @@ def prepare_template_data(fill_pairs):
|
|||
|
||||
# Capitalize only first char of value
|
||||
# - conditions are because of possible index errors
|
||||
# - regex is to skip symbols that are not chars or numbers
|
||||
# - e.g. "{key}" which starts with curly bracket
|
||||
capitalized = ""
|
||||
if value:
|
||||
# Upper first character
|
||||
capitalized += value[0].upper()
|
||||
# Append rest of string if there is any
|
||||
if len(value) > 1:
|
||||
capitalized += value[1:]
|
||||
for idx in range(len(value or "")):
|
||||
char = value[idx]
|
||||
if not regex.match(char):
|
||||
capitalized += char
|
||||
else:
|
||||
capitalized += char.upper()
|
||||
capitalized += value[idx + 1:]
|
||||
break
|
||||
|
||||
fill_data[key.capitalize()] = capitalized
|
||||
|
||||
return fill_data
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ClockifyAPI:
|
|||
|
||||
self._secure_registry = None
|
||||
|
||||
@property
|
||||
def secure_registry(self):
|
||||
if self._secure_registry is None:
|
||||
self._secure_registry = OpenPypeSecureRegistry("clockify")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from Qt import QtCore, QtGui, QtWidgets
|
||||
from avalon import style
|
||||
from openpype import resources
|
||||
from openpype import resources, style
|
||||
|
||||
|
||||
class MessageWidget(QtWidgets.QWidget):
|
||||
|
|
@ -22,14 +21,6 @@ class MessageWidget(QtWidgets.QWidget):
|
|||
QtCore.Qt.WindowMinimizeButtonHint
|
||||
)
|
||||
|
||||
# Font
|
||||
self.font = QtGui.QFont()
|
||||
self.font.setFamily("DejaVu Sans Condensed")
|
||||
self.font.setPointSize(9)
|
||||
self.font.setBold(True)
|
||||
self.font.setWeight(50)
|
||||
self.font.setKerning(True)
|
||||
|
||||
# Size setting
|
||||
self.resize(self.SIZE_W, self.SIZE_H)
|
||||
self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H))
|
||||
|
|
@ -53,7 +44,6 @@ class MessageWidget(QtWidgets.QWidget):
|
|||
labels = []
|
||||
for message in messages:
|
||||
label = QtWidgets.QLabel(message)
|
||||
label.setFont(self.font)
|
||||
label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
|
||||
label.setTextFormat(QtCore.Qt.RichText)
|
||||
label.setWordWrap(True)
|
||||
|
|
@ -103,84 +93,64 @@ class ClockifySettings(QtWidgets.QWidget):
|
|||
icon = QtGui.QIcon(resources.pype_icon_filepath())
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
self.setWindowTitle("Clockify settings")
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowCloseButtonHint |
|
||||
QtCore.Qt.WindowMinimizeButtonHint
|
||||
)
|
||||
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
# Font
|
||||
self.font = QtGui.QFont()
|
||||
self.font.setFamily("DejaVu Sans Condensed")
|
||||
self.font.setPointSize(9)
|
||||
self.font.setBold(True)
|
||||
self.font.setWeight(50)
|
||||
self.font.setKerning(True)
|
||||
|
||||
# Size setting
|
||||
self.resize(self.SIZE_W, self.SIZE_H)
|
||||
self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H))
|
||||
self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100))
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
self.setLayout(self._main())
|
||||
self.setWindowTitle('Clockify settings')
|
||||
self._ui_init()
|
||||
|
||||
def _main(self):
|
||||
self.main = QtWidgets.QVBoxLayout()
|
||||
self.main.setObjectName("main")
|
||||
def _ui_init(self):
|
||||
label_api_key = QtWidgets.QLabel("Clockify API key:")
|
||||
|
||||
self.form = QtWidgets.QFormLayout()
|
||||
self.form.setContentsMargins(10, 15, 10, 5)
|
||||
self.form.setObjectName("form")
|
||||
input_api_key = QtWidgets.QLineEdit()
|
||||
input_api_key.setFrame(True)
|
||||
input_api_key.setPlaceholderText("e.g. XX1XxXX2x3x4xXxx")
|
||||
|
||||
self.label_api_key = QtWidgets.QLabel("Clockify API key:")
|
||||
self.label_api_key.setFont(self.font)
|
||||
self.label_api_key.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
|
||||
self.label_api_key.setTextFormat(QtCore.Qt.RichText)
|
||||
self.label_api_key.setObjectName("label_api_key")
|
||||
error_label = QtWidgets.QLabel("")
|
||||
error_label.setTextFormat(QtCore.Qt.RichText)
|
||||
error_label.setWordWrap(True)
|
||||
error_label.hide()
|
||||
|
||||
self.input_api_key = QtWidgets.QLineEdit()
|
||||
self.input_api_key.setEnabled(True)
|
||||
self.input_api_key.setFrame(True)
|
||||
self.input_api_key.setObjectName("input_api_key")
|
||||
self.input_api_key.setPlaceholderText(
|
||||
self._translate("main", "e.g. XX1XxXX2x3x4xXxx")
|
||||
)
|
||||
form_layout = QtWidgets.QFormLayout()
|
||||
form_layout.setContentsMargins(10, 15, 10, 5)
|
||||
form_layout.addRow(label_api_key, input_api_key)
|
||||
form_layout.addRow(error_label)
|
||||
|
||||
self.error_label = QtWidgets.QLabel("")
|
||||
self.error_label.setFont(self.font)
|
||||
self.error_label.setTextFormat(QtCore.Qt.RichText)
|
||||
self.error_label.setObjectName("error_label")
|
||||
self.error_label.setWordWrap(True)
|
||||
self.error_label.hide()
|
||||
btn_ok = QtWidgets.QPushButton("Ok")
|
||||
btn_ok.setToolTip('Sets Clockify API Key so can Start/Stop timer')
|
||||
|
||||
self.form.addRow(self.label_api_key, self.input_api_key)
|
||||
self.form.addRow(self.error_label)
|
||||
|
||||
self.btn_group = QtWidgets.QHBoxLayout()
|
||||
self.btn_group.addStretch(1)
|
||||
self.btn_group.setObjectName("btn_group")
|
||||
|
||||
self.btn_ok = QtWidgets.QPushButton("Ok")
|
||||
self.btn_ok.setToolTip('Sets Clockify API Key so can Start/Stop timer')
|
||||
self.btn_ok.clicked.connect(self.click_ok)
|
||||
|
||||
self.btn_cancel = QtWidgets.QPushButton("Cancel")
|
||||
btn_cancel = QtWidgets.QPushButton("Cancel")
|
||||
cancel_tooltip = 'Application won\'t start'
|
||||
if self.optional:
|
||||
cancel_tooltip = 'Close this window'
|
||||
self.btn_cancel.setToolTip(cancel_tooltip)
|
||||
self.btn_cancel.clicked.connect(self._close_widget)
|
||||
btn_cancel.setToolTip(cancel_tooltip)
|
||||
|
||||
self.btn_group.addWidget(self.btn_ok)
|
||||
self.btn_group.addWidget(self.btn_cancel)
|
||||
btn_group = QtWidgets.QHBoxLayout()
|
||||
btn_group.addStretch(1)
|
||||
btn_group.addWidget(btn_ok)
|
||||
btn_group.addWidget(btn_cancel)
|
||||
|
||||
self.main.addLayout(self.form)
|
||||
self.main.addLayout(self.btn_group)
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.addLayout(form_layout)
|
||||
main_layout.addLayout(btn_group)
|
||||
|
||||
return self.main
|
||||
btn_ok.clicked.connect(self.click_ok)
|
||||
btn_cancel.clicked.connect(self._close_widget)
|
||||
|
||||
self.label_api_key = label_api_key
|
||||
self.input_api_key = input_api_key
|
||||
self.error_label = error_label
|
||||
|
||||
self.btn_ok = btn_ok
|
||||
self.btn_cancel = btn_cancel
|
||||
|
||||
def setError(self, msg):
|
||||
self.error_label.setText(msg)
|
||||
|
|
@ -212,6 +182,17 @@ class ClockifySettings(QtWidgets.QWidget):
|
|||
"Entered invalid API key"
|
||||
)
|
||||
|
||||
def showEvent(self, event):
|
||||
super(ClockifySettings, self).showEvent(event)
|
||||
|
||||
# Make btns same width
|
||||
max_width = max(
|
||||
self.btn_ok.sizeHint().width(),
|
||||
self.btn_cancel.sizeHint().width()
|
||||
)
|
||||
self.btn_ok.setMinimumWidth(max_width)
|
||||
self.btn_cancel.setMinimumWidth(max_width)
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.optional is True:
|
||||
event.ignore()
|
||||
|
|
|
|||
|
|
@ -66,7 +66,16 @@ class SocketThread(threading.Thread):
|
|||
*self.additional_args,
|
||||
str(self.port)
|
||||
)
|
||||
self.subproc = subprocess.Popen(args, env=env, stdin=subprocess.PIPE)
|
||||
kwargs = {
|
||||
"env": env,
|
||||
"stdin": subprocess.PIPE
|
||||
}
|
||||
if not sys.stdout:
|
||||
# Redirect to devnull if stdout is None
|
||||
kwargs["stdout"] = subprocess.DEVNULL
|
||||
kwargs["stderr"] = subprocess.DEVNULL
|
||||
|
||||
self.subproc = subprocess.Popen(args, **kwargs)
|
||||
|
||||
# Listen for incoming connections
|
||||
sock.listen(1)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import os
|
||||
import requests
|
||||
from avalon import style
|
||||
from openpype import style
|
||||
from openpype.modules.ftrack.lib import credentials
|
||||
from . import login_tools
|
||||
from openpype import resources
|
||||
|
|
@ -46,8 +46,11 @@ class CredentialsDialog(QtWidgets.QDialog):
|
|||
self.user_label = QtWidgets.QLabel("Username:")
|
||||
self.api_label = QtWidgets.QLabel("API Key:")
|
||||
|
||||
self.ftsite_input = QtWidgets.QLineEdit()
|
||||
self.ftsite_input.setReadOnly(True)
|
||||
self.ftsite_input = QtWidgets.QLabel()
|
||||
self.ftsite_input.setTextInteractionFlags(
|
||||
QtCore.Qt.TextBrowserInteraction
|
||||
)
|
||||
# self.ftsite_input.setReadOnly(True)
|
||||
self.ftsite_input.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
|
||||
|
||||
self.user_input = QtWidgets.QLineEdit()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import os
|
||||
from Qt import QtCore, QtGui, QtWidgets
|
||||
from avalon import style
|
||||
from openpype import resources
|
||||
from openpype import resources, style
|
||||
|
||||
|
||||
class MusterLogin(QtWidgets.QWidget):
|
||||
|
||||
SIZE_W = 300
|
||||
SIZE_H = 130
|
||||
SIZE_H = 150
|
||||
|
||||
loginSignal = QtCore.Signal(object, object, object)
|
||||
|
||||
|
|
@ -123,7 +122,6 @@ class MusterLogin(QtWidgets.QWidget):
|
|||
super().keyPressEvent(key_event)
|
||||
|
||||
def setError(self, msg):
|
||||
|
||||
self.error_label.setText(msg)
|
||||
self.error_label.show()
|
||||
|
||||
|
|
@ -149,6 +147,17 @@ class MusterLogin(QtWidgets.QWidget):
|
|||
def save_credentials(self, username, password):
|
||||
self.module.get_auth_token(username, password)
|
||||
|
||||
def showEvent(self, event):
|
||||
super(MusterLogin, self).showEvent(event)
|
||||
|
||||
# Make btns same width
|
||||
max_width = max(
|
||||
self.btn_ok.sizeHint().width(),
|
||||
self.btn_cancel.sizeHint().width()
|
||||
)
|
||||
self.btn_ok.setMinimumWidth(max_width)
|
||||
self.btn_cancel.setMinimumWidth(max_width)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
self._close_widget()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from avalon import style
|
||||
from Qt import QtCore, QtGui, QtWidgets
|
||||
from openpype import resources
|
||||
from openpype import resources, style
|
||||
|
||||
|
||||
class WidgetUserIdle(QtWidgets.QWidget):
|
||||
|
|
|
|||
|
|
@ -16,13 +16,59 @@ class PypeCreatorMixin:
|
|||
|
||||
Mixin class must be used as first in inheritance order to override methods.
|
||||
"""
|
||||
dynamic_subset_keys = []
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_data(
|
||||
cls, variant, task_name, asset_id, project_name, host_name
|
||||
):
|
||||
"""Return dynamic data for current Creator plugin.
|
||||
|
||||
By default return keys from `dynamic_subset_keys` attribute as mapping
|
||||
to keep formatted template unchanged.
|
||||
|
||||
```
|
||||
dynamic_subset_keys = ["my_key"]
|
||||
---
|
||||
output = {
|
||||
"my_key": "{my_key}"
|
||||
}
|
||||
```
|
||||
|
||||
Dynamic keys may override default Creator keys (family, task, asset,
|
||||
...) but do it wisely if you need.
|
||||
|
||||
All of keys will be converted into 3 variants unchanged, capitalized
|
||||
and all upper letters. Because of that are all keys lowered.
|
||||
|
||||
This method can be modified to prefill some values just keep in mind it
|
||||
is class method.
|
||||
|
||||
Returns:
|
||||
dict: Fill data for subset name template.
|
||||
"""
|
||||
dynamic_data = {}
|
||||
for key in cls.dynamic_subset_keys:
|
||||
key = key.lower()
|
||||
dynamic_data[key] = "{" + key + "}"
|
||||
return dynamic_data
|
||||
|
||||
@classmethod
|
||||
def get_subset_name(
|
||||
cls, variant, task_name, asset_id, project_name, host_name=None
|
||||
):
|
||||
dynamic_data = cls.get_dynamic_data(
|
||||
variant, task_name, asset_id, project_name, host_name
|
||||
)
|
||||
|
||||
return get_subset_name(
|
||||
cls.family, variant, task_name, asset_id, project_name, host_name
|
||||
cls.family,
|
||||
variant,
|
||||
task_name,
|
||||
asset_id,
|
||||
project_name,
|
||||
host_name,
|
||||
dynamic_data=dynamic_data
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import pyblish.api
|
||||
|
||||
|
||||
class IntegrateFtrackComponentOverwrite(pyblish.api.InstancePlugin):
|
||||
"""
|
||||
Set `component_overwrite` to True on all instances `ftrackComponentsList`
|
||||
"""
|
||||
|
||||
order = pyblish.api.IntegratorOrder + 0.49
|
||||
label = 'Overwrite ftrack created versions'
|
||||
families = ["clip"]
|
||||
optional = True
|
||||
active = False
|
||||
|
||||
def process(self, instance):
|
||||
component_list = instance.data['ftrackComponentsList']
|
||||
|
||||
for cl in component_list:
|
||||
cl['component_overwrite'] = True
|
||||
self.log.debug('Component {} overwriting'.format(
|
||||
cl['component_data']['name']))
|
||||
112
openpype/plugins/publish/validate_editorial_asset_name.py
Normal file
112
openpype/plugins/publish/validate_editorial_asset_name.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import pyblish.api
|
||||
from avalon import io
|
||||
from pprint import pformat
|
||||
|
||||
|
||||
class ValidateEditorialAssetName(pyblish.api.ContextPlugin):
|
||||
""" Validating if editorial's asset names are not already created in db.
|
||||
|
||||
Checking variations of names with different size of caps or with
|
||||
or without underscores.
|
||||
"""
|
||||
|
||||
order = pyblish.api.ValidatorOrder
|
||||
label = "Validate Asset Name"
|
||||
|
||||
def process(self, context):
|
||||
|
||||
asset_and_parents = self.get_parents(context)
|
||||
|
||||
if not io.Session:
|
||||
io.install()
|
||||
|
||||
db_assets = list(io.find(
|
||||
{"type": "asset"}, {"name": 1, "data.parents": 1}))
|
||||
self.log.debug("__ db_assets: {}".format(db_assets))
|
||||
|
||||
asset_db_docs = {
|
||||
str(e["name"]): e["data"]["parents"] for e in db_assets}
|
||||
|
||||
self.log.debug("__ project_entities: {}".format(
|
||||
pformat(asset_db_docs)))
|
||||
|
||||
assets_missing_name = {}
|
||||
assets_wrong_parent = {}
|
||||
for asset in asset_and_parents.keys():
|
||||
if asset not in asset_db_docs.keys():
|
||||
# add to some nonexistent list for next layer of check
|
||||
assets_missing_name.update({asset: asset_and_parents[asset]})
|
||||
continue
|
||||
|
||||
if asset_and_parents[asset] != asset_db_docs[asset]:
|
||||
# add to some nonexistent list for next layer of check
|
||||
assets_wrong_parent.update({
|
||||
asset: {
|
||||
"required": asset_and_parents[asset],
|
||||
"already_in_db": asset_db_docs[asset]
|
||||
}
|
||||
})
|
||||
continue
|
||||
|
||||
self.log.info("correct asset: {}".format(asset))
|
||||
|
||||
if assets_missing_name:
|
||||
wrong_names = {}
|
||||
self.log.debug(
|
||||
">> assets_missing_name: {}".format(assets_missing_name))
|
||||
for asset in assets_missing_name.keys():
|
||||
_asset = asset.lower().replace("_", "")
|
||||
if _asset in [a.lower().replace("_", "")
|
||||
for a in asset_db_docs.keys()]:
|
||||
wrong_names.update({
|
||||
"required_name": asset,
|
||||
"used_variants_in_db": [
|
||||
a for a in asset_db_docs.keys()
|
||||
if a.lower().replace("_", "") == _asset
|
||||
]
|
||||
})
|
||||
|
||||
if wrong_names:
|
||||
self.log.debug(
|
||||
">> wrong_names: {}".format(wrong_names))
|
||||
raise Exception(
|
||||
"Some already existing asset name variants `{}`".format(
|
||||
wrong_names))
|
||||
|
||||
if assets_wrong_parent:
|
||||
self.log.debug(
|
||||
">> assets_wrong_parent: {}".format(assets_wrong_parent))
|
||||
raise Exception(
|
||||
"Wrong parents on assets `{}`".format(assets_wrong_parent))
|
||||
|
||||
def _get_all_assets(self, input_dict):
|
||||
""" Returns asset names in list.
|
||||
|
||||
List contains all asset names including parents
|
||||
"""
|
||||
for key in input_dict.keys():
|
||||
# check if child key is available
|
||||
if input_dict[key].get("childs"):
|
||||
# loop deeper
|
||||
self._get_all_assets(
|
||||
input_dict[key]["childs"])
|
||||
else:
|
||||
self.all_testing_assets.append(key)
|
||||
|
||||
def get_parents(self, context):
|
||||
return_dict = {}
|
||||
for instance in context:
|
||||
asset = instance.data["asset"]
|
||||
families = instance.data.get("families", []) + [
|
||||
instance.data["family"]
|
||||
]
|
||||
# filter out non-shot families
|
||||
if "shot" not in families:
|
||||
continue
|
||||
|
||||
parents = instance.data["parents"]
|
||||
|
||||
return_dict.update({
|
||||
asset: [p["entity_name"] for p in parents]
|
||||
})
|
||||
return return_dict
|
||||
|
|
@ -215,6 +215,17 @@
|
|||
"hosts": [],
|
||||
"tasks": [],
|
||||
"template": "{family}{Task}{Variant}"
|
||||
},
|
||||
{
|
||||
"families": [
|
||||
"renderLayer",
|
||||
"renderPass"
|
||||
],
|
||||
"hosts": [
|
||||
"tvpaint"
|
||||
],
|
||||
"tasks": [],
|
||||
"template": "{family}{Task}_{Render_layer}_{Render_pass}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@
|
|||
"jpeg",
|
||||
"png",
|
||||
"h264",
|
||||
"mov"
|
||||
"mov",
|
||||
"mp4"
|
||||
],
|
||||
"clip_name_template": "{asset}_{subset}_{representation}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class AppsEnumEntity(BaseEnumEntity):
|
|||
system_settings_entity = self.get_entity_from_path("system_settings")
|
||||
|
||||
valid_keys = set()
|
||||
enum_items = []
|
||||
enum_items_list = []
|
||||
applications_entity = system_settings_entity["applications"]
|
||||
for group_name, app_group in applications_entity.items():
|
||||
enabled_entity = app_group.get("enabled")
|
||||
|
|
@ -149,8 +149,12 @@ class AppsEnumEntity(BaseEnumEntity):
|
|||
full_label = variant_label
|
||||
|
||||
full_name = "/".join((group_name, variant_name))
|
||||
enum_items.append({full_name: full_label})
|
||||
enum_items_list.append((full_name, full_label))
|
||||
valid_keys.add(full_name)
|
||||
|
||||
enum_items = []
|
||||
for key, value in sorted(enum_items_list, key=lambda item: item[1]):
|
||||
enum_items.append({key: value})
|
||||
return enum_items, valid_keys
|
||||
|
||||
def set_override_state(self, *args, **kwargs):
|
||||
|
|
@ -179,7 +183,7 @@ class ToolsEnumEntity(BaseEnumEntity):
|
|||
system_settings_entity = self.get_entity_from_path("system_settings")
|
||||
|
||||
valid_keys = set()
|
||||
enum_items = []
|
||||
enum_items_list = []
|
||||
tool_groups_entity = system_settings_entity["tools"]["tool_groups"]
|
||||
for group_name, tool_group in tool_groups_entity.items():
|
||||
# Try to get group label from entity
|
||||
|
|
@ -204,8 +208,12 @@ class ToolsEnumEntity(BaseEnumEntity):
|
|||
else:
|
||||
tool_label = tool_name
|
||||
|
||||
enum_items.append({tool_name: tool_label})
|
||||
enum_items_list.append((tool_name, tool_label))
|
||||
valid_keys.add(tool_name)
|
||||
|
||||
enum_items = []
|
||||
for key, value in sorted(enum_items_list, key=lambda item: item[1]):
|
||||
enum_items.append({key: value})
|
||||
return enum_items, valid_keys
|
||||
|
||||
def set_override_state(self, *args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,24 @@
|
|||
"""Project Manager tool
|
||||
|
||||
Purpose of the tool is to be able create and modify hierarchy under project
|
||||
ready for OpenPype pipeline usage. Tool also give ability to create new
|
||||
projects.
|
||||
|
||||
# Brief info
|
||||
Project hierarchy consist of two types "asset" and "task". Assets can be
|
||||
children of Project or other Asset. Task can be children of Asset.
|
||||
|
||||
It is not possible to have duplicated Asset name across whole project.
|
||||
It is not possible to have duplicated Task name under one Asset.
|
||||
|
||||
Asset can't be moved or renamed if has or it's children has published content.
|
||||
|
||||
Deleted assets are not deleted from database but their type is changed to
|
||||
"archived_asset".
|
||||
|
||||
Tool allows to modify Asset attributes like frame start/end, fps, etc.
|
||||
"""
|
||||
|
||||
from .project_manager import (
|
||||
ProjectManagerWindow,
|
||||
main
|
||||
|
|
|
|||
|
|
@ -2,12 +2,21 @@ import re
|
|||
from Qt import QtCore
|
||||
|
||||
|
||||
# Item identifier (unique ID - uuid4 is used)
|
||||
IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1
|
||||
# Item has duplicated name (Asset and Task items)
|
||||
DUPLICATED_ROLE = QtCore.Qt.UserRole + 2
|
||||
# It is possible to move and rename items
|
||||
# - that is disabled if e.g. Asset has published content
|
||||
HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3
|
||||
# Item is marked for deletion
|
||||
# - item will be deleted after hitting save
|
||||
REMOVED_ROLE = QtCore.Qt.UserRole + 4
|
||||
# Item type in string
|
||||
ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5
|
||||
# Item has opened editor (per column)
|
||||
EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6
|
||||
|
||||
# Allowed symbols for any name
|
||||
NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_"
|
||||
NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ from .multiselection_combobox import MultiSelectionComboBox
|
|||
|
||||
|
||||
class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Implementation of private method from QStyledItemDelegate.
|
||||
|
||||
Force editor to resize into item size.
|
||||
"""
|
||||
@staticmethod
|
||||
def _q_smart_min_size(editor):
|
||||
min_size_hint = editor.minimumSizeHint()
|
||||
|
|
@ -67,6 +71,16 @@ class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate):
|
|||
|
||||
|
||||
class NumberDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Delegate for number attributes.
|
||||
|
||||
Editor correspond passed arguments.
|
||||
|
||||
Args:
|
||||
minimum(int, float): Minimum possible value.
|
||||
maximum(int, float): Maximum possible value.
|
||||
decimals(int): How many decimal points can be used. Float will be used
|
||||
as value if is higher than 0.
|
||||
"""
|
||||
def __init__(self, minimum, maximum, decimals, *args, **kwargs):
|
||||
super(NumberDelegate, self).__init__(*args, **kwargs)
|
||||
self.minimum = minimum
|
||||
|
|
@ -80,10 +94,13 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate):
|
|||
editor = QtWidgets.QSpinBox(parent)
|
||||
|
||||
editor.setObjectName("NumberEditor")
|
||||
# Set min/max
|
||||
editor.setMinimum(self.minimum)
|
||||
editor.setMaximum(self.maximum)
|
||||
# Hide spinbox buttons
|
||||
editor.setButtonSymbols(QtWidgets.QSpinBox.NoButtons)
|
||||
|
||||
# Try to set value from item
|
||||
value = index.data(QtCore.Qt.EditRole)
|
||||
if value is not None:
|
||||
try:
|
||||
|
|
@ -98,6 +115,8 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate):
|
|||
|
||||
|
||||
class NameDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Specific delegate for "name" key."""
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
editor = NameTextEdit(parent)
|
||||
editor.setObjectName("NameEditor")
|
||||
|
|
@ -108,11 +127,26 @@ class NameDelegate(QtWidgets.QStyledItemDelegate):
|
|||
|
||||
|
||||
class TypeDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Specific delegate for "type" key.
|
||||
|
||||
It is expected that will be used only for TaskItem which has modifiable
|
||||
type. Type values are defined with cached project document.
|
||||
|
||||
Args:
|
||||
project_doc_cache(ProjectDocCache): Project cache shared across all
|
||||
delegates (kind of a struct pointer).
|
||||
"""
|
||||
|
||||
def __init__(self, project_doc_cache, *args, **kwargs):
|
||||
self._project_doc_cache = project_doc_cache
|
||||
super(TypeDelegate, self).__init__(*args, **kwargs)
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
"""Editor is using filtrable combobox.
|
||||
|
||||
Editor should not be possible to create new items or set values that
|
||||
are not in this method.
|
||||
"""
|
||||
editor = FilterComboBox(parent)
|
||||
editor.setObjectName("TypeEditor")
|
||||
editor.style().polish(editor)
|
||||
|
|
@ -136,6 +170,18 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate):
|
|||
|
||||
|
||||
class ToolsDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Specific delegate for "tools_env" key.
|
||||
|
||||
Expected that editor will be used only on AssetItem which is the only item
|
||||
that can have `tools_env` (except project).
|
||||
|
||||
Delegate requires tools cache which is shared across all ToolsDelegate
|
||||
objects.
|
||||
|
||||
Args:
|
||||
tools_cache (ToolsCache): Possible values of tools.
|
||||
"""
|
||||
|
||||
def __init__(self, tools_cache, *args, **kwargs):
|
||||
self._tools_cache = tools_cache
|
||||
super(ToolsDelegate, self).__init__(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ from Qt import QtCore, QtGui
|
|||
|
||||
|
||||
class ProjectModel(QtGui.QStandardItemModel):
|
||||
project_changed = QtCore.Signal()
|
||||
"""Load possible projects to modify from MongoDB.
|
||||
|
||||
Mongo collection must contain project document with "type" "project" and
|
||||
matching "name" value with name of collection.
|
||||
"""
|
||||
|
||||
def __init__(self, dbcon, *args, **kwargs):
|
||||
self.dbcon = dbcon
|
||||
|
|
@ -30,6 +34,7 @@ class ProjectModel(QtGui.QStandardItemModel):
|
|||
super(ProjectModel, self).__init__(*args, **kwargs)
|
||||
|
||||
def refresh(self):
|
||||
"""Reload projects."""
|
||||
self.dbcon.Session["AVALON_PROJECT"] = None
|
||||
|
||||
project_items = []
|
||||
|
|
@ -62,6 +67,12 @@ class ProjectModel(QtGui.QStandardItemModel):
|
|||
|
||||
|
||||
class HierarchySelectionModel(QtCore.QItemSelectionModel):
|
||||
"""Selection model with defined allowed multiselection columns.
|
||||
|
||||
This model allows to select multiple rows and enter one of their
|
||||
editors to edit value of all selected rows.
|
||||
"""
|
||||
|
||||
def __init__(self, multiselection_columns, *args, **kwargs):
|
||||
super(HierarchySelectionModel, self).__init__(*args, **kwargs)
|
||||
self.multiselection_columns = multiselection_columns
|
||||
|
|
@ -77,6 +88,21 @@ class HierarchySelectionModel(QtCore.QItemSelectionModel):
|
|||
|
||||
|
||||
class HierarchyModel(QtCore.QAbstractItemModel):
|
||||
"""Main model for hierarchy modification and value changes.
|
||||
|
||||
Main part of ProjectManager.
|
||||
|
||||
Model should be able to load existing entities, create new, handle their
|
||||
validations like name duplication and validate if is possible to save it's
|
||||
data.
|
||||
|
||||
Args:
|
||||
dbcon (AvalonMongoDB): Connection to MongoDB with set AVALON_PROJECT in
|
||||
it's Session to current project.
|
||||
"""
|
||||
|
||||
# Definition of all possible columns with their labels in default order
|
||||
# - order is important as column names are used as keys for column indexes
|
||||
_columns_def = [
|
||||
("name", "Name"),
|
||||
("type", "Type"),
|
||||
|
|
@ -92,6 +118,8 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
("pixelAspect", "Pixel aspect"),
|
||||
("tools_env", "Tools")
|
||||
]
|
||||
# Columns allowing multiselection in edit mode
|
||||
# - gives ability to set all of keys below on multiple items at once
|
||||
multiselection_columns = {
|
||||
"frameStart",
|
||||
"frameEnd",
|
||||
|
|
@ -140,13 +168,19 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return self._items_by_id
|
||||
|
||||
def _reset_root_item(self):
|
||||
"""Removes all previous content related to model."""
|
||||
self._root_item = RootItem(self)
|
||||
|
||||
def refresh_project(self):
|
||||
"""Reload project data and discard unsaved changes."""
|
||||
self.set_project(self._current_project, True)
|
||||
|
||||
@property
|
||||
def project_item(self):
|
||||
"""Access to current project item.
|
||||
|
||||
Model can have 0-1 ProjectItems at once.
|
||||
"""
|
||||
output = None
|
||||
for row in range(self._root_item.rowCount()):
|
||||
item = self._root_item.child(row)
|
||||
|
|
@ -156,6 +190,14 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return output
|
||||
|
||||
def set_project(self, project_name, force=False):
|
||||
"""Change project and discard unsaved changes.
|
||||
|
||||
Args:
|
||||
project_name(str): New project name. Or None if just clearing
|
||||
content.
|
||||
force(bool): Force to change project even if project name is same
|
||||
as current project.
|
||||
"""
|
||||
if self._current_project == project_name and not force:
|
||||
return
|
||||
|
||||
|
|
@ -166,19 +208,26 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
self.clear()
|
||||
|
||||
self._current_project = project_name
|
||||
|
||||
# Skip if project is None
|
||||
if not project_name:
|
||||
return
|
||||
|
||||
# Find project'd document
|
||||
project_doc = self.dbcon.database[project_name].find_one(
|
||||
{"type": "project"},
|
||||
ProjectItem.query_projection
|
||||
)
|
||||
# Skip if project document does not exist
|
||||
# - this shouldn't happen using only UI elements
|
||||
if not project_doc:
|
||||
return
|
||||
|
||||
# Create project item
|
||||
project_item = ProjectItem(project_doc)
|
||||
self.add_item(project_item)
|
||||
|
||||
# Query all assets of the project
|
||||
asset_docs = self.dbcon.database[project_name].find(
|
||||
{"type": "asset"},
|
||||
AssetItem.query_projection
|
||||
|
|
@ -188,7 +237,8 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
for asset_doc in asset_docs
|
||||
}
|
||||
|
||||
# Prepare booleans if asset item can be modified (name or hierarchy)
|
||||
# Check if asset have published content and prepare booleans
|
||||
# if asset item can be modified (name and hierarchy change)
|
||||
# - the same must be applied to all it's parents
|
||||
asset_ids = list(asset_docs_by_id.keys())
|
||||
result = []
|
||||
|
|
@ -217,6 +267,7 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
count = item["count"]
|
||||
asset_modifiable[asset_id] = count < 1
|
||||
|
||||
# Store assets by their visual parent to be able create their hierarchy
|
||||
asset_docs_by_parent_id = collections.defaultdict(list)
|
||||
for asset_doc in asset_docs_by_id.values():
|
||||
parent_id = asset_doc["data"].get("visualParent")
|
||||
|
|
@ -285,9 +336,11 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
|
||||
self.add_items(task_items, asset_item)
|
||||
|
||||
# Emit that project was successfully changed
|
||||
self.project_changed.emit()
|
||||
|
||||
def rowCount(self, parent=None):
|
||||
"""Number of rows for passed parent."""
|
||||
if parent is None or not parent.isValid():
|
||||
parent_item = self._root_item
|
||||
else:
|
||||
|
|
@ -295,9 +348,15 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return parent_item.rowCount()
|
||||
|
||||
def columnCount(self, *args, **kwargs):
|
||||
"""Number of columns is static for this model."""
|
||||
return self.columns_len
|
||||
|
||||
def data(self, index, role):
|
||||
"""Access data for passed index and it's role.
|
||||
|
||||
Model is using principles implemented in BaseItem so converts passed
|
||||
index column into key and ask item to return value for passed role.
|
||||
"""
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
|
|
@ -308,18 +367,24 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return item.data(role, key)
|
||||
|
||||
def setData(self, index, value, role=QtCore.Qt.EditRole):
|
||||
"""Store data to passed index under role.
|
||||
|
||||
Pass values to corresponding item and behave by it's result.
|
||||
"""
|
||||
if not index.isValid():
|
||||
return False
|
||||
|
||||
item = index.internalPointer()
|
||||
column = index.column()
|
||||
key = self.columns[column]
|
||||
# Capture asset name changes for duplcated asset names validation.
|
||||
if (
|
||||
key == "name"
|
||||
and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole)
|
||||
):
|
||||
self._rename_asset(item, value)
|
||||
|
||||
# Pass values to item and by result emi dataChanged signal or not
|
||||
result = item.setData(value, role, key)
|
||||
if result:
|
||||
self.dataChanged.emit(index, index, [role])
|
||||
|
|
@ -327,6 +392,7 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return result
|
||||
|
||||
def headerData(self, section, orientation, role):
|
||||
"""Header labels."""
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
if section < self.columnCount():
|
||||
return self.column_labels[section]
|
||||
|
|
@ -336,6 +402,7 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
)
|
||||
|
||||
def flags(self, index):
|
||||
"""Index flags are defined by corresponding item."""
|
||||
item = index.internalPointer()
|
||||
if item is None:
|
||||
return QtCore.Qt.NoItemFlags
|
||||
|
|
@ -344,6 +411,11 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return item.flags(key)
|
||||
|
||||
def parent(self, index=None):
|
||||
"""Parent for passed index as QModelIndex.
|
||||
|
||||
Args:
|
||||
index(QModelIndex): Parent index. Root item is used if not passed.
|
||||
"""
|
||||
if not index.isValid():
|
||||
return QtCore.QModelIndex()
|
||||
|
||||
|
|
@ -357,7 +429,13 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return self.createIndex(parent_item.row(), 0, parent_item)
|
||||
|
||||
def index(self, row, column, parent=None):
|
||||
"""Return index for row/column under parent"""
|
||||
"""Return index for row/column under parent.
|
||||
|
||||
Args:
|
||||
row(int): Row number.
|
||||
column(int): Column number.
|
||||
parent(QModelIndex): Parent index. Root item is used if not passed.
|
||||
"""
|
||||
parent_item = None
|
||||
if parent is not None and parent.isValid():
|
||||
parent_item = parent.internalPointer()
|
||||
|
|
@ -365,11 +443,31 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return self.index_from_item(row, column, parent_item)
|
||||
|
||||
def index_for_item(self, item, column=0):
|
||||
"""Index for passed item.
|
||||
|
||||
This is for cases that index operations are required on specific item.
|
||||
|
||||
Args:
|
||||
item(BaseItem): Item from model that will be converted to
|
||||
corresponding QModelIndex.
|
||||
column(int): Which column will be part of returned index. By
|
||||
default is used column 0.
|
||||
"""
|
||||
return self.index_from_item(
|
||||
item.row(), column, item.parent()
|
||||
)
|
||||
|
||||
def index_from_item(self, row, column, parent=None):
|
||||
"""Index for passed row, column and parent item.
|
||||
|
||||
Same implementation as `index` method but "parent" is one of
|
||||
BaseItem objects instead of QModelIndex.
|
||||
|
||||
Args:
|
||||
row(int): Row number.
|
||||
column(int): Column number.
|
||||
parent(BaseItem): Parent item. Root item is used if not passed.
|
||||
"""
|
||||
if parent is None:
|
||||
parent = self._root_item
|
||||
|
||||
|
|
@ -380,6 +478,12 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return QtCore.QModelIndex()
|
||||
|
||||
def add_new_asset(self, source_index):
|
||||
"""Create new asset item in hierarchy.
|
||||
|
||||
Args:
|
||||
source_index(QModelIndex): Parent under which new asset will be
|
||||
added.
|
||||
"""
|
||||
item_id = source_index.data(IDENTIFIER_ROLE)
|
||||
item = self.items_by_id[item_id]
|
||||
|
||||
|
|
@ -389,9 +493,11 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
if isinstance(item, (RootItem, ProjectItem)):
|
||||
name = "ep"
|
||||
new_row = None
|
||||
else:
|
||||
elif isinstance(item, AssetItem):
|
||||
name = None
|
||||
new_row = item.rowCount()
|
||||
else:
|
||||
return
|
||||
|
||||
asset_data = {}
|
||||
if name:
|
||||
|
|
@ -408,6 +514,13 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return result
|
||||
|
||||
def add_new_task(self, parent_index):
|
||||
"""Create new TaskItem under passed parent index or it's parent.
|
||||
|
||||
Args:
|
||||
parent_index(QModelIndex): Index of parent AssetItem under which
|
||||
will be task added. If index represents TaskItem it's parent is
|
||||
used as parent.
|
||||
"""
|
||||
item_id = parent_index.data(IDENTIFIER_ROLE)
|
||||
item = self.items_by_id[item_id]
|
||||
|
||||
|
|
@ -423,6 +536,18 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return self.add_item(new_child, parent)
|
||||
|
||||
def add_items(self, items, parent=None, start_row=None):
|
||||
"""Add new items with definition of QAbstractItemModel.
|
||||
|
||||
Trigger `beginInsertRows` and `endInsertRows` to trigger proper
|
||||
callbacks in view or proxy model.
|
||||
|
||||
Args:
|
||||
items(list[BaseItem]): List of item that will be inserted in model.
|
||||
parent(RootItem, ProjectItem, AssetItem): Parent of items under
|
||||
which will be items added. Root item is used if not passed.
|
||||
start_row(int): Define to which row will be items added. Next
|
||||
available row of parent is used if not passed.
|
||||
"""
|
||||
if parent is None:
|
||||
parent = self._root_item
|
||||
|
||||
|
|
@ -462,12 +587,25 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return indexes
|
||||
|
||||
def add_item(self, item, parent=None, row=None):
|
||||
"""Add single item into model."""
|
||||
result = self.add_items([item], parent, row)
|
||||
if result:
|
||||
return result[0]
|
||||
return None
|
||||
|
||||
def remove_delete_flag(self, item_ids, with_children=True):
|
||||
"""Remove deletion flag from items with matching ids.
|
||||
|
||||
The flag is also removed from all parents of passed children as it
|
||||
wouldn't make sense to not to do so.
|
||||
|
||||
Children of passed item ids are by default also unset for deletion.
|
||||
|
||||
Args:
|
||||
list(uuid4): Ids of model items where remove flag should be unset.
|
||||
with_children(bool): Unset remove flag also on all children of
|
||||
passed items.
|
||||
"""
|
||||
items_by_id = {}
|
||||
for item_id in item_ids:
|
||||
if item_id in items_by_id:
|
||||
|
|
@ -514,9 +652,11 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
self._validate_asset_duplicity(name)
|
||||
|
||||
def delete_index(self, index):
|
||||
"""Delete item of the index from model."""
|
||||
return self.delete_indexes([index])
|
||||
|
||||
def delete_indexes(self, indexes):
|
||||
"""Delete items from model."""
|
||||
items_by_id = {}
|
||||
processed_ids = set()
|
||||
for index in indexes:
|
||||
|
|
@ -539,12 +679,26 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
self._remove_item(item)
|
||||
|
||||
def _remove_item(self, item):
|
||||
"""Remove item from model or mark item for deletion.
|
||||
|
||||
Deleted items are using definition of QAbstractItemModel which call
|
||||
`beginRemoveRows` and `endRemoveRows` to trigger proper view and proxy
|
||||
model callbacks.
|
||||
|
||||
Item is not just removed but is checked if can be removed from model or
|
||||
just mark it for deletion for save.
|
||||
|
||||
First of all will find all related children and based on their
|
||||
attributes define if can be removed.
|
||||
"""
|
||||
# Skip if item is already marked for deletion
|
||||
is_removed = item.data(REMOVED_ROLE)
|
||||
if is_removed:
|
||||
return
|
||||
|
||||
parent = item.parent()
|
||||
|
||||
# Find all descendants and store them by parent id
|
||||
all_descendants = collections.defaultdict(dict)
|
||||
all_descendants[parent.id][item.id] = item
|
||||
|
||||
|
|
@ -577,6 +731,8 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
if isinstance(cur_item, AssetItem):
|
||||
self._rename_asset(cur_item, None)
|
||||
|
||||
# Process tasks as last because their logic is based on parent
|
||||
# - tasks may be processed before parent check all asset children
|
||||
for task_item in task_children:
|
||||
_fill_children(_all_descendants, task_item, cur_item)
|
||||
return remove_item
|
||||
|
|
@ -602,21 +758,29 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
if not all_without_children:
|
||||
continue
|
||||
|
||||
parent_item = self._items_by_id[parent_id]
|
||||
# Row ranges of items to remove
|
||||
# - store tuples of row "start", "end" (can be the same)
|
||||
row_ranges = []
|
||||
# Predefine start, end variables
|
||||
start_row = end_row = None
|
||||
chilren_by_row = {}
|
||||
parent_item = self._items_by_id[parent_id]
|
||||
for row in range(parent_item.rowCount()):
|
||||
child_item = parent_item.child(row)
|
||||
child_id = child_item.id
|
||||
# Not sure if this can happend
|
||||
# TODO validate this line it seems dangerous as start/end
|
||||
# row is not changed
|
||||
if child_id not in children:
|
||||
continue
|
||||
|
||||
chilren_by_row[row] = child_item
|
||||
children.pop(child_item.id)
|
||||
|
||||
remove_item = child_item.data(REMOVED_ROLE)
|
||||
if not remove_item or not child_item.is_new:
|
||||
removed_mark = child_item.data(REMOVED_ROLE)
|
||||
if not removed_mark or not child_item.is_new:
|
||||
# Skip row sequence store child for later processing
|
||||
# and store current start/end row range
|
||||
modified_children.append(child_item)
|
||||
if end_row is not None:
|
||||
row_ranges.append((start_row, end_row))
|
||||
|
|
@ -630,11 +794,12 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
if end_row is not None:
|
||||
row_ranges.append((start_row, end_row))
|
||||
|
||||
parent_index = None
|
||||
for start, end in row_ranges:
|
||||
if parent_index is None:
|
||||
parent_index = self.index_for_item(parent_item)
|
||||
if not row_ranges:
|
||||
continue
|
||||
|
||||
# Remove items from model
|
||||
parent_index = self.index_for_item(parent_item)
|
||||
for start, end in row_ranges:
|
||||
self.beginRemoveRows(parent_index, start, end)
|
||||
|
||||
for idx in range(start, end + 1):
|
||||
|
|
@ -647,6 +812,8 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
|
||||
self.endRemoveRows()
|
||||
|
||||
# Trigger data change to repaint items
|
||||
# - `BackgroundRole` is random role without any specific reason
|
||||
for item in modified_children:
|
||||
s_index = self.index_for_item(item)
|
||||
e_index = self.index_for_item(item, column=self.columns_len - 1)
|
||||
|
|
@ -1060,12 +1227,32 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
self.index_moved.emit(new_index)
|
||||
|
||||
def move_vertical(self, indexes, direction):
|
||||
"""Move item vertically in model to matching parent if possible.
|
||||
|
||||
If passed indexes contain items that has parent<->child relation at any
|
||||
hierarchy level only the top parent is actually moved.
|
||||
|
||||
Example (items marked with star are passed in `indexes`):
|
||||
- shots*
|
||||
- ep01
|
||||
- ep01_sh0010*
|
||||
- ep01_sh0020*
|
||||
In this case only `shots` item will be moved vertically and
|
||||
both "ep01_sh0010" "ep01_sh0020" will stay as children of "ep01".
|
||||
|
||||
Args:
|
||||
indexes(list[QModelIndex]): Indexes that should be moved
|
||||
vertically.
|
||||
direction(int): Which way will be moved -1 or 1 to determine.
|
||||
"""
|
||||
if not indexes:
|
||||
return
|
||||
|
||||
# Convert single index to list of indexes
|
||||
if isinstance(indexes, QtCore.QModelIndex):
|
||||
indexes = [indexes]
|
||||
|
||||
# Just process single index
|
||||
if len(indexes) == 1:
|
||||
self._move_vertical_single(indexes[0], direction)
|
||||
return
|
||||
|
|
@ -1100,6 +1287,7 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
self._move_vertical_single(index, direction)
|
||||
|
||||
def child_removed(self, child):
|
||||
"""Callback for removed child."""
|
||||
self._items_by_id.pop(child.id, None)
|
||||
|
||||
def column_name(self, column):
|
||||
|
|
@ -1109,11 +1297,19 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
return None
|
||||
|
||||
def clear(self):
|
||||
"""Reset model."""
|
||||
self.beginResetModel()
|
||||
self._reset_root_item()
|
||||
self.endResetModel()
|
||||
|
||||
def save(self):
|
||||
"""Save all changes from current project manager session.
|
||||
|
||||
Will create new asset documents, update existing and asset documents
|
||||
marked for deletion are removed from mongo if has published content or
|
||||
their type is changed to `archived_asset` to not loose their data.
|
||||
"""
|
||||
# Check if all items are valid before save
|
||||
all_valid = True
|
||||
for item in self._items_by_id.values():
|
||||
if not item.is_valid:
|
||||
|
|
@ -1123,6 +1319,7 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
if not all_valid:
|
||||
return
|
||||
|
||||
# Check project item and do not save without it
|
||||
project_item = None
|
||||
for _project_item in self._root_item.children():
|
||||
project_item = _project_item
|
||||
|
|
@ -1133,6 +1330,9 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
project_name = project_item.name
|
||||
project_col = self.dbcon.database[project_name]
|
||||
|
||||
# Process asset items per one hierarchical level.
|
||||
# - new assets are inserted per one parent
|
||||
# - update and delete data are stored and processed at once at the end
|
||||
to_process = collections.deque()
|
||||
to_process.append(project_item)
|
||||
|
||||
|
|
@ -1253,6 +1453,14 @@ class HierarchyModel(QtCore.QAbstractItemModel):
|
|||
|
||||
|
||||
class BaseItem:
|
||||
"""Base item for HierarchyModel.
|
||||
|
||||
Is not meant to be used as real item but as superclass for all items used
|
||||
in HierarchyModel.
|
||||
|
||||
TODO cleanup some attributes and methods related only to AssetItem and
|
||||
TaskItem.
|
||||
"""
|
||||
columns = []
|
||||
# Use `set` for faster result
|
||||
editable_columns = set()
|
||||
|
|
@ -1280,6 +1488,10 @@ class BaseItem:
|
|||
self._data[key] = value
|
||||
|
||||
def name_icon(self):
|
||||
"""Icon shown next to name.
|
||||
|
||||
Item must imlpement this method to change it.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
|
|
@ -1298,6 +1510,7 @@ class BaseItem:
|
|||
self._children.insert(row, item)
|
||||
|
||||
def _get_global_data(self, role):
|
||||
"""Global data getter without column specification."""
|
||||
if role == ITEM_TYPE_ROLE:
|
||||
return self.item_type
|
||||
|
||||
|
|
@ -1425,6 +1638,7 @@ class BaseItem:
|
|||
|
||||
|
||||
class RootItem(BaseItem):
|
||||
"""Invisible root item used as base item for model."""
|
||||
item_type = "root"
|
||||
|
||||
def __init__(self, model):
|
||||
|
|
@ -1439,6 +1653,10 @@ class RootItem(BaseItem):
|
|||
|
||||
|
||||
class ProjectItem(BaseItem):
|
||||
"""Item representing project document in Mongo.
|
||||
|
||||
Item is used only to read it's data. It is not possible to modify them.
|
||||
"""
|
||||
item_type = "project"
|
||||
|
||||
columns = {
|
||||
|
|
@ -1482,21 +1700,32 @@ class ProjectItem(BaseItem):
|
|||
|
||||
@property
|
||||
def project_id(self):
|
||||
"""Project Mongo ID."""
|
||||
return self._mongo_id
|
||||
|
||||
@property
|
||||
def asset_id(self):
|
||||
"""Should not be implemented.
|
||||
|
||||
TODO Remove this method from ProjectItem.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Project name"""
|
||||
return self._data["name"]
|
||||
|
||||
def child_parents(self):
|
||||
"""Used by children AssetItems for filling `data.parents` key."""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def data_from_doc(cls, project_doc):
|
||||
"""Convert document data into item data.
|
||||
|
||||
Project data are used as default value for it's children.
|
||||
"""
|
||||
data = {
|
||||
"name": project_doc["name"],
|
||||
"type": project_doc["type"]
|
||||
|
|
@ -1511,10 +1740,17 @@ class ProjectItem(BaseItem):
|
|||
return data
|
||||
|
||||
def flags(self, *args, **kwargs):
|
||||
"""Project is enabled and selectable."""
|
||||
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
|
||||
|
||||
class AssetItem(BaseItem):
|
||||
"""Item represent asset document.
|
||||
|
||||
Item have ability to set all required and optional data for OpenPype
|
||||
workflow. Some of them are not modifiable in specific cases e.g. when asset
|
||||
has published content it is not possible to change it's name or parent.
|
||||
"""
|
||||
item_type = "asset"
|
||||
|
||||
columns = {
|
||||
|
|
@ -1597,34 +1833,57 @@ class AssetItem(BaseItem):
|
|||
|
||||
@property
|
||||
def project_id(self):
|
||||
"""Access to project "parent" id which is always set."""
|
||||
if self._project_id is None:
|
||||
self._project_id = self.parent().project_id
|
||||
return self._project_id
|
||||
|
||||
@property
|
||||
def asset_id(self):
|
||||
"""Property access to mongo id."""
|
||||
return self.mongo_id
|
||||
|
||||
@property
|
||||
def is_new(self):
|
||||
"""Item was created during current project manager session."""
|
||||
return self.asset_id is None
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Item is invalid for saving."""
|
||||
if self._is_duplicated or not self._data["name"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Asset name.
|
||||
|
||||
Returns:
|
||||
str: If name is set.
|
||||
None: If name is not yet set in that case is AssetItem marked as
|
||||
invalid.
|
||||
"""
|
||||
return self._data["name"]
|
||||
|
||||
def child_parents(self):
|
||||
"""Chilren AssetItem can use this method to get it's parent names.
|
||||
|
||||
This is used for `data.parents` key on document.
|
||||
"""
|
||||
parents = self.parent().child_parents()
|
||||
parents.append(self.name)
|
||||
return parents
|
||||
|
||||
def to_doc(self):
|
||||
"""Convert item to Mongo document matching asset schema.
|
||||
|
||||
Method does no validate if item is valid or children are valid.
|
||||
|
||||
Returns:
|
||||
dict: Document with all related data about asset item also
|
||||
contains task children.
|
||||
"""
|
||||
tasks = {}
|
||||
for item in self.children():
|
||||
if isinstance(item, TaskItem):
|
||||
|
|
@ -1659,6 +1918,22 @@ class AssetItem(BaseItem):
|
|||
return doc
|
||||
|
||||
def update_data(self):
|
||||
"""Changes dictionary ready for Mongo's update.
|
||||
|
||||
Method should be used on save. There is not other usage of this method.
|
||||
|
||||
# Example
|
||||
```python
|
||||
{
|
||||
"$set": {
|
||||
"name": "new_name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns:
|
||||
dict: May be empty if item was not changed.
|
||||
"""
|
||||
if not self.mongo_id:
|
||||
return {}
|
||||
|
||||
|
|
@ -1695,6 +1970,8 @@ class AssetItem(BaseItem):
|
|||
|
||||
@classmethod
|
||||
def data_from_doc(cls, asset_doc):
|
||||
"""Convert asset document from Mongo to item data."""
|
||||
# Minimum required data for cases that it is new AssetItem withoud doc
|
||||
data = {
|
||||
"name": None,
|
||||
"type": "asset"
|
||||
|
|
@ -1714,6 +1991,7 @@ class AssetItem(BaseItem):
|
|||
return data
|
||||
|
||||
def name_icon(self):
|
||||
"""Icon shown next to name."""
|
||||
if self.__class__._name_icons is None:
|
||||
self.__class__._name_icons = ResourceCache.get_icons()["asset"]
|
||||
|
||||
|
|
@ -1728,6 +2006,7 @@ class AssetItem(BaseItem):
|
|||
return self.__class__._name_icons[icon_type]
|
||||
|
||||
def _get_global_data(self, role):
|
||||
"""Global data getter without column specification."""
|
||||
if role == HIERARCHY_CHANGE_ABLE_ROLE:
|
||||
return self._hierarchy_changes_enabled
|
||||
|
||||
|
|
@ -1757,6 +2036,8 @@ class AssetItem(BaseItem):
|
|||
return super(AssetItem, self).data(role, key)
|
||||
|
||||
def setData(self, value, role, key=None):
|
||||
# Store information that column has opened editor
|
||||
# - DisplayRole for the column will return empty string
|
||||
if role == EDITOR_OPENED_ROLE:
|
||||
if key not in self._edited_columns:
|
||||
return False
|
||||
|
|
@ -1767,12 +2048,15 @@ class AssetItem(BaseItem):
|
|||
self._removed = value
|
||||
return True
|
||||
|
||||
# This can be set only on project load (or save)
|
||||
if role == HIERARCHY_CHANGE_ABLE_ROLE:
|
||||
if self._hierarchy_changes_enabled == value:
|
||||
return False
|
||||
self._hierarchy_changes_enabled = value
|
||||
return True
|
||||
|
||||
# Do not allow to change name if item is marked to not be able do any
|
||||
# hierarchical changes.
|
||||
if (
|
||||
role == QtCore.Qt.EditRole
|
||||
and key == "name"
|
||||
|
|
@ -1820,6 +2104,8 @@ class AssetItem(BaseItem):
|
|||
_item.setData(False, DUPLICATED_ROLE)
|
||||
|
||||
def _rename_task(self, item):
|
||||
# Skip processing if item is marked for removing
|
||||
# - item is not in any of attributes below
|
||||
if item.data(REMOVED_ROLE):
|
||||
return
|
||||
|
||||
|
|
@ -1851,9 +2137,22 @@ class AssetItem(BaseItem):
|
|||
self._task_name_by_item_id[item_id] = new_name
|
||||
|
||||
def on_task_name_change(self, task_item):
|
||||
"""Method called from TaskItem children on name change.
|
||||
|
||||
Helps to handle duplicated task name validations.
|
||||
"""
|
||||
|
||||
self._rename_task(task_item)
|
||||
|
||||
def on_task_remove_state_change(self, task_item):
|
||||
"""Method called from children TaskItem to handle name duplications.
|
||||
|
||||
Method is called when TaskItem children is marked for deletion or
|
||||
deletion was reversed.
|
||||
|
||||
Name is removed/added to task item mapping attribute and removed/added
|
||||
to `_task_items_by_name` used for determination of duplicated tasks.
|
||||
"""
|
||||
is_removed = task_item.data(REMOVED_ROLE)
|
||||
item_id = task_item.data(IDENTIFIER_ROLE)
|
||||
if is_removed:
|
||||
|
|
@ -1880,18 +2179,35 @@ class AssetItem(BaseItem):
|
|||
_item.setData(True, DUPLICATED_ROLE)
|
||||
|
||||
def add_child(self, item, row=None):
|
||||
"""Add new children.
|
||||
|
||||
Args:
|
||||
item(AssetItem, TaskItem): New added item.
|
||||
row(int): Optionally can be passed on which row (index) should be
|
||||
children added.
|
||||
"""
|
||||
if item in self._children:
|
||||
return
|
||||
|
||||
super(AssetItem, self).add_child(item, row)
|
||||
|
||||
# Call inner method for checking task name duplications
|
||||
if isinstance(item, TaskItem):
|
||||
self._add_task(item)
|
||||
|
||||
def remove_child(self, item):
|
||||
"""Remove one of children from AssetItem children.
|
||||
|
||||
Skipped if item is not children of item.
|
||||
|
||||
Args:
|
||||
item(AssetItem, TaskItem): Child item.
|
||||
"""
|
||||
if item not in self._children:
|
||||
return
|
||||
|
||||
# Call inner method to remove task from registered task name
|
||||
# validations.
|
||||
if isinstance(item, TaskItem):
|
||||
self._remove_task(item)
|
||||
|
||||
|
|
@ -1899,6 +2215,16 @@ class AssetItem(BaseItem):
|
|||
|
||||
|
||||
class TaskItem(BaseItem):
|
||||
"""Item representing Task item on Asset document.
|
||||
|
||||
Always should be AssetItem children and never should have any other
|
||||
childrens.
|
||||
|
||||
It's name value should be validated with it's parent which only knows if
|
||||
has same name as other sibling under same parent.
|
||||
"""
|
||||
|
||||
# String representation of item
|
||||
item_type = "task"
|
||||
|
||||
columns = {
|
||||
|
|
@ -1927,10 +2253,12 @@ class TaskItem(BaseItem):
|
|||
|
||||
@property
|
||||
def is_new(self):
|
||||
"""Task was created during current project manager session."""
|
||||
return self._is_new
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Task valid for saving."""
|
||||
if self._is_duplicated or not self._data["type"]:
|
||||
return False
|
||||
if not self.data(QtCore.Qt.EditRole, "name"):
|
||||
|
|
@ -1938,6 +2266,7 @@ class TaskItem(BaseItem):
|
|||
return True
|
||||
|
||||
def name_icon(self):
|
||||
"""Icon shown next to name."""
|
||||
if self.__class__._name_icons is None:
|
||||
self.__class__._name_icons = ResourceCache.get_icons()["task"]
|
||||
|
||||
|
|
@ -1952,9 +2281,11 @@ class TaskItem(BaseItem):
|
|||
return self.__class__._name_icons[icon_type]
|
||||
|
||||
def add_child(self, item, row=None):
|
||||
"""Reimplement `add_child` to avoid adding items under task."""
|
||||
raise AssertionError("BUG: Can't add children to Task")
|
||||
|
||||
def _get_global_data(self, role):
|
||||
"""Global data getter without column specification."""
|
||||
if role == REMOVED_ROLE:
|
||||
return self._removed
|
||||
|
||||
|
|
@ -1973,6 +2304,12 @@ class TaskItem(BaseItem):
|
|||
return super(TaskItem, self)._get_global_data(role)
|
||||
|
||||
def to_doc_data(self):
|
||||
"""Data for Asset document.
|
||||
|
||||
Returns:
|
||||
dict: May be empty if task is marked as removed or with single key
|
||||
dict with name as key and task data as value.
|
||||
"""
|
||||
if self._removed:
|
||||
return {}
|
||||
data = copy.deepcopy(self._data)
|
||||
|
|
@ -1988,6 +2325,7 @@ class TaskItem(BaseItem):
|
|||
return False
|
||||
return self._edited_columns[key]
|
||||
|
||||
# Return empty string if column is edited
|
||||
if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key):
|
||||
return ""
|
||||
|
||||
|
|
@ -1995,6 +2333,7 @@ class TaskItem(BaseItem):
|
|||
if key == "type":
|
||||
return self._data["type"]
|
||||
|
||||
# Always require task type filled
|
||||
if key == "name":
|
||||
if not self._data["type"]:
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
|
|
@ -2007,6 +2346,8 @@ class TaskItem(BaseItem):
|
|||
return super(TaskItem, self).data(role, key)
|
||||
|
||||
def setData(self, value, role, key=None):
|
||||
# Store information that item on a column is edited
|
||||
# - DisplayRole will return empty string in that case
|
||||
if role == EDITOR_OPENED_ROLE:
|
||||
if key not in self._edited_columns:
|
||||
return False
|
||||
|
|
@ -2014,12 +2355,14 @@ class TaskItem(BaseItem):
|
|||
return True
|
||||
|
||||
if role == REMOVED_ROLE:
|
||||
# Skip value change if is same as already set value
|
||||
if value == self._removed:
|
||||
return False
|
||||
self._removed = value
|
||||
self.parent().on_task_remove_state_change(self)
|
||||
return True
|
||||
|
||||
# Convert empty string to None on EditRole
|
||||
if (
|
||||
role == QtCore.Qt.EditRole
|
||||
and key == "name"
|
||||
|
|
@ -2030,6 +2373,7 @@ class TaskItem(BaseItem):
|
|||
result = super(TaskItem, self).setData(value, role, key)
|
||||
|
||||
if role == QtCore.Qt.EditRole:
|
||||
# Trigger task name change of parent AssetItem
|
||||
if (
|
||||
key == "name"
|
||||
or (key == "type" and not self._data["name"])
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from avalon.api import AvalonMongoDB
|
|||
|
||||
|
||||
class ProjectManagerWindow(QtWidgets.QWidget):
|
||||
"""Main widget of Project Manager tool."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(ProjectManagerWindow, self).__init__(parent)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring Pype version."""
|
||||
__version__ = "3.1.0-nightly.2"
|
||||
__version__ = "3.1.0-nightly.3"
|
||||
|
|
|
|||
|
|
@ -5834,9 +5834,9 @@ normalize-range@^0.1.2:
|
|||
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
|
||||
|
||||
normalize-url@^4.1.0, normalize-url@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129"
|
||||
integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
|
||||
version "4.5.1"
|
||||
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a"
|
||||
integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==
|
||||
|
||||
npm-run-path@^2.0.0:
|
||||
version "2.0.2"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue