mirror of
https://github.com/ynput/ayon-core.git
synced 2026-01-01 16:34:53 +01:00
Merge branch 'develop' into enhancement/OP-2855_move-plugins-register-and-discover
This commit is contained in:
commit
4b6ccc9ee3
69 changed files with 3629 additions and 1780 deletions
|
|
@ -4,11 +4,12 @@ from Qt import QtWidgets, QtCore
|
|||
import qtawesome
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
from avalon import io, pipeline
|
||||
from openpype.pipeline import (
|
||||
from avalon import io
|
||||
from openpype.pipeline.load import (
|
||||
discover_loader_plugins,
|
||||
switch_container,
|
||||
get_repres_contexts,
|
||||
loaders_from_repre_context,
|
||||
)
|
||||
|
||||
from .widgets import (
|
||||
|
|
@ -370,7 +371,7 @@ class SwitchAssetDialog(QtWidgets.QDialog):
|
|||
|
||||
loaders = None
|
||||
for repre_context in repre_contexts.values():
|
||||
_loaders = set(pipeline.loaders_from_repre_context(
|
||||
_loaders = set(loaders_from_repre_context(
|
||||
available_loaders, repre_context
|
||||
))
|
||||
if loaders is None:
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ class CollapsibleWrapper(WrapperWidget):
|
|||
self.content_layout = content_layout
|
||||
|
||||
if self.collapsible:
|
||||
body_widget.toggle_content(self.collapsed)
|
||||
if not self.collapsed:
|
||||
body_widget.toggle_content()
|
||||
else:
|
||||
body_widget.hide_toolbox(hide_content=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -287,9 +287,5 @@ class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
|
|||
"""
|
||||
|
||||
def displayText(self, value, locale):
|
||||
|
||||
if value is None:
|
||||
# Ignore None value
|
||||
return
|
||||
|
||||
return pretty_timestamp(value)
|
||||
if value is not None:
|
||||
return pretty_timestamp(value)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
from .window import Window
|
||||
from .app import (
|
||||
show,
|
||||
Window
|
||||
validate_host_requirements,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Window",
|
||||
|
||||
"show",
|
||||
"Window"
|
||||
"validate_host_requirements",
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
583
openpype/tools/workfiles/files_widget.py
Normal file
583
openpype/tools/workfiles/files_widget.py
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
import os
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
import Qt
|
||||
from Qt import QtWidgets, QtCore
|
||||
from avalon import io, api
|
||||
|
||||
from openpype.tools.utils import PlaceholderLineEdit
|
||||
from openpype.tools.utils.delegates import PrettyTimeDelegate
|
||||
from openpype.lib import (
|
||||
emit_event,
|
||||
Anatomy,
|
||||
get_workfile_template_key,
|
||||
create_workdir_extra_folders,
|
||||
)
|
||||
from openpype.lib.avalon_context import (
|
||||
update_current_task,
|
||||
compute_session_changes
|
||||
)
|
||||
from .model import (
|
||||
WorkAreaFilesModel,
|
||||
PublishFilesModel,
|
||||
|
||||
FILEPATH_ROLE,
|
||||
DATE_MODIFIED_ROLE,
|
||||
)
|
||||
from .save_as_dialog import SaveAsDialog
|
||||
from .lib import TempPublishFiles
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class FilesWidget(QtWidgets.QWidget):
|
||||
"""A widget displaying files that allows to save and open files."""
|
||||
file_selected = QtCore.Signal(str)
|
||||
file_opened = QtCore.Signal()
|
||||
publish_file_viewed = QtCore.Signal()
|
||||
workfile_created = QtCore.Signal(str)
|
||||
published_visible_changed = QtCore.Signal(bool)
|
||||
|
||||
def __init__(self, parent):
|
||||
super(FilesWidget, self).__init__(parent)
|
||||
|
||||
# Setup
|
||||
self._asset_id = None
|
||||
self._asset_doc = None
|
||||
self._task_name = None
|
||||
self._task_type = 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
|
||||
self.template_key = "work"
|
||||
|
||||
# This is not root but workfile directory
|
||||
self._workfiles_root = None
|
||||
self._workdir_path = None
|
||||
self.host = api.registered_host()
|
||||
temp_publish_files = TempPublishFiles()
|
||||
temp_publish_files.cleanup()
|
||||
self._temp_publish_files = temp_publish_files
|
||||
|
||||
# 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
|
||||
|
||||
# Filtering input
|
||||
filter_widget = QtWidgets.QWidget(self)
|
||||
|
||||
published_checkbox = QtWidgets.QCheckBox("Published", filter_widget)
|
||||
|
||||
filter_input = PlaceholderLineEdit(filter_widget)
|
||||
filter_input.setPlaceholderText("Filter files..")
|
||||
|
||||
filter_layout = QtWidgets.QHBoxLayout(filter_widget)
|
||||
filter_layout.setContentsMargins(0, 0, 0, 0)
|
||||
filter_layout.addWidget(published_checkbox, 0)
|
||||
filter_layout.addWidget(filter_input, 1)
|
||||
|
||||
# Create the Files models
|
||||
extensions = set(self.host.file_extensions())
|
||||
|
||||
views_widget = QtWidgets.QWidget(self)
|
||||
# Workarea view
|
||||
workarea_files_model = WorkAreaFilesModel(extensions)
|
||||
|
||||
# Create proxy model for files to be able sort and filter
|
||||
workarea_proxy_model = QtCore.QSortFilterProxyModel()
|
||||
workarea_proxy_model.setSourceModel(workarea_files_model)
|
||||
workarea_proxy_model.setDynamicSortFilter(True)
|
||||
workarea_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
# Set up the file list tree view
|
||||
workarea_files_view = FilesView(views_widget)
|
||||
workarea_files_view.setModel(workarea_proxy_model)
|
||||
workarea_files_view.setSortingEnabled(True)
|
||||
workarea_files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
# Date modified delegate
|
||||
workarea_time_delegate = PrettyTimeDelegate()
|
||||
workarea_files_view.setItemDelegateForColumn(1, workarea_time_delegate)
|
||||
workarea_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.
|
||||
workarea_files_view.setColumnWidth(0, 330)
|
||||
|
||||
# Publish files view
|
||||
publish_files_model = PublishFilesModel(extensions, io, self.anatomy)
|
||||
|
||||
publish_proxy_model = QtCore.QSortFilterProxyModel()
|
||||
publish_proxy_model.setSourceModel(publish_files_model)
|
||||
publish_proxy_model.setDynamicSortFilter(True)
|
||||
publish_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
publish_files_view = FilesView(views_widget)
|
||||
publish_files_view.setModel(publish_proxy_model)
|
||||
|
||||
publish_files_view.setSortingEnabled(True)
|
||||
publish_files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
# Date modified delegate
|
||||
publish_time_delegate = PrettyTimeDelegate()
|
||||
publish_files_view.setItemDelegateForColumn(1, publish_time_delegate)
|
||||
publish_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.
|
||||
publish_files_view.setColumnWidth(0, 330)
|
||||
|
||||
views_layout = QtWidgets.QHBoxLayout(views_widget)
|
||||
views_layout.setContentsMargins(0, 0, 0, 0)
|
||||
views_layout.addWidget(workarea_files_view, 1)
|
||||
views_layout.addWidget(publish_files_view, 1)
|
||||
|
||||
# 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)
|
||||
|
||||
btn_view_published = QtWidgets.QPushButton("View", btns_widget)
|
||||
|
||||
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
|
||||
btns_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btns_layout.addWidget(btn_open, 1)
|
||||
btns_layout.addWidget(btn_browse, 1)
|
||||
btns_layout.addWidget(btn_save, 1)
|
||||
btns_layout.addWidget(btn_view_published, 1)
|
||||
|
||||
# Build files widgets for home page
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(filter_widget, 0)
|
||||
main_layout.addWidget(views_widget, 1)
|
||||
main_layout.addWidget(btns_widget, 0)
|
||||
|
||||
# Register signal callbacks
|
||||
published_checkbox.stateChanged.connect(self._on_published_change)
|
||||
filter_input.textChanged.connect(self._on_filter_text_change)
|
||||
|
||||
workarea_files_view.doubleClickedLeft.connect(
|
||||
self._on_workarea_open_pressed
|
||||
)
|
||||
workarea_files_view.customContextMenuRequested.connect(
|
||||
self._on_workarea_context_menu
|
||||
)
|
||||
workarea_files_view.selectionModel().selectionChanged.connect(
|
||||
self.on_file_select
|
||||
)
|
||||
publish_files_view.doubleClickedLeft.connect(
|
||||
self._on_view_published_pressed
|
||||
)
|
||||
|
||||
btn_open.pressed.connect(self._on_workarea_open_pressed)
|
||||
btn_browse.pressed.connect(self.on_browse_pressed)
|
||||
btn_save.pressed.connect(self.on_save_as_pressed)
|
||||
btn_view_published.pressed.connect(self._on_view_published_pressed)
|
||||
|
||||
# Store attributes
|
||||
self._published_checkbox = published_checkbox
|
||||
self._filter_input = filter_input
|
||||
|
||||
self._workarea_time_delegate = workarea_time_delegate
|
||||
self._workarea_files_view = workarea_files_view
|
||||
self._workarea_files_model = workarea_files_model
|
||||
self._workarea_proxy_model = workarea_proxy_model
|
||||
|
||||
self._publish_time_delegate = publish_time_delegate
|
||||
self._publish_files_view = publish_files_view
|
||||
self._publish_files_model = publish_files_model
|
||||
self._publish_proxy_model = publish_proxy_model
|
||||
|
||||
self._btns_widget = btns_widget
|
||||
self._btn_open = btn_open
|
||||
self._btn_browse = btn_browse
|
||||
self._btn_save = btn_save
|
||||
self._btn_view_published = btn_view_published
|
||||
|
||||
# Create a proxy widget for files widget
|
||||
self.setFocusProxy(btn_open)
|
||||
|
||||
# Hide publish files widgets
|
||||
publish_files_view.setVisible(False)
|
||||
btn_view_published.setVisible(False)
|
||||
|
||||
@property
|
||||
def published_enabled(self):
|
||||
return self._published_checkbox.isChecked()
|
||||
|
||||
def _on_published_change(self):
|
||||
published_enabled = self.published_enabled
|
||||
|
||||
self._workarea_files_view.setVisible(not published_enabled)
|
||||
self._btn_open.setVisible(not published_enabled)
|
||||
self._btn_browse.setVisible(not published_enabled)
|
||||
self._btn_save.setVisible(not published_enabled)
|
||||
|
||||
self._publish_files_view.setVisible(published_enabled)
|
||||
self._btn_view_published.setVisible(published_enabled)
|
||||
|
||||
self._update_filtering()
|
||||
self._update_asset_task()
|
||||
|
||||
self.published_visible_changed.emit(published_enabled)
|
||||
|
||||
self._select_last_modified_file()
|
||||
|
||||
def _on_filter_text_change(self):
|
||||
self._update_filtering()
|
||||
|
||||
def _update_filtering(self):
|
||||
text = self._filter_input.text()
|
||||
if self.published_enabled:
|
||||
self._publish_proxy_model.setFilterFixedString(text)
|
||||
else:
|
||||
self._workarea_proxy_model.setFilterFixedString(text)
|
||||
|
||||
def set_save_enabled(self, enabled):
|
||||
self._btn_save.setEnabled(enabled)
|
||||
|
||||
def set_asset_task(self, asset_id, task_name, task_type):
|
||||
if asset_id != self._asset_id:
|
||||
self._asset_doc = None
|
||||
self._asset_id = asset_id
|
||||
self._task_name = task_name
|
||||
self._task_type = task_type
|
||||
self._update_asset_task()
|
||||
|
||||
def _update_asset_task(self):
|
||||
if self.published_enabled:
|
||||
self._publish_files_model.set_context(
|
||||
self._asset_id, self._task_name
|
||||
)
|
||||
has_valid_items = self._publish_files_model.has_valid_items()
|
||||
self._btn_view_published.setEnabled(has_valid_items)
|
||||
else:
|
||||
# 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_id and self._task_name and self._task_type:
|
||||
session = self._get_session()
|
||||
self._workdir_path = session["AVALON_WORKDIR"]
|
||||
self._workfiles_root = self.host.work_root(session)
|
||||
self._workarea_files_model.set_root(self._workfiles_root)
|
||||
|
||||
else:
|
||||
self._workarea_files_model.set_root(None)
|
||||
|
||||
# Disable/Enable buttons based on available files in model
|
||||
has_valid_items = self._workarea_files_model.has_valid_items()
|
||||
self._btn_browse.setEnabled(has_valid_items)
|
||||
self._btn_open.setEnabled(has_valid_items)
|
||||
# Manually trigger file selection
|
||||
if not has_valid_items:
|
||||
self.on_file_select()
|
||||
|
||||
def _get_asset_doc(self):
|
||||
if self._asset_id is None:
|
||||
return None
|
||||
|
||||
if self._asset_doc is None:
|
||||
self._asset_doc = io.find_one({"_id": self._asset_id})
|
||||
return self._asset_doc
|
||||
|
||||
def _get_session(self):
|
||||
"""Return a modified session for the current asset and task"""
|
||||
|
||||
session = api.Session.copy()
|
||||
self.template_key = get_workfile_template_key(
|
||||
self._task_type,
|
||||
session["AVALON_APP"],
|
||||
project_name=session["AVALON_PROJECT"]
|
||||
)
|
||||
changes = compute_session_changes(
|
||||
session,
|
||||
asset=self._get_asset_doc(),
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
session.update(changes)
|
||||
|
||||
return session
|
||||
|
||||
def _enter_session(self):
|
||||
"""Enter the asset and task session currently selected"""
|
||||
|
||||
session = api.Session.copy()
|
||||
changes = compute_session_changes(
|
||||
session,
|
||||
asset=self._get_asset_doc(),
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
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
|
||||
|
||||
update_current_task(
|
||||
asset=self._get_asset_doc(),
|
||||
task=self._task_name,
|
||||
template_key=self.template_key
|
||||
)
|
||||
|
||||
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.file_opened.emit()
|
||||
|
||||
def save_changes_prompt(self):
|
||||
self._messagebox = messagebox = QtWidgets.QMessageBox(parent=self)
|
||||
messagebox.setWindowFlags(messagebox.windowFlags() |
|
||||
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
|
||||
)
|
||||
|
||||
result = messagebox.exec_()
|
||||
if result == messagebox.Yes:
|
||||
return True
|
||||
if 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 = SaveAsDialog(
|
||||
parent=self,
|
||||
root=self._workfiles_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._workfiles_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"""
|
||||
if self.published_enabled:
|
||||
source_view = self._publish_files_view
|
||||
else:
|
||||
source_view = self._workarea_files_view
|
||||
selection = source_view.selectionModel()
|
||||
index = selection.currentIndex()
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
return index.data(FILEPATH_ROLE)
|
||||
|
||||
def _on_workarea_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._workfiles_root
|
||||
else:
|
||||
kwargs["directory"] = self._workfiles_root
|
||||
|
||||
work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0]
|
||||
if work_file:
|
||||
self.open_file(work_file)
|
||||
|
||||
def on_save_as_pressed(self):
|
||||
work_filename = self.get_filename()
|
||||
if not work_filename:
|
||||
return
|
||||
|
||||
# Trigger before save event
|
||||
emit_event(
|
||||
"workfile.save.before",
|
||||
{"filename": work_filename, "workdir_path": self._workdir_path},
|
||||
source="workfiles.tool"
|
||||
)
|
||||
|
||||
# Make sure workfiles root is updated
|
||||
# - this triggers 'workio.work_root(...)' which may change value of
|
||||
# '_workfiles_root'
|
||||
self.set_asset_task(
|
||||
self._asset_id, self._task_name, self._task_type
|
||||
)
|
||||
|
||||
# Create workfiles root folder
|
||||
if not os.path.exists(self._workfiles_root):
|
||||
log.debug("Initializing Work Directory: %s", self._workfiles_root)
|
||||
os.makedirs(self._workfiles_root)
|
||||
|
||||
# Update session if context has changed
|
||||
self._enter_session()
|
||||
# Prepare full path to workfile and save it
|
||||
filepath = os.path.join(
|
||||
os.path.normpath(self._workfiles_root), work_filename
|
||||
)
|
||||
self.host.save_file(filepath)
|
||||
# Create extra folders
|
||||
create_workdir_extra_folders(
|
||||
self._workdir_path,
|
||||
api.Session["AVALON_APP"],
|
||||
self._task_type,
|
||||
self._task_name,
|
||||
api.Session["AVALON_PROJECT"]
|
||||
)
|
||||
# Trigger after save events
|
||||
emit_event(
|
||||
"workfile.save.after",
|
||||
{"filename": work_filename, "workdir_path": self._workdir_path},
|
||||
source="workfiles.tool"
|
||||
)
|
||||
|
||||
self.workfile_created.emit(filepath)
|
||||
# Refresh files model
|
||||
self.refresh()
|
||||
|
||||
def _on_view_published_pressed(self):
|
||||
filepath = self._get_selected_filepath()
|
||||
if not filepath or not os.path.exists(filepath):
|
||||
return
|
||||
item = self._temp_publish_files.add_file(filepath)
|
||||
self.host.open_file(item.filepath)
|
||||
self.publish_file_viewed.emit()
|
||||
# Change state back to workarea
|
||||
self._published_checkbox.setChecked(False)
|
||||
|
||||
def on_file_select(self):
|
||||
self.file_selected.emit(self._get_selected_filepath())
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh listed files for current selection in the interface"""
|
||||
if self.published_enabled:
|
||||
self._publish_files_model.refresh()
|
||||
else:
|
||||
self._workarea_files_model.refresh()
|
||||
|
||||
if self.auto_select_latest_modified:
|
||||
self._select_last_modified_file()
|
||||
|
||||
def _on_workarea_context_menu(self, point):
|
||||
index = self._workarea_files_view.indexAt(point)
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
if not index.flags() & QtCore.Qt.ItemIsEnabled:
|
||||
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._workarea_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"""
|
||||
if self.published_enabled:
|
||||
source_view = self._publish_files_view
|
||||
else:
|
||||
source_view = self._workarea_files_view
|
||||
model = source_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(DATE_MODIFIED_ROLE)
|
||||
if modified is not None and modified > highest:
|
||||
highest_index = index
|
||||
highest = modified
|
||||
|
||||
if highest_index:
|
||||
source_view.setCurrentIndex(highest_index)
|
||||
272
openpype/tools/workfiles/lib.py
Normal file
272
openpype/tools/workfiles/lib.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import contextlib
|
||||
|
||||
import appdirs
|
||||
|
||||
|
||||
class TempPublishFilesItem(object):
|
||||
"""Object representing copied workfile in app temp folder.
|
||||
|
||||
Args:
|
||||
item_id (str): Id of item used as subfolder.
|
||||
data (dict): Metadata about temp files.
|
||||
directory (str): Path to directory where files are copied to.
|
||||
"""
|
||||
|
||||
def __init__(self, item_id, data, directory):
|
||||
self._id = item_id
|
||||
self._directory = directory
|
||||
self._filepath = os.path.join(directory, data["filename"])
|
||||
|
||||
@property
|
||||
def directory(self):
|
||||
return self._directory
|
||||
|
||||
@property
|
||||
def filepath(self):
|
||||
return self._filepath
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
if os.path.exists(self.filepath):
|
||||
s = os.stat(self.filepath)
|
||||
return s.st_size
|
||||
return 0
|
||||
|
||||
|
||||
class TempPublishFiles(object):
|
||||
"""Directory where published workfiles are copied when opened.
|
||||
|
||||
Directory is located in appdirs on the machine. Folder contains file
|
||||
with metadata about stored files. Each item in metadata has id, filename
|
||||
and expiration time. When expiration time is higher then current time the
|
||||
item is removed from metadata and it's files are deleted. Files of items
|
||||
are stored in subfolder named by item's id.
|
||||
|
||||
Metadata file can be in theory opened and modified by multiple processes,
|
||||
threads at one time. For those cases is created simple lock file which
|
||||
is created before modification begins and is removed when modification
|
||||
ends. Existence of the file means that it should not be modified by
|
||||
any other process at the same time.
|
||||
|
||||
Metadata example:
|
||||
```
|
||||
{
|
||||
"96050b4a-8974-4fca-8179-7c446c478d54": {
|
||||
"created": 1647880725.555,
|
||||
"expiration": 1647884325.555,
|
||||
"filename": "cg_pigeon_workfileModeling_v025.ma"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Why is this needed
|
||||
Combination of more issues. Temp files are not automatically removed by
|
||||
OS on windows so using tempfiles in TEMP would lead to kill disk space of
|
||||
machine. There are also cases when someone wants to open multiple files
|
||||
in short period of time and want to manually remove those files so keeping
|
||||
track of temporary copied files in pre-defined structure is needed.
|
||||
"""
|
||||
minute_in_seconds = 60
|
||||
hour_in_seconds = 60 * minute_in_seconds
|
||||
day_in_seconds = 24 * hour_in_seconds
|
||||
|
||||
def __init__(self):
|
||||
root_dir = appdirs.user_data_dir(
|
||||
"published_workfiles_temp", "openpype"
|
||||
)
|
||||
if not os.path.exists(root_dir):
|
||||
os.makedirs(root_dir)
|
||||
|
||||
metadata_path = os.path.join(root_dir, "metadata.json")
|
||||
lock_path = os.path.join(root_dir, "lock.json")
|
||||
|
||||
self._root_dir = root_dir
|
||||
self._metadata_path = metadata_path
|
||||
self._lock_path = lock_path
|
||||
self._log = None
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
self._log = logging.getLogger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
@property
|
||||
def life_time(self):
|
||||
"""How long will be new item kept in temp in seconds.
|
||||
|
||||
Returns:
|
||||
int: Lifetime of temp item.
|
||||
"""
|
||||
return int(self.hour_in_seconds)
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""File size of existing items."""
|
||||
size = 0
|
||||
for item in self.get_items():
|
||||
size += item.size
|
||||
return size
|
||||
|
||||
def add_file(self, src_path):
|
||||
"""Add workfile to temp directory.
|
||||
|
||||
This will create new item and source path is copied to it's directory.
|
||||
"""
|
||||
filename = os.path.basename(src_path)
|
||||
|
||||
item_id = str(uuid.uuid4())
|
||||
dst_dirpath = os.path.join(self._root_dir, item_id)
|
||||
if not os.path.exists(dst_dirpath):
|
||||
os.makedirs(dst_dirpath)
|
||||
|
||||
dst_path = os.path.join(dst_dirpath, filename)
|
||||
shutil.copy(src_path, dst_path)
|
||||
|
||||
now = time.time()
|
||||
item_data = {
|
||||
"filename": filename,
|
||||
"expiration": now + self.life_time,
|
||||
"created": now
|
||||
}
|
||||
with self._modify_data() as data:
|
||||
data[item_id] = item_data
|
||||
|
||||
return TempPublishFilesItem(item_id, item_data, dst_dirpath)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _modify_data(self):
|
||||
"""Create lock file when data in metadata file are modified."""
|
||||
start_time = time.time()
|
||||
timeout = 3
|
||||
while os.path.exists(self._lock_path):
|
||||
time.sleep(0.01)
|
||||
if start_time > timeout:
|
||||
self.log.warning((
|
||||
"Waited for {} seconds to free lock file. Overriding lock."
|
||||
).format(timeout))
|
||||
|
||||
with open(self._lock_path, "w") as stream:
|
||||
json.dump({"pid": os.getpid()}, stream)
|
||||
|
||||
try:
|
||||
data = self._get_data()
|
||||
yield data
|
||||
with open(self._metadata_path, "w") as stream:
|
||||
json.dump(data, stream)
|
||||
|
||||
finally:
|
||||
os.remove(self._lock_path)
|
||||
|
||||
def _get_data(self):
|
||||
output = {}
|
||||
if not os.path.exists(self._metadata_path):
|
||||
return output
|
||||
|
||||
try:
|
||||
with open(self._metadata_path, "r") as stream:
|
||||
output = json.load(stream)
|
||||
except Exception:
|
||||
self.log.warning("Failed to read metadata file.", exc_info=True)
|
||||
return output
|
||||
|
||||
def cleanup(self, check_expiration=True):
|
||||
"""Cleanup files based on metadata.
|
||||
|
||||
Items that passed expiration are removed when this is called. Or all
|
||||
files are removed when `check_expiration` is set to False.
|
||||
|
||||
Args:
|
||||
check_expiration (bool): All items and files are removed when set
|
||||
to True.
|
||||
"""
|
||||
data = self._get_data()
|
||||
now = time.time()
|
||||
remove_ids = set()
|
||||
all_ids = set()
|
||||
for item_id, item_data in data.items():
|
||||
all_ids.add(item_id)
|
||||
if check_expiration and now < item_data["expiration"]:
|
||||
continue
|
||||
|
||||
remove_ids.add(item_id)
|
||||
|
||||
for item_id in remove_ids:
|
||||
try:
|
||||
self.remove_id(item_id)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"Failed to remove temp publish item \"{}\"".format(
|
||||
item_id
|
||||
),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Remove unknown folders/files
|
||||
for filename in os.listdir(self._root_dir):
|
||||
if filename in all_ids:
|
||||
continue
|
||||
|
||||
full_path = os.path.join(self._root_dir, filename)
|
||||
if full_path in (self._metadata_path, self._lock_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
shutil.rmtree(full_path)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"Couldn't remove arbitrary path \"{}\"".format(full_path),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
def clear(self):
|
||||
self.cleanup(False)
|
||||
|
||||
def get_items(self):
|
||||
"""Receive all items from metadata file.
|
||||
|
||||
Returns:
|
||||
list<TempPublishFilesItem>: Info about each item in metadata.
|
||||
"""
|
||||
output = []
|
||||
data = self._get_data()
|
||||
for item_id, item_data in data.items():
|
||||
item_path = os.path.join(self._root_dir, item_id)
|
||||
output.append(TempPublishFilesItem(item_id, item_data, item_path))
|
||||
return output
|
||||
|
||||
def remove_id(self, item_id):
|
||||
"""Remove files of item and then remove the item from metadata."""
|
||||
filepath = os.path.join(self._root_dir, item_id)
|
||||
if os.path.exists(filepath):
|
||||
shutil.rmtree(filepath)
|
||||
|
||||
with self._modify_data() as data:
|
||||
data.pop(item_id, None)
|
||||
|
||||
|
||||
def file_size_to_string(file_size):
|
||||
size = 0
|
||||
size_ending_mapping = {
|
||||
"KB": 1024 ** 1,
|
||||
"MB": 1024 ** 2,
|
||||
"GB": 1024 ** 3
|
||||
}
|
||||
ending = "B"
|
||||
for _ending, _size in size_ending_mapping.items():
|
||||
if file_size < _size:
|
||||
break
|
||||
size = file_size / _size
|
||||
ending = _ending
|
||||
return "{:.2f} {}".format(size, ending)
|
||||
|
|
@ -1,153 +1,179 @@
|
|||
import os
|
||||
import logging
|
||||
|
||||
from Qt import QtCore
|
||||
from Qt import QtCore, QtGui
|
||||
import qtawesome
|
||||
|
||||
from openpype.style import (
|
||||
get_default_entity_icon_color,
|
||||
get_disabled_entity_icon_color,
|
||||
)
|
||||
|
||||
from openpype.tools.utils.models import TreeModel, Item
|
||||
from openpype.pipeline import get_representation_path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FilesModel(TreeModel):
|
||||
"""Model listing files with specified extensions in a root folder"""
|
||||
Columns = ["filename", "date"]
|
||||
FILEPATH_ROLE = QtCore.Qt.UserRole + 2
|
||||
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3
|
||||
ITEM_ID_ROLE = QtCore.Qt.UserRole + 4
|
||||
|
||||
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)
|
||||
class WorkAreaFilesModel(QtGui.QStandardItemModel):
|
||||
"""Model is looking into one folder for files with extension."""
|
||||
|
||||
def __init__(self, extensions, *args, **kwargs):
|
||||
super(WorkAreaFilesModel, self).__init__(*args, **kwargs)
|
||||
|
||||
self.setColumnCount(2)
|
||||
|
||||
self._root = None
|
||||
self._file_extensions = file_extensions
|
||||
self._icons = {
|
||||
"file": qtawesome.icon(
|
||||
"fa.file-o",
|
||||
color=get_default_entity_icon_color()
|
||||
self._file_extensions = extensions
|
||||
self._invalid_path_item = None
|
||||
self._empty_root_item = None
|
||||
self._file_icon = qtawesome.icon(
|
||||
"fa.file-o",
|
||||
color=get_default_entity_icon_color()
|
||||
)
|
||||
self._invalid_item_visible = False
|
||||
self._items_by_filename = {}
|
||||
|
||||
def _get_invalid_path_item(self):
|
||||
if self._invalid_path_item is None:
|
||||
message = "Work Area does not exist. Use Save As to create it."
|
||||
item = QtGui.QStandardItem(message)
|
||||
icon = qtawesome.icon(
|
||||
"fa.times",
|
||||
color=get_disabled_entity_icon_color()
|
||||
)
|
||||
}
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
item.setColumnCount(self.columnCount())
|
||||
self._invalid_path_item = item
|
||||
return self._invalid_path_item
|
||||
|
||||
def _get_empty_root_item(self):
|
||||
if self._empty_root_item is None:
|
||||
message = "Work Area is empty."
|
||||
item = QtGui.QStandardItem(message)
|
||||
icon = qtawesome.icon(
|
||||
"fa.times",
|
||||
color=get_disabled_entity_icon_color()
|
||||
)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
item.setColumnCount(self.columnCount())
|
||||
self._empty_root_item = item
|
||||
return self._empty_root_item
|
||||
|
||||
def set_root(self, root):
|
||||
"""Change directory where to look for file."""
|
||||
self._root = root
|
||||
if root and not os.path.exists(root):
|
||||
log.debug("Work Area does not exist: {}".format(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 _clear(self):
|
||||
root_item = self.invisibleRootItem()
|
||||
rows = root_item.rowCount()
|
||||
if rows > 0:
|
||||
if self._invalid_item_visible:
|
||||
for row in range(rows):
|
||||
root_item.takeRow(row)
|
||||
else:
|
||||
root_item.removeRows(0, rows)
|
||||
self._items_by_filename = {}
|
||||
|
||||
def refresh(self):
|
||||
self.clear()
|
||||
self.beginResetModel()
|
||||
|
||||
root = self._root
|
||||
|
||||
if not root:
|
||||
self.endResetModel()
|
||||
return
|
||||
|
||||
if not os.path.exists(root):
|
||||
"""Refresh and update model items."""
|
||||
root_item = self.invisibleRootItem()
|
||||
# If path is not set or does not exist then add invalid path item
|
||||
if not self._root or not os.path.exists(self._root):
|
||||
self._clear()
|
||||
# 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=get_disabled_entity_icon_color()
|
||||
)
|
||||
})
|
||||
self.add_child(item)
|
||||
self.endResetModel()
|
||||
item = self._get_invalid_path_item()
|
||||
root_item.appendRow(item)
|
||||
self._invalid_item_visible = True
|
||||
return
|
||||
|
||||
extensions = self._file_extensions
|
||||
# Clear items if previous refresh set '_invalid_item_visible' to True
|
||||
# - Invalid items are not stored to '_items_by_filename' so they would
|
||||
# not be removed
|
||||
if self._invalid_item_visible:
|
||||
self._clear()
|
||||
|
||||
for filename in os.listdir(root):
|
||||
path = os.path.join(root, filename)
|
||||
if os.path.isdir(path):
|
||||
# Check for new items that should be added and items that should be
|
||||
# removed
|
||||
new_items = []
|
||||
items_to_remove = set(self._items_by_filename.keys())
|
||||
for filename in os.listdir(self._root):
|
||||
filepath = os.path.join(self._root, filename)
|
||||
if os.path.isdir(filepath):
|
||||
continue
|
||||
|
||||
ext = os.path.splitext(filename)[1]
|
||||
if extensions and ext not in extensions:
|
||||
if ext not in self._file_extensions:
|
||||
continue
|
||||
|
||||
modified = os.path.getmtime(path)
|
||||
modified = os.path.getmtime(filepath)
|
||||
|
||||
item = Item({
|
||||
"filename": filename,
|
||||
"date": modified,
|
||||
"filepath": path
|
||||
})
|
||||
# Use existing item or create new one
|
||||
if filename in items_to_remove:
|
||||
items_to_remove.remove(filename)
|
||||
item = self._items_by_filename[filename]
|
||||
else:
|
||||
item = QtGui.QStandardItem(filename)
|
||||
item.setColumnCount(self.columnCount())
|
||||
item.setFlags(
|
||||
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
)
|
||||
item.setData(self._file_icon, QtCore.Qt.DecorationRole)
|
||||
new_items.append(item)
|
||||
self._items_by_filename[filename] = item
|
||||
# Update data that may be different
|
||||
item.setData(filepath, FILEPATH_ROLE)
|
||||
item.setData(modified, DATE_MODIFIED_ROLE)
|
||||
|
||||
self.add_child(item)
|
||||
# Add new items if there are any
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
if self.rowCount() == 0:
|
||||
self._add_empty()
|
||||
# Remove items that are no longer available
|
||||
for filename in items_to_remove:
|
||||
item = self._items_by_filename.pop(filename)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
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
|
||||
# Add empty root item if there are not filenames that could be shown
|
||||
if root_item.rowCount() > 0:
|
||||
self._invalid_item_visible = False
|
||||
else:
|
||||
parent_item = parent.internalPointer()
|
||||
return parent_item.childCount()
|
||||
self._invalid_item_visible = True
|
||||
item = self._get_empty_root_item()
|
||||
root_item.appendRow(item)
|
||||
|
||||
def data(self, index, role):
|
||||
if not index.isValid():
|
||||
return
|
||||
def has_valid_items(self):
|
||||
"""Directory has files that are listed in items."""
|
||||
return not self._invalid_item_visible
|
||||
|
||||
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)
|
||||
def flags(self, index):
|
||||
# Use flags of first column for all columns
|
||||
if index.column() != 0:
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
return super(WorkAreaFilesModel, self).flags(index)
|
||||
|
||||
if role == self.FileNameRole:
|
||||
item = index.internalPointer()
|
||||
return item["filename"]
|
||||
def data(self, index, role=None):
|
||||
if role is None:
|
||||
role = QtCore.Qt.DisplayRole
|
||||
|
||||
if role == self.DateModifiedRole:
|
||||
item = index.internalPointer()
|
||||
return item["date"]
|
||||
# Handle roles for first column
|
||||
if index.column() == 1:
|
||||
if role == QtCore.Qt.DecorationRole:
|
||||
return None
|
||||
|
||||
if role == self.FilePathRole:
|
||||
item = index.internalPointer()
|
||||
return item["filepath"]
|
||||
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
|
||||
role = DATE_MODIFIED_ROLE
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
|
||||
if role == self.IsEnabled:
|
||||
item = index.internalPointer()
|
||||
return item.get("enabled", True)
|
||||
|
||||
return super(FilesModel, self).data(index, role)
|
||||
return super(WorkAreaFilesModel, self).data(index, role)
|
||||
|
||||
def headerData(self, section, orientation, role):
|
||||
# Show nice labels in the header
|
||||
|
|
@ -160,4 +186,274 @@ class FilesModel(TreeModel):
|
|||
elif section == 1:
|
||||
return "Date modified"
|
||||
|
||||
return super(FilesModel, self).headerData(section, orientation, role)
|
||||
return super(WorkAreaFilesModel, self).headerData(
|
||||
section, orientation, role
|
||||
)
|
||||
|
||||
|
||||
class PublishFilesModel(QtGui.QStandardItemModel):
|
||||
"""Model filling files with published files calculated from representation.
|
||||
|
||||
This model looks for workfile family representations based on selected
|
||||
asset and task.
|
||||
|
||||
Asset must set to be able look for representations that could be used.
|
||||
Task is used to filter representations by task.
|
||||
Model has few filter criteria for filling.
|
||||
- First criteria is that version document must have "workfile" in
|
||||
"data.families".
|
||||
- Second cirteria is that representation must have extension same as
|
||||
defined extensions
|
||||
- If task is set then representation must have 'task["name"]' with same
|
||||
name.
|
||||
"""
|
||||
|
||||
def __init__(self, extensions, dbcon, anatomy, *args, **kwargs):
|
||||
super(PublishFilesModel, self).__init__(*args, **kwargs)
|
||||
|
||||
self.setColumnCount(2)
|
||||
|
||||
self._dbcon = dbcon
|
||||
self._anatomy = anatomy
|
||||
self._file_extensions = extensions
|
||||
|
||||
self._invalid_context_item = None
|
||||
self._empty_root_item = None
|
||||
self._file_icon = qtawesome.icon(
|
||||
"fa.file-o",
|
||||
color=get_default_entity_icon_color()
|
||||
)
|
||||
self._invalid_icon = qtawesome.icon(
|
||||
"fa.times",
|
||||
color=get_disabled_entity_icon_color()
|
||||
)
|
||||
self._invalid_item_visible = False
|
||||
|
||||
self._items_by_id = {}
|
||||
|
||||
self._asset_id = None
|
||||
self._task_name = None
|
||||
|
||||
def _set_item_invalid(self, item):
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
item.setData(self._invalid_icon, QtCore.Qt.DecorationRole)
|
||||
|
||||
def _set_item_valid(self, item):
|
||||
item.setFlags(
|
||||
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
)
|
||||
item.setData(self._file_icon, QtCore.Qt.DecorationRole)
|
||||
|
||||
def _get_invalid_context_item(self):
|
||||
if self._invalid_context_item is None:
|
||||
item = QtGui.QStandardItem("Selected context is not valid.")
|
||||
item.setColumnCount(self.columnCount())
|
||||
self._set_item_invalid(item)
|
||||
self._invalid_context_item = item
|
||||
return self._invalid_context_item
|
||||
|
||||
def _get_empty_root_item(self):
|
||||
if self._empty_root_item is None:
|
||||
item = QtGui.QStandardItem("Didn't find any published workfiles.")
|
||||
item.setColumnCount(self.columnCount())
|
||||
self._set_item_invalid(item)
|
||||
self._empty_root_item = item
|
||||
return self._empty_root_item
|
||||
|
||||
def set_context(self, asset_id, task_name):
|
||||
"""Change context to asset and task.
|
||||
|
||||
Args:
|
||||
asset_id (ObjectId): Id of selected asset.
|
||||
task_name (str): Name of selected task.
|
||||
"""
|
||||
self._asset_id = asset_id
|
||||
self._task_name = task_name
|
||||
self.refresh()
|
||||
|
||||
def _clear(self):
|
||||
root_item = self.invisibleRootItem()
|
||||
rows = root_item.rowCount()
|
||||
if rows > 0:
|
||||
if self._invalid_item_visible:
|
||||
for row in range(rows):
|
||||
root_item.takeRow(row)
|
||||
else:
|
||||
root_item.removeRows(0, rows)
|
||||
self._items_by_id = {}
|
||||
|
||||
def _get_workfie_representations(self):
|
||||
output = []
|
||||
# Get subset docs of asset
|
||||
subset_docs = self._dbcon.find(
|
||||
{
|
||||
"type": "subset",
|
||||
"parent": self._asset_id
|
||||
},
|
||||
{
|
||||
"_id": True,
|
||||
"name": True
|
||||
}
|
||||
)
|
||||
|
||||
subset_ids = [subset_doc["_id"] for subset_doc in subset_docs]
|
||||
if not subset_ids:
|
||||
return output
|
||||
|
||||
# Get version docs of subsets with their families
|
||||
version_docs = self._dbcon.find(
|
||||
{
|
||||
"type": "version",
|
||||
"parent": {"$in": subset_ids}
|
||||
},
|
||||
{
|
||||
"_id": True,
|
||||
"data.families": True,
|
||||
"parent": True
|
||||
}
|
||||
)
|
||||
# Filter versions if they contain 'workfile' family
|
||||
filtered_versions = []
|
||||
for version_doc in version_docs:
|
||||
data = version_doc.get("data") or {}
|
||||
families = data.get("families") or []
|
||||
if "workfile" in families:
|
||||
filtered_versions.append(version_doc)
|
||||
|
||||
version_ids = [version_doc["_id"] for version_doc in filtered_versions]
|
||||
if not version_ids:
|
||||
return output
|
||||
|
||||
# Query representations of filtered versions and add filter for
|
||||
# extension
|
||||
extensions = [ext.replace(".", "") for ext in self._file_extensions]
|
||||
repre_docs = self._dbcon.find(
|
||||
{
|
||||
"type": "representation",
|
||||
"parent": {"$in": version_ids},
|
||||
"context.ext": {"$in": extensions}
|
||||
}
|
||||
)
|
||||
# Filter queried representations by task name if task is set
|
||||
filtered_repre_docs = []
|
||||
for repre_doc in repre_docs:
|
||||
if self._task_name is None:
|
||||
filtered_repre_docs.append(repre_doc)
|
||||
continue
|
||||
|
||||
task_info = repre_doc["context"].get("task")
|
||||
if not task_info:
|
||||
print("Not task info")
|
||||
continue
|
||||
|
||||
if isinstance(task_info, dict):
|
||||
task_name = task_info.get("name")
|
||||
else:
|
||||
task_name = task_info
|
||||
|
||||
if task_name == self._task_name:
|
||||
filtered_repre_docs.append(repre_doc)
|
||||
|
||||
# Collect paths of representations
|
||||
for repre_doc in filtered_repre_docs:
|
||||
path = get_representation_path(
|
||||
repre_doc, root=self._anatomy.roots
|
||||
)
|
||||
output.append((path, repre_doc["_id"]))
|
||||
return output
|
||||
|
||||
def refresh(self):
|
||||
root_item = self.invisibleRootItem()
|
||||
if not self._asset_id:
|
||||
self._clear()
|
||||
# Add Work Area does not exist placeholder
|
||||
item = self._get_invalid_context_item()
|
||||
root_item.appendRow(item)
|
||||
self._invalid_item_visible = True
|
||||
return
|
||||
|
||||
if self._invalid_item_visible:
|
||||
self._clear()
|
||||
|
||||
new_items = []
|
||||
items_to_remove = set(self._items_by_id.keys())
|
||||
for item in self._get_workfie_representations():
|
||||
filepath, repre_id = item
|
||||
# TODO handle empty filepaths
|
||||
if not filepath:
|
||||
continue
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
if repre_id in items_to_remove:
|
||||
items_to_remove.remove(repre_id)
|
||||
item = self._items_by_id[repre_id]
|
||||
else:
|
||||
item = QtGui.QStandardItem(filename)
|
||||
item.setColumnCount(self.columnCount())
|
||||
new_items.append(item)
|
||||
self._items_by_id[repre_id] = item
|
||||
|
||||
if os.path.exists(filepath):
|
||||
modified = os.path.getmtime(filepath)
|
||||
tooltip = None
|
||||
self._set_item_valid(item)
|
||||
else:
|
||||
modified = None
|
||||
tooltip = "File is not available from this machine"
|
||||
self._set_item_invalid(item)
|
||||
|
||||
item.setData(tooltip, QtCore.Qt.ToolTipRole)
|
||||
item.setData(filepath, FILEPATH_ROLE)
|
||||
item.setData(modified, DATE_MODIFIED_ROLE)
|
||||
item.setData(repre_id, ITEM_ID_ROLE)
|
||||
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
for filename in items_to_remove:
|
||||
item = self._items_by_id.pop(filename)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
if root_item.rowCount() > 0:
|
||||
self._invalid_item_visible = False
|
||||
else:
|
||||
self._invalid_item_visible = True
|
||||
item = self._get_empty_root_item()
|
||||
root_item.appendRow(item)
|
||||
|
||||
def has_valid_items(self):
|
||||
return not self._invalid_item_visible
|
||||
|
||||
def flags(self, index):
|
||||
if index.column() != 0:
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
return super(PublishFilesModel, self).flags(index)
|
||||
|
||||
def data(self, index, role=None):
|
||||
if role is None:
|
||||
role = QtCore.Qt.DisplayRole
|
||||
|
||||
if index.column() == 1:
|
||||
if role == QtCore.Qt.DecorationRole:
|
||||
return None
|
||||
|
||||
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
|
||||
role = DATE_MODIFIED_ROLE
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
|
||||
return super(PublishFilesModel, 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(PublishFilesModel, self).headerData(
|
||||
section, orientation, role
|
||||
)
|
||||
|
|
|
|||
482
openpype/tools/workfiles/save_as_dialog.py
Normal file
482
openpype/tools/workfiles/save_as_dialog.py
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
import os
|
||||
import re
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from Qt import QtWidgets, QtCore
|
||||
|
||||
from avalon import api, io
|
||||
|
||||
from openpype.lib import (
|
||||
get_last_workfile_with_version,
|
||||
get_workdir_data,
|
||||
)
|
||||
from openpype.tools.utils import PlaceholderLineEdit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_workfile_data(session):
|
||||
"""Get the data required for workfile formatting from avalon `session`"""
|
||||
|
||||
# Set work file data for template formatting
|
||||
asset_name = session["AVALON_ASSET"]
|
||||
task_name = session["AVALON_TASK"]
|
||||
host_name = session["AVALON_APP"]
|
||||
project_doc = io.find_one(
|
||||
{"type": "project"},
|
||||
{
|
||||
"name": True,
|
||||
"data.code": True,
|
||||
"config.tasks": True,
|
||||
}
|
||||
)
|
||||
|
||||
asset_doc = io.find_one(
|
||||
{
|
||||
"type": "asset",
|
||||
"name": asset_name
|
||||
},
|
||||
{
|
||||
"name": True,
|
||||
"data.tasks": True,
|
||||
"data.parents": True
|
||||
}
|
||||
)
|
||||
data = get_workdir_data(project_doc, asset_doc, task_name, host_name)
|
||||
data.update({
|
||||
"version": 1,
|
||||
"comment": "",
|
||||
"ext": None
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CommentMatcher(object):
|
||||
"""Use anatomy and work file data to parse comments from filenames"""
|
||||
def __init__(self, anatomy, template_key, data):
|
||||
|
||||
self.fname_regex = None
|
||||
|
||||
template = anatomy.templates[template_key]["file"]
|
||||
if "{comment}" not in template:
|
||||
# Don't look for comment if template doesn't allow it
|
||||
return
|
||||
|
||||
# Create a regex group for extensions
|
||||
extensions = api.registered_host().file_extensions()
|
||||
any_extension = "(?:{})".format(
|
||||
"|".join(re.escape(ext[1:]) for ext in extensions)
|
||||
)
|
||||
|
||||
# Use placeholders that will never be in the filename
|
||||
temp_data = copy.deepcopy(data)
|
||||
temp_data["comment"] = "<<comment>>"
|
||||
temp_data["version"] = "<<version>>"
|
||||
temp_data["ext"] = "<<ext>>"
|
||||
|
||||
formatted = anatomy.format(temp_data)
|
||||
fname_pattern = formatted[template_key]["file"]
|
||||
fname_pattern = re.escape(fname_pattern)
|
||||
|
||||
# Replace comment and version with something we can match with regex
|
||||
replacements = {
|
||||
"<<comment>>": "(.+)",
|
||||
"<<version>>": "[0-9]+",
|
||||
"<<ext>>": any_extension,
|
||||
}
|
||||
for src, dest in replacements.items():
|
||||
fname_pattern = fname_pattern.replace(re.escape(src), dest)
|
||||
|
||||
# Match from beginning to end of string to be safe
|
||||
fname_pattern = "^{}$".format(fname_pattern)
|
||||
|
||||
self.fname_regex = re.compile(fname_pattern)
|
||||
|
||||
def parse_comment(self, filepath):
|
||||
"""Parse the {comment} part from a filename"""
|
||||
if not self.fname_regex:
|
||||
return
|
||||
|
||||
fname = os.path.basename(filepath)
|
||||
match = self.fname_regex.match(fname)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
|
||||
class SubversionLineEdit(QtWidgets.QWidget):
|
||||
"""QLineEdit with QPushButton for drop down selection of list of strings"""
|
||||
|
||||
text_changed = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SubversionLineEdit, self).__init__(*args, **kwargs)
|
||||
|
||||
input_field = PlaceholderLineEdit(self)
|
||||
menu_btn = QtWidgets.QPushButton(self)
|
||||
menu_btn.setFixedWidth(18)
|
||||
|
||||
menu = QtWidgets.QMenu(self)
|
||||
menu_btn.setMenu(menu)
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(3)
|
||||
|
||||
layout.addWidget(input_field, 1)
|
||||
layout.addWidget(menu_btn, 0)
|
||||
|
||||
input_field.textChanged.connect(self.text_changed)
|
||||
|
||||
self.setFocusProxy(input_field)
|
||||
|
||||
self._input_field = input_field
|
||||
self._menu_btn = menu_btn
|
||||
self._menu = menu
|
||||
|
||||
def set_placeholder(self, placeholder):
|
||||
self._input_field.setPlaceholderText(placeholder)
|
||||
|
||||
def set_text(self, text):
|
||||
self._input_field.setText(text)
|
||||
|
||||
def set_values(self, values):
|
||||
self._update(values)
|
||||
|
||||
def _on_button_clicked(self):
|
||||
self._menu.exec_()
|
||||
|
||||
def _on_action_clicked(self, action):
|
||||
self._input_field.setText(action.text())
|
||||
|
||||
def _update(self, values):
|
||||
"""Create optional predefined subset names
|
||||
|
||||
Args:
|
||||
default_names(list): all predefined names
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
menu = self._menu
|
||||
button = self._menu_btn
|
||||
|
||||
state = any(values)
|
||||
button.setEnabled(state)
|
||||
if state is False:
|
||||
return
|
||||
|
||||
# Include an empty string
|
||||
values = [""] + sorted(values)
|
||||
|
||||
# Get and destroy the action group
|
||||
group = button.findChild(QtWidgets.QActionGroup)
|
||||
if group:
|
||||
group.deleteLater()
|
||||
|
||||
# Build new action group
|
||||
group = QtWidgets.QActionGroup(button)
|
||||
for name in values:
|
||||
action = group.addAction(name)
|
||||
menu.addAction(action)
|
||||
|
||||
group.triggered.connect(self._on_action_clicked)
|
||||
|
||||
|
||||
class SaveAsDialog(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(SaveAsDialog, 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
|
||||
|
||||
self.data = build_workfile_data(session)
|
||||
|
||||
# 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 = SubversionLineEdit(inputs_widget)
|
||||
subversion.set_placeholder("Will be part of filename.")
|
||||
|
||||
# Extensions combobox
|
||||
ext_combo = QtWidgets.QComboBox(inputs_widget)
|
||||
# Add styled delegate to use stylesheets
|
||||
ext_delegate = QtWidgets.QStyledItemDelegate()
|
||||
ext_combo.setItemDelegate(ext_delegate)
|
||||
ext_combo.addItems(self.host.file_extensions())
|
||||
|
||||
# Build inputs
|
||||
inputs_layout = QtWidgets.QFormLayout(inputs_widget)
|
||||
# Add version only if template contains 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)
|
||||
else:
|
||||
version_widget.setVisible(False)
|
||||
|
||||
# Add subversion only if template contains `{comment}`
|
||||
if "{comment}" in self.template:
|
||||
inputs_layout.addRow("Subversion:", subversion)
|
||||
|
||||
# Detect whether a {comment} is in the current filename - if so,
|
||||
# preserve it by default and set it in the comment/subversion field
|
||||
current_filepath = self.host.current_file()
|
||||
if current_filepath:
|
||||
# We match the current filename against the current session
|
||||
# instead of the session where the user is saving to.
|
||||
current_data = build_workfile_data(api.Session)
|
||||
matcher = CommentMatcher(anatomy, template_key, current_data)
|
||||
comment = matcher.parse_comment(current_filepath)
|
||||
if comment:
|
||||
log.info("Detected subversion comment: {}".format(comment))
|
||||
self.data["comment"] = comment
|
||||
subversion.set_text(comment)
|
||||
|
||||
existing_comments = self.get_existing_comments()
|
||||
subversion.set_values(existing_comments)
|
||||
|
||||
else:
|
||||
subversion.setVisible(False)
|
||||
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)
|
||||
|
||||
# Signal callback registration
|
||||
version_input.valueChanged.connect(self.on_version_spinbox_changed)
|
||||
last_version_check.stateChanged.connect(
|
||||
self.on_version_checkbox_changed
|
||||
)
|
||||
|
||||
subversion.text_changed.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.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 = subversion
|
||||
self.ext_combo = ext_combo
|
||||
self._ext_delegate = ext_delegate
|
||||
|
||||
self.refresh()
|
||||
|
||||
def get_existing_comments(self):
|
||||
matcher = CommentMatcher(self.anatomy, self.template_key, self.data)
|
||||
host_extensions = set(self.host.file_extensions())
|
||||
comments = set()
|
||||
if os.path.isdir(self.root):
|
||||
for fname in os.listdir(self.root):
|
||||
if not os.path.isfile(os.path.join(self.root, fname)):
|
||||
continue
|
||||
|
||||
ext = os.path.splitext(fname)[-1]
|
||||
if ext not in host_extensions:
|
||||
continue
|
||||
|
||||
comment = matcher.parse_comment(fname)
|
||||
if comment:
|
||||
comments.add(comment)
|
||||
|
||||
return list(comments)
|
||||
|
||||
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 = get_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 `get_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(
|
||||
"<font color='red'>Cannot create \"{0}\" because file exists!"
|
||||
"</font>".format(work_file)
|
||||
)
|
||||
else:
|
||||
self.preview_label.setText(
|
||||
"<font color='green'>{0}</font>".format(work_file)
|
||||
)
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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)
|
||||
393
openpype/tools/workfiles/window.py
Normal file
393
openpype/tools/workfiles/window.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import os
|
||||
import datetime
|
||||
from Qt import QtCore, QtWidgets
|
||||
|
||||
from avalon import io
|
||||
|
||||
from openpype import style
|
||||
from openpype.lib import (
|
||||
get_workfile_doc,
|
||||
create_workfile_doc,
|
||||
save_workfile_data_to_doc,
|
||||
)
|
||||
from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget
|
||||
from openpype.tools.utils.tasks_widget import TasksWidget
|
||||
|
||||
from .files_widget import FilesWidget
|
||||
from .lib import TempPublishFiles, file_size_to_string
|
||||
|
||||
|
||||
class SidePanelWidget(QtWidgets.QWidget):
|
||||
save_clicked = QtCore.Signal()
|
||||
published_workfile_message = (
|
||||
"<b>INFO</b>: Opened published workfiles will be stored in"
|
||||
" temp directory on your machine. Current temp size: <b>{}</b>."
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
artist_note_widget = QtWidgets.QWidget(self)
|
||||
note_label = QtWidgets.QLabel("Artist note", artist_note_widget)
|
||||
note_input = QtWidgets.QPlainTextEdit(artist_note_widget)
|
||||
btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget)
|
||||
|
||||
artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget)
|
||||
artist_note_layout.setContentsMargins(0, 0, 0, 0)
|
||||
artist_note_layout.addWidget(note_label, 0)
|
||||
artist_note_layout.addWidget(note_input, 1)
|
||||
artist_note_layout.addWidget(
|
||||
btn_note_save, 0, alignment=QtCore.Qt.AlignRight
|
||||
)
|
||||
|
||||
publish_temp_widget = QtWidgets.QWidget(self)
|
||||
publish_temp_info_label = QtWidgets.QLabel(
|
||||
self.published_workfile_message.format(
|
||||
file_size_to_string(0)
|
||||
),
|
||||
publish_temp_widget
|
||||
)
|
||||
publish_temp_info_label.setWordWrap(True)
|
||||
|
||||
btn_clear_temp = QtWidgets.QPushButton(
|
||||
"Clear temp", publish_temp_widget
|
||||
)
|
||||
|
||||
publish_temp_layout = QtWidgets.QVBoxLayout(publish_temp_widget)
|
||||
publish_temp_layout.setContentsMargins(0, 0, 0, 0)
|
||||
publish_temp_layout.addWidget(publish_temp_info_label, 0)
|
||||
publish_temp_layout.addWidget(
|
||||
btn_clear_temp, 0, alignment=QtCore.Qt.AlignRight
|
||||
)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(details_label, 0)
|
||||
main_layout.addWidget(details_input, 1)
|
||||
main_layout.addWidget(artist_note_widget, 1)
|
||||
main_layout.addWidget(publish_temp_widget, 0)
|
||||
|
||||
note_input.textChanged.connect(self._on_note_change)
|
||||
btn_note_save.clicked.connect(self._on_save_click)
|
||||
btn_clear_temp.clicked.connect(self._on_clear_temp_click)
|
||||
|
||||
self._details_input = details_input
|
||||
self._artist_note_widget = artist_note_widget
|
||||
self._note_input = note_input
|
||||
self._btn_note_save = btn_note_save
|
||||
|
||||
self._publish_temp_info_label = publish_temp_info_label
|
||||
self._publish_temp_widget = publish_temp_widget
|
||||
|
||||
self._orig_note = ""
|
||||
self._workfile_doc = None
|
||||
|
||||
publish_temp_widget.setVisible(False)
|
||||
|
||||
def set_published_visible(self, published_visible):
|
||||
self._artist_note_widget.setVisible(not published_visible)
|
||||
self._publish_temp_widget.setVisible(published_visible)
|
||||
if published_visible:
|
||||
self.refresh_publish_temp_sizes()
|
||||
|
||||
def refresh_publish_temp_sizes(self):
|
||||
temp_publish_files = TempPublishFiles()
|
||||
text = self.published_workfile_message.format(
|
||||
file_size_to_string(temp_publish_files.size)
|
||||
)
|
||||
self._publish_temp_info_label.setText(text)
|
||||
|
||||
def _on_clear_temp_click(self):
|
||||
temp_publish_files = TempPublishFiles()
|
||||
temp_publish_files.clear()
|
||||
self.refresh_publish_temp_sizes()
|
||||
|
||||
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_id, task_name, filepath, workfile_doc):
|
||||
# Check if asset, task and file are selected
|
||||
# NOTE workfile document is not requirement
|
||||
enabled = bool(asset_id) 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 overridden
|
||||
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_value = file_size_to_string(filestat.st_size)
|
||||
|
||||
# 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 = (
|
||||
"<b>Size:</b>",
|
||||
size_value,
|
||||
"<b>Created:</b>",
|
||||
creation_time.strftime(datetime_format),
|
||||
"<b>Modified:</b>",
|
||||
modification_time.strftime(datetime_format)
|
||||
)
|
||||
self._details_input.appendHtml("<br>".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)
|
||||
window_flags = QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint
|
||||
if not parent:
|
||||
window_flags |= QtCore.Qt.WindowStaysOnTopHint
|
||||
self.setWindowFlags(window_flags)
|
||||
|
||||
# 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 = SingleSelectAssetsWidget(io, parent=home_body_widget)
|
||||
assets_widget.set_current_asset_btn_visibility(True)
|
||||
|
||||
tasks_widget = TasksWidget(io, 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.setSizes([255, 160, 455, 175])
|
||||
|
||||
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)
|
||||
|
||||
# Set context after asset widget is refreshed
|
||||
# - to do so it is necessary to wait until refresh is done
|
||||
set_context_timer = QtCore.QTimer()
|
||||
set_context_timer.setInterval(100)
|
||||
|
||||
# Connect signals
|
||||
set_context_timer.timeout.connect(self._on_context_set_timeout)
|
||||
assets_widget.selection_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)
|
||||
files_widget.file_opened.connect(self._on_file_opened)
|
||||
files_widget.publish_file_viewed.connect(
|
||||
self._on_publish_file_viewed
|
||||
)
|
||||
files_widget.published_visible_changed.connect(
|
||||
self._on_published_change
|
||||
)
|
||||
side_panel.save_clicked.connect(self.on_side_panel_save)
|
||||
|
||||
self._set_context_timer = set_context_timer
|
||||
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
|
||||
|
||||
# Force focus on the open button by default, required for Houdini.
|
||||
files_widget.setFocus()
|
||||
|
||||
self.resize(1200, 600)
|
||||
|
||||
self._first_show = True
|
||||
self._context_to_set = None
|
||||
|
||||
def showEvent(self, event):
|
||||
super(Window, self).showEvent(event)
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self.refresh()
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
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 accidentally perform Maya commands
|
||||
whilst trying to name an instance.
|
||||
|
||||
"""
|
||||
|
||||
def set_save_enabled(self, enabled):
|
||||
self.files_widget.set_save_enabled(enabled)
|
||||
|
||||
def on_file_select(self, filepath):
|
||||
asset_id = self.assets_widget.get_selected_asset_id()
|
||||
task_name = self.tasks_widget.get_selected_task_name()
|
||||
|
||||
workfile_doc = None
|
||||
if asset_id and task_name and filepath:
|
||||
filename = os.path.split(filepath)[1]
|
||||
workfile_doc = get_workfile_doc(
|
||||
asset_id, task_name, filename, io
|
||||
)
|
||||
self.side_panel.set_context(
|
||||
asset_id, task_name, filepath, workfile_doc
|
||||
)
|
||||
|
||||
def on_workfile_create(self, filepath):
|
||||
self._create_workfile_doc(filepath)
|
||||
|
||||
def _on_file_opened(self):
|
||||
self.close()
|
||||
|
||||
def _on_publish_file_viewed(self):
|
||||
self.side_panel.refresh_publish_temp_sizes()
|
||||
|
||||
def _on_published_change(self, visible):
|
||||
self.side_panel.set_published_visible(visible)
|
||||
|
||||
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_selected_task_name()
|
||||
asset_id = self.assets_widget.get_selected_asset_id()
|
||||
if not task_name or not asset_id or not filepath:
|
||||
return
|
||||
|
||||
filename = os.path.split(filepath)[1]
|
||||
return get_workfile_doc(
|
||||
asset_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_id = self.assets_widget.get_selected_asset_id()
|
||||
asset_doc = io.find_one({"_id": asset_id})
|
||||
task_name = self.tasks_widget.get_selected_task_name()
|
||||
create_workfile_doc(asset_doc, task_name, filename, workdir, io)
|
||||
|
||||
def refresh(self):
|
||||
# Refresh asset widget
|
||||
self.assets_widget.refresh()
|
||||
|
||||
self._on_task_changed()
|
||||
|
||||
def set_context(self, context):
|
||||
self._context_to_set = context
|
||||
self._set_context_timer.start()
|
||||
|
||||
def _on_context_set_timeout(self):
|
||||
if self._context_to_set is None:
|
||||
self._set_context_timer.stop()
|
||||
return
|
||||
|
||||
if self.assets_widget.refreshing:
|
||||
return
|
||||
|
||||
self._context_to_set, context = None, self._context_to_set
|
||||
if "asset" in context:
|
||||
asset_doc = io.find_one(
|
||||
{
|
||||
"name": context["asset"],
|
||||
"type": "asset"
|
||||
},
|
||||
{"_id": 1}
|
||||
) or {}
|
||||
asset_id = asset_doc.get("_id")
|
||||
# Select the asset
|
||||
self.assets_widget.select_asset(asset_id)
|
||||
self.tasks_widget.set_asset_id(asset_id)
|
||||
|
||||
if "task" in context:
|
||||
self.tasks_widget.select_task_name(context["task"])
|
||||
self._on_task_changed()
|
||||
|
||||
def _on_asset_changed(self):
|
||||
asset_id = self.assets_widget.get_selected_asset_id()
|
||||
if asset_id:
|
||||
self.tasks_widget.setEnabled(True)
|
||||
else:
|
||||
# Force disable the other widgets if no
|
||||
# active selection
|
||||
self.tasks_widget.setEnabled(False)
|
||||
self.files_widget.setEnabled(False)
|
||||
|
||||
self.tasks_widget.set_asset_id(asset_id)
|
||||
|
||||
def _on_task_changed(self):
|
||||
asset_id = self.assets_widget.get_selected_asset_id()
|
||||
task_name = self.tasks_widget.get_selected_task_name()
|
||||
task_type = self.tasks_widget.get_selected_task_type()
|
||||
|
||||
asset_is_valid = asset_id is not None
|
||||
self.tasks_widget.setEnabled(asset_is_valid)
|
||||
|
||||
self.files_widget.setEnabled(bool(task_name) and asset_is_valid)
|
||||
self.files_widget.set_asset_task(asset_id, task_name, task_type)
|
||||
self.files_widget.refresh()
|
||||
Loading…
Add table
Add a link
Reference in a new issue