diff --git a/pype/hooks/global/pre_global_host_data.py b/pype/hooks/global/pre_global_host_data.py
index 4910d08010..cb497814f5 100644
--- a/pype/hooks/global/pre_global_host_data.py
+++ b/pype/hooks/global/pre_global_host_data.py
@@ -11,7 +11,9 @@ from pype.api import (
from pype.lib import (
env_value_to_bool,
PreLaunchHook,
- ApplicationLaunchFailed
+ ApplicationLaunchFailed,
+ get_workdir_data,
+ get_workdir_with_workdir_data,
)
import acre
@@ -140,17 +142,15 @@ class GlobalHostDataHook(PreLaunchHook):
)
return
- workdir_data = self._prepare_workdir_data(
- project_doc, asset_doc, task_name
+ workdir_data = get_workdir_data(
+ project_doc, asset_doc, task_name, self.host_name
)
self.data["workdir_data"] = workdir_data
- hierarchy = workdir_data["hierarchy"]
anatomy = self.data["anatomy"]
try:
- anatomy_filled = anatomy.format(workdir_data)
- workdir = os.path.normpath(anatomy_filled["work"]["folder"])
+ workdir = get_workdir_with_workdir_data(workdir_data, anatomy)
if not os.path.exists(workdir):
self.log.debug(
"Creating workdir folder: \"{}\"".format(workdir)
@@ -168,7 +168,6 @@ class GlobalHostDataHook(PreLaunchHook):
"AVALON_TASK": task_name,
"AVALON_APP": self.host_name,
"AVALON_APP_NAME": self.app_name,
- "AVALON_HIERARCHY": hierarchy,
"AVALON_WORKDIR": workdir
}
self.log.debug(
@@ -180,21 +179,6 @@ class GlobalHostDataHook(PreLaunchHook):
self.prepare_last_workfile(workdir)
- def _prepare_workdir_data(self, project_doc, asset_doc, task_name):
- hierarchy = "/".join(asset_doc["data"]["parents"])
-
- data = {
- "project": {
- "name": project_doc["name"],
- "code": project_doc["data"].get("code")
- },
- "task": task_name,
- "asset": asset_doc["name"],
- "app": self.host_name,
- "hierarchy": hierarchy
- }
- return data
-
def prepare_last_workfile(self, workdir):
"""last workfile workflow preparation.
diff --git a/pype/hosts/maya/menu.py b/pype/hosts/maya/menu.py
index 288502a1e1..fa7690bca7 100644
--- a/pype/hosts/maya/menu.py
+++ b/pype/hosts/maya/menu.py
@@ -13,12 +13,14 @@ self._menu = os.environ.get('PYPE_STUDIO_NAME') or "Pype"
log = logging.getLogger(__name__)
-def _get_menu():
+def _get_menu(menu_name=None):
"""Return the menu instance if it currently exists in Maya"""
+ if menu_name is None:
+ menu_name = self._menu
widgets = dict((
w.objectName(), w) for w in QtWidgets.QApplication.allWidgets())
- menu = widgets.get(self._menu)
+ menu = widgets.get(menu_name)
return menu
@@ -40,10 +42,51 @@ def deferred():
command=lambda *args: mayalookassigner.show()
)
+ def modify_workfiles():
+ from pype.tools import workfiles
+
+ def launch_workfiles_app(*_args, **_kwargs):
+ workfiles.show(
+ os.path.join(
+ cmds.workspace(query=True, rootDirectory=True),
+ cmds.workspace(fileRuleEntry="scene")
+ ),
+ parent=pipeline._parent
+ )
+
+ # Find the pipeline menu
+ top_menu = _get_menu(pipeline._menu)
+
+ # Try to find workfile tool action in the menu
+ workfile_action = None
+ for action in top_menu.actions():
+ if action.text() == "Work Files":
+ workfile_action = action
+ break
+
+ # Add at the top of menu if "Work Files" action was not found
+ after_action = ""
+ if workfile_action:
+ # Use action's object name for `insertAfter` argument
+ after_action = workfile_action.objectName()
+
+ # Insert action to menu
+ cmds.menuItem(
+ "Work Files",
+ parent=pipeline._menu,
+ command=launch_workfiles_app,
+ insertAfter=after_action
+ )
+
+ # Remove replaced action
+ if workfile_action:
+ top_menu.removeAction(workfile_action)
+
log.info("Attempting to install scripts menu..")
add_build_workfiles_item()
add_look_assigner_item()
+ modify_workfiles()
try:
import scriptsmenu.launchformaya as launchformaya
diff --git a/pype/hosts/nuke/menu.py b/pype/hosts/nuke/menu.py
index f35cebfde0..a8d5090da9 100644
--- a/pype/hosts/nuke/menu.py
+++ b/pype/hosts/nuke/menu.py
@@ -1,9 +1,11 @@
+import os
import nuke
from avalon.api import Session
from pype.hosts.nuke import lib
from ...lib import BuildWorkfile
from pype.api import Logger
+from pype.tools import workfiles
log = Logger().get_logger(__name__)
@@ -12,6 +14,24 @@ def install():
menubar = nuke.menu("Nuke")
menu = menubar.findItem(Session["AVALON_LABEL"])
workfile_settings = lib.WorkfileSettings
+
+ # replace reset resolution from avalon core to pype's
+ name = "Work Files..."
+ rm_item = [
+ (i, item) for i, item in enumerate(menu.items()) if name in item.name()
+ ][0]
+
+ log.debug("Changing Item: {}".format(rm_item))
+
+ menu.removeItem(rm_item[1].name())
+ menu.addCommand(
+ name,
+ lambda: workfiles.show(
+ os.environ["AVALON_WORKDIR"]
+ ),
+ index=(rm_item[0])
+ )
+
# replace reset resolution from avalon core to pype's
name = "Reset Resolution"
new_name = "Set Resolution"
diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py
index b393c9a177..7e33577f3e 100644
--- a/pype/lib/__init__.py
+++ b/pype/lib/__init__.py
@@ -35,6 +35,15 @@ from .avalon_context import (
get_hierarchy,
get_linked_assets,
get_latest_version,
+
+ get_workdir_data,
+ get_workdir,
+ get_workdir_with_workdir_data,
+
+ create_workfile_doc,
+ save_workfile_data_to_doc,
+ get_workfile_doc,
+
BuildWorkfile
)
@@ -103,6 +112,15 @@ __all__ = [
"get_hierarchy",
"get_linked_assets",
"get_latest_version",
+
+ "get_workdir_data",
+ "get_workdir",
+ "get_workdir_with_workdir_data",
+
+ "create_workfile_doc",
+ "save_workfile_data_to_doc",
+ "get_workfile_doc",
+
"BuildWorkfile",
"ApplicationLaunchFailed",
diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py
index 3a18e956d9..fd4155703e 100644
--- a/pype/lib/avalon_context.py
+++ b/pype/lib/avalon_context.py
@@ -1,11 +1,13 @@
import os
import json
import re
+import copy
import logging
import collections
import functools
from pype.settings import get_project_settings
+from .anatomy import Anatomy
# avalon module is not imported at the top
# - may not be in path at the time of pype.lib initialization
@@ -246,6 +248,229 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None):
return version_doc
+def get_workdir_data(project_doc, asset_doc, task_name, host_name):
+ """Prepare data for workdir template filling from entered information.
+
+ Args:
+ project_doc (dict): Mongo document of project from MongoDB.
+ asset_doc (dict): Mongo document of asset from MongoDB.
+ task_name (str): Task name for which are workdir data preapred.
+ host_name (str): Host which is used to workdir. This is required
+ because workdir template may contain `{app}` key.
+
+ Returns:
+ dict: Data prepared for filling workdir template.
+ """
+ hierarchy = "/".join(asset_doc["data"]["parents"])
+
+ data = {
+ "project": {
+ "name": project_doc["name"],
+ "code": project_doc["data"].get("code")
+ },
+ "task": task_name,
+ "asset": asset_doc["name"],
+ "app": host_name,
+ "hierarchy": hierarchy
+ }
+ return data
+
+
+def get_workdir_with_workdir_data(
+ workdir_data, anatomy=None, project_name=None, template_key=None
+):
+ """Fill workdir path from entered data and project's anatomy.
+
+ It is possible to pass only project's name instead of project's anatomy but
+ one of them **must** be entered. It is preffered to enter anatomy if is
+ available as initialization of a new Anatomy object may be time consuming.
+
+ Args:
+ workdir_data (dict): Data to fill workdir template.
+ anatomy (Anatomy): Anatomy object for specific project. Optional if
+ `project_name` is entered.
+ project_name (str): Project's name. Optional if `anatomy` is entered
+ otherwise Anatomy object is created with using the project name.
+ template_key (str): Key of work templates in anatomy templates. By
+ default is seto to `"work"`.
+
+ Returns:
+ TemplateResult: Workdir path.
+
+ Raises:
+ ValueError: When both `anatomy` and `project_name` are set to None.
+ """
+ if not anatomy and not project_name:
+ raise ValueError((
+ "Missing required arguments one of `project_name` or `anatomy`"
+ " must be entered."
+ ))
+
+ if not anatomy:
+ anatomy = Anatomy(project_name)
+
+ if not template_key:
+ template_key = "work"
+
+ anatomy_filled = anatomy.format(workdir_data)
+ # Output is TemplateResult object which contain usefull data
+ return anatomy_filled[template_key]["folder"]
+
+
+def get_workdir(
+ project_doc,
+ asset_doc,
+ task_name,
+ host_name,
+ anatomy=None,
+ template_key=None
+):
+ """Fill workdir path from entered data and project's anatomy.
+
+ Args:
+ project_doc (dict): Mongo document of project from MongoDB.
+ asset_doc (dict): Mongo document of asset from MongoDB.
+ task_name (str): Task name for which are workdir data preapred.
+ host_name (str): Host which is used to workdir. This is required
+ because workdir template may contain `{app}` key. In `Session`
+ is stored under `AVALON_APP` key.
+ anatomy (Anatomy): Optional argument. Anatomy object is created using
+ project name from `project_doc`. It is preffered to pass this
+ argument as initialization of a new Anatomy object may be time
+ consuming.
+ template_key (str): Key of work templates in anatomy templates. Default
+ value is defined in `get_workdir_with_workdir_data`.
+
+ Returns:
+ TemplateResult: Workdir path.
+ """
+ if not anatomy:
+ anatomy = Anatomy(project_doc["name"])
+
+ workdir_data = get_workdir_data(
+ project_doc, asset_doc, task_name, host_name
+ )
+ # Output is TemplateResult object which contain usefull data
+ return get_workdir_with_workdir_data(workdir_data, anatomy, template_key)
+
+
+@with_avalon
+def get_workfile_doc(asset_id, task_name, filename, dbcon=None):
+ """Return workfile document for entered context.
+
+ Do not use this method to get more than one document. In that cases use
+ custom query as this will return documents from database one by one.
+
+ Args:
+ asset_id (ObjectId): Mongo ID of an asset under which workfile belongs.
+ task_name (str): Name of task under which the workfile belongs.
+ filename (str): Name of a workfile.
+ dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and
+ `avalon.io` is used if not entered.
+
+ Returns:
+ dict: Workfile document or None.
+ """
+ # Use avalon.io if dbcon is not entered
+ if not dbcon:
+ dbcon = avalon.io
+
+ return dbcon.find_one({
+ "type": "workfile",
+ "parent": asset_id,
+ "task_name": task_name,
+ "filename": filename
+ })
+
+
+@with_avalon
+def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None):
+ """Creates or replace workfile document in mongo.
+
+ Do not use this method to update data. This method will remove all
+ additional data from existing document.
+
+ Args:
+ asset_doc (dict): Document of asset under which workfile belongs.
+ task_name (str): Name of task for which is workfile related to.
+ filename (str): Filename of workfile.
+ workdir (str): Path to directory where `filename` is located.
+ dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and
+ `avalon.io` is used if not entered.
+ """
+ # Use avalon.io if dbcon is not entered
+ if not dbcon:
+ dbcon = avalon.io
+
+ # Filter of workfile document
+ doc_filter = {
+ "type": "workfile",
+ "parent": asset_doc["_id"],
+ "task_name": task_name,
+ "filename": filename
+ }
+ # Document data are copy of filter
+ doc_data = copy.deepcopy(doc_filter)
+
+ # Prepare project for workdir data
+ project_doc = dbcon.find_one({"type": "project"})
+ workdir_data = get_workdir_data(
+ project_doc, asset_doc, task_name, dbcon.Session["AVALON_APP"]
+ )
+ # Prepare anatomy
+ anatomy = Anatomy(project_doc["name"])
+ # Get workdir path (result is anatomy.TemplateResult)
+ template_workdir = get_workdir_with_workdir_data(workdir_data, anatomy)
+ template_workdir_path = str(template_workdir).replace("\\", "/")
+
+ # Replace slashses in workdir path where workfile is located
+ mod_workdir = workdir.replace("\\", "/")
+
+ # Replace workdir from templates with rootless workdir
+ rootles_workdir = mod_workdir.replace(
+ template_workdir_path,
+ template_workdir.rootless.replace("\\", "/")
+ )
+
+ doc_data["schema"] = "pype:workfile-1.0"
+ doc_data["files"] = ["/".join([rootles_workdir, filename])]
+ doc_data["data"] = {}
+
+ dbcon.replace_one(
+ doc_filter,
+ doc_data,
+ upsert=True
+ )
+
+
+@with_avalon
+def save_workfile_data_to_doc(workfile_doc, data, dbcon=None):
+ if not workfile_doc:
+ # TODO add log message
+ return
+
+ if not data:
+ return
+
+ # Use avalon.io if dbcon is not entered
+ if not dbcon:
+ dbcon = avalon.io
+
+ # Convert data to mongo modification keys/values
+ # - this is naive implementation which does not expect nested
+ # dictionaries
+ set_data = {}
+ for key, value in data.items():
+ new_key = "data.{}".format(key)
+ set_data[new_key] = value
+
+ # Update workfile document with data
+ dbcon.update_one(
+ {"_id": workfile_doc["_id"]},
+ {"$set": set_data}
+ )
+
+
class BuildWorkfile:
"""Wrapper for build workfile process.
diff --git a/pype/tools/workfiles/README.md b/pype/tools/workfiles/README.md
new file mode 100644
index 0000000000..92ad4a8577
--- /dev/null
+++ b/pype/tools/workfiles/README.md
@@ -0,0 +1,143 @@
+# Workfiles App
+
+The Workfiles app facilitates easy saving, creation and launching of work files.
+
+The current supported hosts are:
+
+- Maya
+- Houdini
+- Fusion
+
+The app is available inside hosts via. the ```Avalon > Work Files``` menu.
+
+## Enabling Workfiles on launch
+
+By default the Workfiles app will not launch on startup, so it has to be explicitly enabled in a config.
+
+```python
+workfiles.show()
+```
+
+## Naming Files
+
+Workfiles app enables user to easily save and create new work files.
+
+The user is presented with a two parameters; ```version``` and ```comment```. The name of the work file is determined from a template.
+
+### ```Next Available Version```
+
+Will search for the next version number that is not in use.
+
+## Templates
+
+The default template for work files is ```{task[name]}_v{version:0>4}<_{comment}>```. Launching Maya on an animation task and creating a version 1 will result in ```animation_v0001.ma```. Adding "blocking" to the optional comment input will result in ```animation_v0001_blocking.ma```.
+
+This template can be customized per project with the ```workfile``` template.
+
+There are other variables to customize the template with:
+
+```python
+{
+ "project": project, # The project data from the database.
+ "asset": asset, # The asset data from the database.
+ "task": {
+ "label": label, # Label of task chosen.
+ "name": name # Sanitize version of the label.
+ },
+ "user": user, # Name of the user on the machine.
+ "version": version, # Chosen version of the user.
+ "comment": comment, # Chosen comment of the user.
+}
+```
+
+### Optional template groups
+
+The default template contains an optional template group ```<_{comment}>```. If any template group (```{comment}```) within angle bracket ```<>``` does not exist, the whole optional group is discarded.
+
+
+## Implementing a new host integration for Work Files
+
+For the Work Files tool to work with a new host integration the host must
+implement the following functions:
+
+- `file_extensions()`: The files the host should allow to open and show in the Work Files view.
+- `open_file(filepath)`: Open a file.
+- `save_file(filepath)`: Save the current file. This should return None if it failed to save, and return the path if it succeeded
+- `has_unsaved_changes()`: Return whether the current scene has unsaved changes.
+- `current_file()`: The path to the current file. None if not saved.
+- `work_root()`: The path to where the work files for this app should be saved.
+
+Here's an example code layout:
+
+```python
+def file_extensions():
+ """Return the filename extension formats that should be shown.
+
+ Note:
+ The first entry in the list will be used as the default file
+ format to save to when the current scene is not saved yet.
+
+ Returns:
+ list: A list of the file extensions supported by Work Files.
+
+ """
+ return list()
+
+
+def has_unsaved_changes():
+ """Return whether current file has unsaved modifications."""
+
+
+def save_file(filepath):
+ """Save to filepath.
+
+ This should return None if it failed to save, and return the path if it
+ succeeded.
+ """
+ pass
+
+
+def open_file(filepath):
+ """Open file"""
+ pass
+
+
+def current_file():
+ """Return path to currently open file or None if not saved.
+
+ Returns:
+ str or None: The full path to current file or None when not saved.
+
+ """
+ pass
+
+
+def work_root():
+ """Return the default root for the Host to browse in for Work Files
+
+ Returns:
+ str: The path to look in.
+
+ """
+ pass
+```
+
+#### Work Files Scenes root (AVALON_SCENEDIR)
+
+Whenever the host application has no built-in implementation that defines
+where scene files should be saved to then the Work Files API for that host
+should fall back to the `AVALON_SCENEDIR` variable in `api.Session`.
+
+When `AVALON_SCENEDIR` is set the directory is the relative folder inside the
+`AVALON_WORKDIR`. Otherwise, when it is not set or empty it should fall back
+to the Work Directory's root, `AVALON_WORKDIR`
+
+```python
+AVALON_WORKDIR="/path/to/work"
+AVALON_SCENEDIR="scenes"
+# Result: /path/to/work/scenes
+
+AVALON_WORKDIR="/path/to/work"
+AVALON_SCENEDIR=None
+# Result: /path/to/work
+```
\ No newline at end of file
diff --git a/pype/tools/workfiles/__init__.py b/pype/tools/workfiles/__init__.py
new file mode 100644
index 0000000000..cde7293931
--- /dev/null
+++ b/pype/tools/workfiles/__init__.py
@@ -0,0 +1,9 @@
+from .app import (
+ show,
+ Window
+)
+
+__all__ = [
+ "show",
+ "Window"
+]
diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py
new file mode 100644
index 0000000000..e6b211152a
--- /dev/null
+++ b/pype/tools/workfiles/app.py
@@ -0,0 +1,1166 @@
+import sys
+import os
+import copy
+import getpass
+import shutil
+import logging
+import datetime
+
+import Qt
+from Qt import QtWidgets, QtCore
+from avalon import style, io, api, pipeline
+
+from avalon.tools import lib as tools_lib
+from avalon.tools.widgets import AssetWidget
+from avalon.tools.models import TasksModel
+from avalon.tools.delegates import PrettyTimeDelegate
+
+from .model import FilesModel
+from .view import FilesView
+
+from pype.lib import (
+ Anatomy,
+ get_workdir,
+ get_workfile_doc,
+ create_workfile_doc,
+ save_workfile_data_to_doc
+)
+
+log = logging.getLogger(__name__)
+
+module = sys.modules[__name__]
+module.window = None
+
+
+class NameWindow(QtWidgets.QDialog):
+ """Name Window to define a unique filename inside a root folder
+
+ The filename will be based on the "workfile" template defined in the
+ project["config"]["template"].
+
+ """
+
+ def __init__(self, parent, root, anatomy, template_key, session=None):
+ super(NameWindow, self).__init__(parent=parent)
+ self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
+
+ self.result = None
+ self.host = api.registered_host()
+ self.root = root
+ self.work_file = None
+
+ if not session:
+ # Fallback to active session
+ session = api.Session
+
+ # Set work file data for template formatting
+ asset_name = session["AVALON_ASSET"]
+ project_doc = io.find_one({
+ "type": "project"
+ })
+ self.data = {
+ "project": {
+ "name": project_doc["name"],
+ "code": project_doc["data"].get("code")
+ },
+ "asset": asset_name,
+ "task": session["AVALON_TASK"],
+ "version": 1,
+ "user": getpass.getuser(),
+ "comment": "",
+ "ext": None
+ }
+
+ # Store project anatomy
+ self.anatomy = anatomy
+ self.template = anatomy.templates[template_key]["file"]
+ self.template_key = template_key
+
+ # Btns widget
+ btns_widget = QtWidgets.QWidget(self)
+
+ btn_ok = QtWidgets.QPushButton("Ok", btns_widget)
+ btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget)
+
+ btns_layout = QtWidgets.QHBoxLayout(btns_widget)
+ btns_layout.addWidget(btn_ok)
+ btns_layout.addWidget(btn_cancel)
+
+ # Inputs widget
+ inputs_widget = QtWidgets.QWidget(self)
+
+ # Version widget
+ version_widget = QtWidgets.QWidget(inputs_widget)
+
+ # Version number input
+ version_input = QtWidgets.QSpinBox(version_widget)
+ version_input.setMinimum(1)
+ version_input.setMaximum(9999)
+
+ # Last version checkbox
+ last_version_check = QtWidgets.QCheckBox(
+ "Next Available Version", version_widget
+ )
+ last_version_check.setChecked(True)
+
+ version_layout = QtWidgets.QHBoxLayout(version_widget)
+ version_layout.setContentsMargins(0, 0, 0, 0)
+ version_layout.addWidget(version_input)
+ version_layout.addWidget(last_version_check)
+
+ # Preview widget
+ preview_label = QtWidgets.QLabel("Preview filename", inputs_widget)
+
+ # Subversion input
+ subversion_input = QtWidgets.QLineEdit(inputs_widget)
+ subversion_input.setPlaceholderText("Will be part of filename.")
+
+ # Extensions combobox
+ ext_combo = QtWidgets.QComboBox(inputs_widget)
+ ext_combo.addItems(self.host.file_extensions())
+
+ # Build inputs
+ inputs_layout = QtWidgets.QFormLayout(inputs_widget)
+ # Add version only if template contain version key
+ # - since the version can be padded with "{version:0>4}" we only search
+ # for "{version".
+ if "{version" in self.template:
+ inputs_layout.addRow("Version:", version_widget)
+
+ # Add subversion only if template containt `{comment}`
+ if "{comment}" in self.template:
+ inputs_layout.addRow("Subversion:", subversion_input)
+ inputs_layout.addRow("Extension:", ext_combo)
+ inputs_layout.addRow("Preview:", preview_label)
+
+ # Build layout
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.addWidget(inputs_widget)
+ main_layout.addWidget(btns_widget)
+
+ # Singal callback registration
+ version_input.valueChanged.connect(self.on_version_spinbox_changed)
+ last_version_check.stateChanged.connect(
+ self.on_version_checkbox_changed
+ )
+
+ subversion_input.textChanged.connect(self.on_comment_changed)
+ ext_combo.currentIndexChanged.connect(self.on_extension_changed)
+
+ btn_ok.pressed.connect(self.on_ok_pressed)
+ btn_cancel.pressed.connect(self.on_cancel_pressed)
+
+ # Allow "Enter" key to accept the save.
+ btn_ok.setDefault(True)
+
+ # Force default focus to comment, some hosts didn't automatically
+ # apply focus to this line edit (e.g. Houdini)
+ subversion_input.setFocus()
+
+ # Store widgets
+ self.btn_ok = btn_ok
+
+ self.version_widget = version_widget
+
+ self.version_input = version_input
+ self.last_version_check = last_version_check
+
+ self.preview_label = preview_label
+ self.subversion_input = subversion_input
+ self.ext_combo = ext_combo
+
+ self.refresh()
+
+ def on_version_spinbox_changed(self, value):
+ self.data["version"] = value
+ self.refresh()
+
+ def on_version_checkbox_changed(self, _value):
+ self.refresh()
+
+ def on_comment_changed(self, text):
+ self.data["comment"] = text
+ self.refresh()
+
+ def on_extension_changed(self):
+ ext = self.ext_combo.currentText()
+ if ext == self.data["ext"]:
+ return
+ self.data["ext"] = ext
+ self.refresh()
+
+ def on_ok_pressed(self):
+ self.result = self.work_file
+ self.close()
+
+ def on_cancel_pressed(self):
+ self.close()
+
+ def get_result(self):
+ return self.result
+
+ def get_work_file(self):
+ data = copy.deepcopy(self.data)
+ if not data["comment"]:
+ data.pop("comment", None)
+
+ data["ext"] = data["ext"][1:]
+
+ anatomy_filled = self.anatomy.format(data)
+ return anatomy_filled[self.template_key]["file"]
+
+ def refresh(self):
+ extensions = self.host.file_extensions()
+ extension = self.data["ext"]
+ if extension is None:
+ # Define saving file extension
+ current_file = self.host.current_file()
+ if current_file:
+ # Match the extension of current file
+ _, extension = os.path.splitext(current_file)
+ else:
+ extension = extensions[0]
+
+ if extension != self.data["ext"]:
+ self.data["ext"] = extension
+ index = self.ext_combo.findText(
+ extension, QtCore.Qt.MatchFixedString
+ )
+ if index >= 0:
+ self.ext_combo.setCurrentIndex(index)
+
+ if not self.last_version_check.isChecked():
+ self.version_input.setEnabled(True)
+ self.data["version"] = self.version_input.value()
+
+ work_file = self.get_work_file()
+
+ else:
+ self.version_input.setEnabled(False)
+
+ data = copy.deepcopy(self.data)
+ template = str(self.template)
+
+ if not data["comment"]:
+ data.pop("comment", None)
+
+ data["ext"] = data["ext"][1:]
+
+ version = api.last_workfile_with_version(
+ self.root, template, data, extensions
+ )[1]
+
+ if version is None:
+ version = 1
+ else:
+ version += 1
+
+ found_valid_version = False
+ # Check if next version is valid version and give a chance to try
+ # next 100 versions
+ for idx in range(100):
+ # Store version to data
+ self.data["version"] = version
+
+ work_file = self.get_work_file()
+ # Safety check
+ path = os.path.join(self.root, work_file)
+ if not os.path.exists(path):
+ found_valid_version = True
+ break
+
+ # Try next version
+ version += 1
+ # Log warning
+ if idx == 0:
+ log.warning((
+ "BUG: Function `last_workfile_with_version` "
+ "didn't return last version."
+ ))
+ # Raise exception if even 100 version fallback didn't help
+ if not found_valid_version:
+ raise AssertionError(
+ "This is a bug. Couldn't find valid version!"
+ )
+
+ self.work_file = work_file
+
+ path_exists = os.path.exists(os.path.join(self.root, work_file))
+
+ self.btn_ok.setEnabled(not path_exists)
+
+ if path_exists:
+ self.preview_label.setText(
+ "Cannot create \"{0}\" because file exists!"
+ "".format(work_file)
+ )
+ else:
+ self.preview_label.setText(
+ "{0}".format(work_file)
+ )
+
+
+class TasksWidget(QtWidgets.QWidget):
+ """Widget showing active Tasks"""
+
+ task_changed = QtCore.Signal()
+
+ def __init__(self, parent=None):
+ super(TasksWidget, self).__init__(parent)
+ self.setContentsMargins(0, 0, 0, 0)
+
+ view = QtWidgets.QTreeView()
+ view.setIndentation(0)
+ model = TasksModel(io)
+ view.setModel(model)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(view)
+
+ # Hide the default tasks "count" as we don't need that data here.
+ view.setColumnHidden(1, True)
+
+ selection = view.selectionModel()
+ selection.currentChanged.connect(self.task_changed)
+
+ self.models = {
+ "tasks": model
+ }
+
+ self.widgets = {
+ "view": view,
+ }
+
+ self._last_selected_task = None
+
+ def set_asset(self, asset):
+ if asset is None:
+ # Asset deselected
+ return
+
+ # Try and preserve the last selected task and reselect it
+ # after switching assets. If there's no currently selected
+ # asset keep whatever the "last selected" was prior to it.
+ current = self.get_current_task()
+ if current:
+ self._last_selected_task = current
+
+ self.models["tasks"].set_assets(asset_docs=[asset])
+
+ if self._last_selected_task:
+ self.select_task(self._last_selected_task)
+
+ # Force a task changed emit.
+ self.task_changed.emit()
+
+ def select_task(self, task):
+ """Select a task by name.
+
+ If the task does not exist in the current model then selection is only
+ cleared.
+
+ Args:
+ task (str): Name of the task to select.
+
+ """
+
+ # Clear selection
+ view = self.widgets["view"]
+ model = view.model()
+ selection_model = view.selectionModel()
+ selection_model.clearSelection()
+
+ # Select the task
+ mode = selection_model.Select | selection_model.Rows
+ for row in range(model.rowCount(QtCore.QModelIndex())):
+ index = model.index(row, 0, QtCore.QModelIndex())
+ name = index.data(QtCore.Qt.DisplayRole)
+ if name == task:
+ selection_model.select(index, mode)
+
+ # Set the currently active index
+ view.setCurrentIndex(index)
+
+ def get_current_task(self):
+ """Return name of task at current index (selected)
+
+ Returns:
+ str: Name of the current task.
+
+ """
+ view = self.widgets["view"]
+ index = view.currentIndex()
+ index = index.sibling(index.row(), 0) # ensure column zero for name
+
+ selection = view.selectionModel()
+ if selection.isSelected(index):
+ # Ignore when the current task is not selected as the "No task"
+ # placeholder might be the current index even though it's
+ # disallowed to be selected. So we only return if it is selected.
+ return index.data(QtCore.Qt.DisplayRole)
+
+
+class FilesWidget(QtWidgets.QWidget):
+ """A widget displaying files that allows to save and open files."""
+ file_selected = QtCore.Signal(str)
+ workfile_created = QtCore.Signal(str)
+
+ def __init__(self, parent=None):
+ super(FilesWidget, self).__init__(parent=parent)
+
+ # Setup
+ self._asset = None
+ self._task = None
+
+ # Pype's anatomy object for current project
+ self.anatomy = Anatomy(io.Session["AVALON_PROJECT"])
+ # Template key used to get work template from anatomy templates
+ # TODO change template key based on task
+ self.template_key = "work"
+
+ # This is not root but workfile directory
+ self.root = None
+ self.host = api.registered_host()
+
+ # Whether to automatically select the latest modified
+ # file on a refresh of the files model.
+ self.auto_select_latest_modified = True
+
+ # Avoid crash in Blender and store the message box
+ # (setting parent doesn't work as it hides the message box)
+ self._messagebox = None
+
+ files_view = FilesView(self)
+
+ # Create the Files model
+ extensions = set(self.host.file_extensions())
+ files_model = FilesModel(file_extensions=extensions)
+
+ # Create proxy model for files to be able sort and filter
+ proxy_model = QtCore.QSortFilterProxyModel()
+ proxy_model.setSourceModel(files_model)
+ proxy_model.setDynamicSortFilter(True)
+ proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ # Set up the file list tree view
+ files_view.setModel(proxy_model)
+ files_view.setSortingEnabled(True)
+ files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+
+ # Date modified delegate
+ time_delegate = PrettyTimeDelegate()
+ files_view.setItemDelegateForColumn(1, time_delegate)
+ files_view.setIndentation(3) # smaller indentation
+
+ # Default to a wider first filename column it is what we mostly care
+ # about and the date modified is relatively small anyway.
+ files_view.setColumnWidth(0, 330)
+
+ # Filtering input
+ filter_input = QtWidgets.QLineEdit(self)
+ filter_input.textChanged.connect(proxy_model.setFilterFixedString)
+ filter_input.setPlaceholderText("Filter files..")
+
+ # Home Page
+ # Build buttons widget for files widget
+ btns_widget = QtWidgets.QWidget(self)
+ btn_save = QtWidgets.QPushButton("Save As", btns_widget)
+ btn_browse = QtWidgets.QPushButton("Browse", btns_widget)
+ btn_open = QtWidgets.QPushButton("Open", btns_widget)
+
+ btns_layout = QtWidgets.QHBoxLayout(btns_widget)
+ btns_layout.setContentsMargins(0, 0, 0, 0)
+ btns_layout.addWidget(btn_open)
+ btns_layout.addWidget(btn_browse)
+ btns_layout.addWidget(btn_save)
+
+ # Build files widgets for home page
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(filter_input)
+ main_layout.addWidget(files_view)
+ main_layout.addWidget(btns_widget)
+
+ # Register signal callbacks
+ files_view.doubleClickedLeft.connect(self.on_open_pressed)
+ files_view.customContextMenuRequested.connect(self.on_context_menu)
+ files_view.selectionModel().selectionChanged.connect(
+ self.on_file_select
+ )
+
+ btn_open.pressed.connect(self.on_open_pressed)
+ btn_browse.pressed.connect(self.on_browse_pressed)
+ btn_save.pressed.connect(self.on_save_as_pressed)
+
+ # Store attributes
+ self.time_delegate = time_delegate
+
+ self.filter_input = filter_input
+
+ self.files_view = files_view
+ self.files_model = files_model
+
+ self.btns_widget = btns_widget
+ self.btn_open = btn_open
+ self.btn_browse = btn_browse
+ self.btn_save = btn_save
+
+ def set_asset_task(self, asset, task):
+ self._asset = asset
+ self._task = task
+
+ # Define a custom session so we can query the work root
+ # for a "Work area" that is not our current Session.
+ # This way we can browse it even before we enter it.
+ if self._asset and self._task:
+ session = self._get_session()
+ self.root = self.host.work_root(session)
+ self.files_model.set_root(self.root)
+
+ else:
+ self.files_model.set_root(None)
+
+ # Disable/Enable buttons based on available files in model
+ has_filenames = self.files_model.has_filenames()
+ self.btn_browse.setEnabled(has_filenames)
+ self.btn_open.setEnabled(has_filenames)
+ if not has_filenames:
+ # Manually trigger file selection
+ self.on_file_select()
+
+ def _get_session(self):
+ """Return a modified session for the current asset and task"""
+
+ session = api.Session.copy()
+ changes = pipeline.compute_session_changes(
+ session,
+ asset=self._asset,
+ task=self._task
+ )
+ session.update(changes)
+
+ return session
+
+ def _enter_session(self):
+ """Enter the asset and task session currently selected"""
+
+ session = api.Session.copy()
+ changes = pipeline.compute_session_changes(
+ session,
+ asset=self._asset,
+ task=self._task
+ )
+ if not changes:
+ # Return early if we're already in the right Session context
+ # to avoid any unwanted Task Changed callbacks to be triggered.
+ return
+
+ api.update_current_task(asset=self._asset, task=self._task)
+
+ def open_file(self, filepath):
+ host = self.host
+ if host.has_unsaved_changes():
+ result = self.save_changes_prompt()
+ if result is None:
+ # Cancel operation
+ return False
+
+ # Save first if has changes
+ if result:
+ current_file = host.current_file()
+ if not current_file:
+ # If the user requested to save the current scene
+ # we can't actually automatically do so if the current
+ # file has not been saved with a name yet. So we'll have
+ # to opt out.
+ log.error("Can't save scene with no filename. Please "
+ "first save your work file using 'Save As'.")
+ return
+
+ # Save current scene, continue to open file
+ host.save_file(current_file)
+
+ self._enter_session()
+ host.open_file(filepath)
+ self.window().close()
+
+ def save_changes_prompt(self):
+ self._messagebox = messagebox = QtWidgets.QMessageBox()
+
+ messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint)
+ messagebox.setIcon(messagebox.Warning)
+ messagebox.setWindowTitle("Unsaved Changes!")
+ messagebox.setText(
+ "There are unsaved changes to the current file."
+ "\nDo you want to save the changes?"
+ )
+ messagebox.setStandardButtons(
+ messagebox.Yes | messagebox.No | messagebox.Cancel
+ )
+
+ # Parenting the QMessageBox to the Widget seems to crash
+ # so we skip parenting and explicitly apply the stylesheet.
+ messagebox.setStyleSheet(style.load_stylesheet())
+
+ result = messagebox.exec_()
+ if result == messagebox.Yes:
+ return True
+ elif result == messagebox.No:
+ return False
+ return None
+
+ def get_filename(self):
+ """Show save dialog to define filename for save or duplicate
+
+ Returns:
+ str: The filename to create.
+
+ """
+ session = self._get_session()
+
+ window = NameWindow(
+ parent=self,
+ root=self.root,
+ anatomy=self.anatomy,
+ template_key=self.template_key,
+ session=session
+ )
+ window.exec_()
+
+ return window.get_result()
+
+ def on_duplicate_pressed(self):
+ work_file = self.get_filename()
+ if not work_file:
+ return
+
+ src = self._get_selected_filepath()
+ dst = os.path.join(self.root, work_file)
+ shutil.copy(src, dst)
+
+ self.workfile_created.emit(dst)
+
+ self.refresh()
+
+ def _get_selected_filepath(self):
+ """Return current filepath selected in view"""
+ selection = self.files_view.selectionModel()
+ index = selection.currentIndex()
+ if not index.isValid():
+ return
+
+ return index.data(self.files_model.FilePathRole)
+
+ def on_open_pressed(self):
+ path = self._get_selected_filepath()
+ if not path:
+ print("No file selected to open..")
+ return
+
+ self.open_file(path)
+
+ def on_browse_pressed(self):
+ ext_filter = "Work File (*{0})".format(
+ " *".join(self.host.file_extensions())
+ )
+ kwargs = {
+ "caption": "Work Files",
+ "filter": ext_filter
+ }
+ if Qt.__binding__ in ("PySide", "PySide2"):
+ kwargs["dir"] = self.root
+ else:
+ kwargs["directory"] = self.root
+
+ work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0]
+ if work_file:
+ self.open_file(work_file)
+
+ def on_save_as_pressed(self):
+ work_file = self.get_filename()
+ if not work_file:
+ return
+
+ # Initialize work directory if it has not been initialized before
+ if not os.path.exists(self.root):
+ log.debug("Initializing Work Directory: %s", self.root)
+ self.initialize_work_directory()
+ if not os.path.exists(self.root):
+ # Failed to initialize Work Directory
+ log.error(
+ "Failed to initialize Work Directory: {}".format(self.root)
+ )
+ return
+
+ file_path = os.path.join(self.root, work_file)
+
+ self._enter_session() # Make sure we are in the right session
+ self.host.save_file(file_path)
+
+ self.set_asset_task(self._asset, self._task)
+
+ self.workfile_created.emit(file_path)
+
+ self.refresh()
+
+ def on_file_select(self):
+ self.file_selected.emit(self._get_selected_filepath())
+
+ def initialize_work_directory(self):
+ """Initialize Work Directory.
+
+ This is used when the Work Directory does not exist yet.
+
+ This finds the current AVALON_APP_NAME and tries to triggers its
+ `.toml` initialization step. Note that this will only be valid
+ whenever `AVALON_APP_NAME` is actually set in the current session.
+
+ """
+
+ # Inputs (from the switched session and running app)
+ session = api.Session.copy()
+ changes = pipeline.compute_session_changes(
+ session,
+ asset=self._asset,
+ task=self._task
+ )
+ session.update(changes)
+
+ # Prepare documents to get workdir data
+ project_doc = io.find_one({"type": "project"})
+ asset_doc = io.find_one(
+ {
+ "type": "asset",
+ "name": session["AVALON_ASSET"]
+ }
+ )
+ task_name = session["AVALON_TASK"]
+ host_name = session["AVALON_APP"]
+
+ # Get workdir from collected documents
+ workdir = get_workdir(project_doc, asset_doc, task_name, host_name)
+ # Create workdir if does not exist yet
+ if not os.path.exists(workdir):
+ os.makedirs(workdir)
+
+ # Force a full to the asset as opposed to just self.refresh() so
+ # that it will actually check again whether the Work directory exists
+ self.set_asset_task(self._asset, self._task)
+
+ def refresh(self):
+ """Refresh listed files for current selection in the interface"""
+ self.files_model.refresh()
+
+ if self.auto_select_latest_modified:
+ tools_lib.schedule(self._select_last_modified_file, 100)
+
+ def on_context_menu(self, point):
+ index = self.files_view.indexAt(point)
+ if not index.isValid():
+ return
+
+ is_enabled = index.data(FilesModel.IsEnabled)
+ if not is_enabled:
+ return
+
+ menu = QtWidgets.QMenu(self)
+
+ # Duplicate
+ action = QtWidgets.QAction("Duplicate", menu)
+ tip = "Duplicate selected file."
+ action.setToolTip(tip)
+ action.setStatusTip(tip)
+ action.triggered.connect(self.on_duplicate_pressed)
+ menu.addAction(action)
+
+ # Show the context action menu
+ global_point = self.files_view.mapToGlobal(point)
+ action = menu.exec_(global_point)
+ if not action:
+ return
+
+ def _select_last_modified_file(self):
+ """Utility function to select the file with latest date modified"""
+ role = self.files_model.DateModifiedRole
+ model = self.files_view.model()
+
+ highest_index = None
+ highest = 0
+ for row in range(model.rowCount()):
+ index = model.index(row, 0, parent=QtCore.QModelIndex())
+ if not index.isValid():
+ continue
+
+ modified = index.data(role)
+ if modified is not None and modified > highest:
+ highest_index = index
+ highest = modified
+
+ if highest_index:
+ self.files_view.setCurrentIndex(highest_index)
+
+
+class SidePanelWidget(QtWidgets.QWidget):
+ save_clicked = QtCore.Signal()
+
+ def __init__(self, parent=None):
+ super(SidePanelWidget, self).__init__(parent)
+
+ details_label = QtWidgets.QLabel("Details", self)
+ details_input = QtWidgets.QPlainTextEdit(self)
+ details_input.setReadOnly(True)
+
+ note_label = QtWidgets.QLabel("Artist note", self)
+ note_input = QtWidgets.QPlainTextEdit(self)
+ btn_note_save = QtWidgets.QPushButton("Save note", self)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(details_label, 0)
+ main_layout.addWidget(details_input, 0)
+ main_layout.addWidget(note_label, 0)
+ main_layout.addWidget(note_input, 1)
+ main_layout.addWidget(btn_note_save, alignment=QtCore.Qt.AlignRight)
+
+ note_input.textChanged.connect(self.on_note_change)
+ btn_note_save.clicked.connect(self.on_save_click)
+
+ self.details_input = details_input
+ self.note_input = note_input
+ self.btn_note_save = btn_note_save
+
+ self._orig_note = ""
+ self._workfile_doc = None
+
+ def on_note_change(self):
+ text = self.note_input.toPlainText()
+ self.btn_note_save.setEnabled(self._orig_note != text)
+
+ def on_save_click(self):
+ self._orig_note = self.note_input.toPlainText()
+ self.on_note_change()
+ self.save_clicked.emit()
+
+ def set_context(self, asset_doc, task_name, filepath, workfile_doc):
+ # Check if asset, task and file are selected
+ # NOTE workfile document is not requirement
+ enabled = bool(asset_doc) and bool(task_name) and bool(filepath)
+
+ self.details_input.setEnabled(enabled)
+ self.note_input.setEnabled(enabled)
+ self.btn_note_save.setEnabled(enabled)
+
+ # Make sure workfile doc is overriden
+ self._workfile_doc = workfile_doc
+ # Disable inputs and remove texts if any required arguments are missing
+ if not enabled:
+ self._orig_note = ""
+ self.details_input.setPlainText("")
+ self.note_input.setPlainText("")
+ return
+
+ orig_note = ""
+ if workfile_doc:
+ orig_note = workfile_doc["data"].get("note") or orig_note
+
+ self._orig_note = orig_note
+ self.note_input.setPlainText(orig_note)
+ # Set as empty string
+ self.details_input.setPlainText("")
+
+ filestat = os.stat(filepath)
+ size_ending_mapping = {
+ "KB": 1024 ** 1,
+ "MB": 1024 ** 2,
+ "GB": 1024 ** 3
+ }
+ size = filestat.st_size
+ ending = "B"
+ for _ending, _size in size_ending_mapping.items():
+ if filestat.st_size < _size:
+ break
+ size = filestat.st_size / _size
+ ending = _ending
+
+ # Append html string
+ datetime_format = "%b %d %Y %H:%M:%S"
+ creation_time = datetime.datetime.fromtimestamp(filestat.st_ctime)
+ modification_time = datetime.datetime.fromtimestamp(filestat.st_mtime)
+ lines = (
+ "Size:",
+ "{:.2f} {}".format(size, ending),
+ "Created:",
+ creation_time.strftime(datetime_format),
+ "Modified:",
+ modification_time.strftime(datetime_format)
+ )
+ self.details_input.appendHtml("
".join(lines))
+
+ def get_workfile_data(self):
+ data = {
+ "note": self.note_input.toPlainText()
+ }
+ return self._workfile_doc, data
+
+
+class Window(QtWidgets.QMainWindow):
+ """Work Files Window"""
+ title = "Work Files"
+
+ def __init__(self, parent=None):
+ super(Window, self).__init__(parent=parent)
+ self.setWindowTitle(self.title)
+ self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint)
+
+ # Create pages widget and set it as central widget
+ pages_widget = QtWidgets.QStackedWidget(self)
+ self.setCentralWidget(pages_widget)
+
+ home_page_widget = QtWidgets.QWidget(pages_widget)
+ home_body_widget = QtWidgets.QWidget(home_page_widget)
+
+ assets_widget = AssetWidget(io, parent=home_body_widget)
+ tasks_widget = TasksWidget(home_body_widget)
+ files_widget = FilesWidget(home_body_widget)
+ side_panel = SidePanelWidget(home_body_widget)
+
+ pages_widget.addWidget(home_page_widget)
+
+ # Build home
+ home_page_layout = QtWidgets.QVBoxLayout(home_page_widget)
+ home_page_layout.addWidget(home_body_widget)
+
+ # Build home - body
+ body_layout = QtWidgets.QVBoxLayout(home_body_widget)
+ split_widget = QtWidgets.QSplitter(home_body_widget)
+ split_widget.addWidget(assets_widget)
+ split_widget.addWidget(tasks_widget)
+ split_widget.addWidget(files_widget)
+ split_widget.addWidget(side_panel)
+ split_widget.setStretchFactor(0, 1)
+ split_widget.setStretchFactor(1, 1)
+ split_widget.setStretchFactor(2, 3)
+ split_widget.setStretchFactor(3, 1)
+ body_layout.addWidget(split_widget)
+
+ # Add top margin for tasks to align it visually with files as
+ # the files widget has a filter field which tasks does not.
+ tasks_widget.setContentsMargins(0, 32, 0, 0)
+
+ # Connect signals
+ assets_widget.current_changed.connect(self.on_asset_changed)
+ tasks_widget.task_changed.connect(self.on_task_changed)
+ files_widget.file_selected.connect(self.on_file_select)
+ files_widget.workfile_created.connect(self.on_workfile_create)
+ side_panel.save_clicked.connect(self.on_side_panel_save)
+
+ self.home_page_widget = home_page_widget
+ self.pages_widget = pages_widget
+ self.home_body_widget = home_body_widget
+ self.split_widget = split_widget
+
+ self.assets_widget = assets_widget
+ self.tasks_widget = tasks_widget
+ self.files_widget = files_widget
+ self.side_panel = side_panel
+
+ self.refresh()
+
+ # Force focus on the open button by default, required for Houdini.
+ files_widget.btn_open.setFocus()
+
+ self.resize(1000, 600)
+
+ def keyPressEvent(self, event):
+ """Custom keyPressEvent.
+
+ Override keyPressEvent to do nothing so that Maya's panels won't
+ take focus when pressing "SHIFT" whilst mouse is over viewport or
+ outliner. This way users don't accidently perform Maya commands
+ whilst trying to name an instance.
+
+ """
+
+ def on_task_changed(self):
+ # Since we query the disk give it slightly more delay
+ tools_lib.schedule(self._on_task_changed, 100, channel="mongo")
+
+ def on_asset_changed(self):
+ tools_lib.schedule(self._on_asset_changed, 50, channel="mongo")
+
+ def on_file_select(self, filepath):
+ asset_docs = self.assets_widget.get_selected_assets()
+ asset_doc = None
+ if asset_docs:
+ asset_doc = asset_docs[0]
+
+ task_name = self.tasks_widget.get_current_task()
+
+ workfile_doc = None
+ if asset_doc and task_name and filepath:
+ filename = os.path.split(filepath)[1]
+ workfile_doc = get_workfile_doc(
+ asset_doc["_id"], task_name, filename, io
+ )
+ self.side_panel.set_context(
+ asset_doc, task_name, filepath, workfile_doc
+ )
+
+ def on_workfile_create(self, filepath):
+ self._create_workfile_doc(filepath)
+
+ def on_side_panel_save(self):
+ workfile_doc, data = self.side_panel.get_workfile_data()
+ if not workfile_doc:
+ filepath = self.files_widget._get_selected_filepath()
+ self._create_workfile_doc(filepath, force=True)
+ workfile_doc = self._get_current_workfile_doc()
+
+ save_workfile_data_to_doc(workfile_doc, data, io)
+
+ def _get_current_workfile_doc(self, filepath=None):
+ if filepath is None:
+ filepath = self.files_widget._get_selected_filepath()
+ task_name = self.tasks_widget.get_current_task()
+ asset_docs = self.assets_widget.get_selected_assets()
+ if not task_name or not asset_docs or not filepath:
+ return
+
+ asset_doc = asset_docs[0]
+ filename = os.path.split(filepath)[1]
+ return get_workfile_doc(
+ asset_doc["_id"], task_name, filename, io
+ )
+
+ def _create_workfile_doc(self, filepath, force=False):
+ workfile_doc = None
+ if not force:
+ workfile_doc = self._get_current_workfile_doc(filepath)
+
+ if not workfile_doc:
+ workdir, filename = os.path.split(filepath)
+ asset_docs = self.assets_widget.get_selected_assets()
+ asset_doc = asset_docs[0]
+ task_name = self.tasks_widget.get_current_task()
+ create_workfile_doc(asset_doc, task_name, filename, workdir, io)
+
+ def set_context(self, context):
+ if "asset" in context:
+ asset = context["asset"]
+ asset_document = io.find_one(
+ {
+ "name": asset,
+ "type": "asset"
+ },
+ {
+ "data.tasks": 1
+ }
+ )
+
+ # Select the asset
+ self.assets_widget.select_assets([asset], expand=True)
+
+ # Force a refresh on Tasks?
+ self.tasks_widget.set_asset(asset_document)
+
+ if "task" in context:
+ self.tasks_widget.select_task(context["task"])
+
+ def refresh(self):
+ # Refresh asset widget
+ self.assets_widget.refresh()
+
+ self._on_task_changed()
+
+ def _on_asset_changed(self):
+ asset = self.assets_widget.get_selected_assets() or None
+
+ if not asset:
+ # Force disable the other widgets if no
+ # active selection
+ self.tasks_widget.setEnabled(False)
+ self.files_widget.setEnabled(False)
+ else:
+ asset = asset[0]
+ self.tasks_widget.setEnabled(True)
+
+ self.tasks_widget.set_asset(asset)
+
+ def _on_task_changed(self):
+ asset = self.assets_widget.get_selected_assets() or None
+ if asset is not None:
+ asset = asset[0]
+ task = self.tasks_widget.get_current_task()
+
+ self.tasks_widget.setEnabled(bool(asset))
+
+ self.files_widget.setEnabled(all([bool(task), bool(asset)]))
+ self.files_widget.set_asset_task(asset, task)
+ self.files_widget.refresh()
+
+
+def validate_host_requirements(host):
+ if host is None:
+ raise RuntimeError("No registered host.")
+
+ # Verify the host has implemented the api for Work Files
+ required = [
+ "open_file",
+ "save_file",
+ "current_file",
+ "has_unsaved_changes",
+ "work_root",
+ "file_extensions",
+ ]
+ missing = []
+ for name in required:
+ if not hasattr(host, name):
+ missing.append(name)
+ if missing:
+ raise RuntimeError(
+ "Host is missing required Work Files interfaces: "
+ "%s (host: %s)" % (", ".join(missing), host)
+ )
+ return True
+
+
+def show(root=None, debug=False, parent=None, use_context=True, save=True):
+ """Show Work Files GUI"""
+ # todo: remove `root` argument to show()
+
+ try:
+ module.window.close()
+ del(module.window)
+ except (AttributeError, RuntimeError):
+ pass
+
+ host = api.registered_host()
+ validate_host_requirements(host)
+
+ if debug:
+ api.Session["AVALON_ASSET"] = "Mock"
+ api.Session["AVALON_TASK"] = "Testing"
+
+ with tools_lib.application():
+ window = Window(parent=parent)
+ window.refresh()
+
+ if use_context:
+ context = {
+ "asset": api.Session["AVALON_ASSET"],
+ "silo": api.Session["AVALON_SILO"],
+ "task": api.Session["AVALON_TASK"]
+ }
+ window.set_context(context)
+
+ window.files_widget.btn_save.setEnabled(save)
+
+ window.show()
+ window.setStyleSheet(style.load_stylesheet())
+
+ module.window = window
+
+ # Pull window to the front.
+ module.window.raise_()
+ module.window.activateWindow()
diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py
new file mode 100644
index 0000000000..368988fd4e
--- /dev/null
+++ b/pype/tools/workfiles/model.py
@@ -0,0 +1,153 @@
+import os
+import logging
+
+from Qt import QtCore
+
+from avalon import style
+from avalon.vendor import qtawesome
+from avalon.tools.models import TreeModel, Item
+
+log = logging.getLogger(__name__)
+
+
+class FilesModel(TreeModel):
+ """Model listing files with specified extensions in a root folder"""
+ Columns = ["filename", "date"]
+
+ FileNameRole = QtCore.Qt.UserRole + 2
+ DateModifiedRole = QtCore.Qt.UserRole + 3
+ FilePathRole = QtCore.Qt.UserRole + 4
+ IsEnabled = QtCore.Qt.UserRole + 5
+
+ def __init__(self, file_extensions, parent=None):
+ super(FilesModel, self).__init__(parent=parent)
+
+ self._root = None
+ self._file_extensions = file_extensions
+ self._icons = {
+ "file": qtawesome.icon("fa.file-o", color=style.colors.default)
+ }
+
+ def set_root(self, root):
+ self._root = root
+ self.refresh()
+
+ def _add_empty(self):
+ item = Item()
+ item.update({
+ # Put a display message in 'filename'
+ "filename": "No files found.",
+ # Not-selectable
+ "enabled": False,
+ "date": None,
+ "filepath": None
+ })
+
+ self.add_child(item)
+
+ def refresh(self):
+ self.clear()
+ self.beginResetModel()
+
+ root = self._root
+
+ if not root:
+ self.endResetModel()
+ return
+
+ if not os.path.exists(root):
+ # Add Work Area does not exist placeholder
+ log.debug("Work Area does not exist: %s", root)
+ message = "Work Area does not exist. Use Save As to create it."
+ item = Item({
+ "filename": message,
+ "date": None,
+ "filepath": None,
+ "enabled": False,
+ "icon": qtawesome.icon("fa.times", color=style.colors.mid)
+ })
+ self.add_child(item)
+ self.endResetModel()
+ return
+
+ extensions = self._file_extensions
+
+ for filename in os.listdir(root):
+ path = os.path.join(root, filename)
+ if os.path.isdir(path):
+ continue
+
+ ext = os.path.splitext(filename)[1]
+ if extensions and ext not in extensions:
+ continue
+
+ modified = os.path.getmtime(path)
+
+ item = Item({
+ "filename": filename,
+ "date": modified,
+ "filepath": path
+ })
+
+ self.add_child(item)
+
+ if self.rowCount() == 0:
+ self._add_empty()
+
+ self.endResetModel()
+
+ def has_filenames(self):
+ for item in self._root_item.children():
+ if item.get("enabled", True):
+ return True
+ return False
+
+ def rowCount(self, parent=None):
+ if parent is None or not parent.isValid():
+ parent_item = self._root_item
+ else:
+ parent_item = parent.internalPointer()
+ return parent_item.childCount()
+
+ def data(self, index, role):
+ if not index.isValid():
+ return
+
+ if role == QtCore.Qt.DecorationRole:
+ # Add icon to filename column
+ item = index.internalPointer()
+ if index.column() == 0:
+ if item["filepath"]:
+ return self._icons["file"]
+ return item.get("icon", None)
+
+ if role == self.FileNameRole:
+ item = index.internalPointer()
+ return item["filename"]
+
+ if role == self.DateModifiedRole:
+ item = index.internalPointer()
+ return item["date"]
+
+ if role == self.FilePathRole:
+ item = index.internalPointer()
+ return item["filepath"]
+
+ if role == self.IsEnabled:
+ item = index.internalPointer()
+ return item.get("enabled", True)
+
+ return super(FilesModel, self).data(index, role)
+
+ def headerData(self, section, orientation, role):
+ # Show nice labels in the header
+ if (
+ role == QtCore.Qt.DisplayRole
+ and orientation == QtCore.Qt.Horizontal
+ ):
+ if section == 0:
+ return "Name"
+ elif section == 1:
+ return "Date modified"
+
+ return super(FilesModel, self).headerData(section, orientation, role)
diff --git a/pype/tools/workfiles/view.py b/pype/tools/workfiles/view.py
new file mode 100644
index 0000000000..8e3993e4c7
--- /dev/null
+++ b/pype/tools/workfiles/view.py
@@ -0,0 +1,15 @@
+from Qt import QtWidgets, QtCore
+
+
+class FilesView(QtWidgets.QTreeView):
+ doubleClickedLeft = QtCore.Signal()
+ doubleClickedRight = QtCore.Signal()
+
+ def mouseDoubleClickEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self.doubleClickedLeft.emit()
+
+ elif event.button() == QtCore.Qt.RightButton:
+ self.doubleClickedRight.emit()
+
+ return super(FilesView, self).mouseDoubleClickEvent(event)
diff --git a/schema/workfile-1.0.json b/schema/workfile-1.0.json
new file mode 100644
index 0000000000..15bfdc6ff7
--- /dev/null
+++ b/schema/workfile-1.0.json
@@ -0,0 +1,52 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+
+ "title": "pype:workfile-1.0",
+ "description": "Workfile additional information.",
+
+ "type": "object",
+
+ "additionalProperties": true,
+
+ "required": [
+ "schema",
+ "type",
+ "filename",
+ "task_name",
+ "parent"
+ ],
+
+ "properties": {
+ "schema": {
+ "description": "Schema identifier for payload",
+ "type": "string",
+ "enum": ["pype:workfile-1.0"],
+ "example": "pype:workfile-1.0"
+ },
+ "type": {
+ "description": "The type of document",
+ "type": "string",
+ "enum": ["workfile"],
+ "example": "workfile"
+ },
+ "parent": {
+ "description": "Unique identifier to parent document",
+ "example": "592c33475f8c1b064c4d1696"
+ },
+ "filename": {
+ "description": "Workfile's filename",
+ "type": "string",
+ "example": "kuba_each_case_Alpaca_01_animation_v001.ma"
+ },
+ "task_name": {
+ "description": "Task name",
+ "type": "string",
+ "example": "animation"
+ },
+ "data": {
+ "description": "Document metadata",
+ "type": "object",
+ "example": {"key": "value"}
+ }
+ }
+}