Merge branch 'develop' into bugfix/1587-hiero-published-whole-edit-mov

This commit is contained in:
Jakub Jezek 2021-06-14 11:19:45 +02:00
commit 71fc0fceef
No known key found for this signature in database
GPG key ID: D8548FBF690B100A
27 changed files with 992 additions and 202 deletions

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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"],

View file

@ -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:

View file

@ -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 = {

View file

@ -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({

View file

@ -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

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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):

View file

@ -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
)

View file

@ -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']))

View 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

View file

@ -215,6 +215,17 @@
"hosts": [],
"tasks": [],
"template": "{family}{Task}{Variant}"
},
{
"families": [
"renderLayer",
"renderPass"
],
"hosts": [
"tvpaint"
],
"tasks": [],
"template": "{family}{Task}_{Render_layer}_{Render_pass}"
}
]
},

View file

@ -34,7 +34,8 @@
"jpeg",
"png",
"h264",
"mov"
"mov",
"mp4"
],
"clip_name_template": "{asset}_{subset}_{representation}"
}

View file

@ -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):

View file

@ -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

View file

@ -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 + "]*$")

View file

@ -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)

View file

@ -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"])

View file

@ -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)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.1.0-nightly.2"
__version__ = "3.1.0-nightly.3"

View file

@ -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"