Merge branch 'develop' into enhancement/OP-2855_move-plugins-register-and-discover

This commit is contained in:
Jakub Trllo 2022-03-30 16:06:20 +02:00
commit 4b6ccc9ee3
69 changed files with 3629 additions and 1780 deletions

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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