From de5c4bffc46e5e2e93c6ea7b993e48d4b79da0a8 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 28 Jun 2022 16:50:22 +0200 Subject: [PATCH] adding shotgrid back to a realease --- .../plugins/publish/submit_maya_deadline.py | 1 + openpype/modules/shotgrid/README.md | 19 ++ openpype/modules/shotgrid/__init__.py | 5 + openpype/modules/shotgrid/lib/__init__.py | 0 openpype/modules/shotgrid/lib/const.py | 1 + openpype/modules/shotgrid/lib/credentials.py | 125 +++++++++++ openpype/modules/shotgrid/lib/record.py | 20 ++ openpype/modules/shotgrid/lib/settings.py | 18 ++ .../publish/collect_shotgrid_entities.py | 100 +++++++++ .../publish/collect_shotgrid_session.py | 123 +++++++++++ .../publish/integrate_shotgrid_publish.py | 77 +++++++ .../publish/integrate_shotgrid_version.py | 92 ++++++++ .../plugins/publish/validate_shotgrid_user.py | 38 ++++ openpype/modules/shotgrid/server/README.md | 5 + openpype/modules/shotgrid/shotgrid_module.py | 58 +++++ .../tests/shotgrid/lib/test_credentials.py | 34 +++ .../shotgrid/tray/credential_dialog.py | 201 ++++++++++++++++++ .../modules/shotgrid/tray/shotgrid_tray.py | 75 +++++++ openpype/resources/app_icons/shotgrid.png | Bin 0 -> 45744 bytes .../defaults/project_settings/shotgrid.json | 22 ++ .../defaults/system_settings/modules.json | 8 +- openpype/settings/entities/__init__.py | 2 + openpype/settings/entities/enum_entity.py | 114 ++++++---- .../schemas/projects_schema/schema_main.json | 4 + .../schema_project_shotgrid.json | 98 +++++++++ .../schemas/schema_representation_tags.json | 3 + .../schemas/system_schema/schema_modules.json | 54 +++++ poetry.lock | 16 ++ pyproject.toml | 1 + 29 files changed, 1276 insertions(+), 38 deletions(-) create mode 100644 openpype/modules/shotgrid/README.md create mode 100644 openpype/modules/shotgrid/__init__.py create mode 100644 openpype/modules/shotgrid/lib/__init__.py create mode 100644 openpype/modules/shotgrid/lib/const.py create mode 100644 openpype/modules/shotgrid/lib/credentials.py create mode 100644 openpype/modules/shotgrid/lib/record.py create mode 100644 openpype/modules/shotgrid/lib/settings.py create mode 100644 openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py create mode 100644 openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py create mode 100644 openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py create mode 100644 openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py create mode 100644 openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py create mode 100644 openpype/modules/shotgrid/server/README.md create mode 100644 openpype/modules/shotgrid/shotgrid_module.py create mode 100644 openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py create mode 100644 openpype/modules/shotgrid/tray/credential_dialog.py create mode 100644 openpype/modules/shotgrid/tray/shotgrid_tray.py create mode 100644 openpype/resources/app_icons/shotgrid.png create mode 100644 openpype/settings/defaults/project_settings/shotgrid.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 9964e3c646..dff80e62b9 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -519,6 +519,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", + "OPENPYPE_SG_USER", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", diff --git a/openpype/modules/shotgrid/README.md b/openpype/modules/shotgrid/README.md new file mode 100644 index 0000000000..cbee0e9bf4 --- /dev/null +++ b/openpype/modules/shotgrid/README.md @@ -0,0 +1,19 @@ +## Shotgrid Module + +### Pre-requisites + +Install and launch a [shotgrid leecher](https://github.com/Ellipsanime/shotgrid-leecher) server + +### Quickstart + +The goal of this tutorial is to synchronize an already existing shotgrid project with OpenPype. + +- Activate the shotgrid module in the **system settings** and inform the shotgrid leecher server API url + +- Create a new OpenPype project with the **project manager** + +- Inform the shotgrid authentication infos (url, script name, api key) and the shotgrid project ID related to this OpenPype project in the **project settings** + +- Use the batch interface (Tray > shotgrid > Launch batch), select your project and click "batch" + +- You can now access your shotgrid entities within the **avalon launcher** and publish informations to shotgrid with **pyblish** diff --git a/openpype/modules/shotgrid/__init__.py b/openpype/modules/shotgrid/__init__.py new file mode 100644 index 0000000000..f1337a9492 --- /dev/null +++ b/openpype/modules/shotgrid/__init__.py @@ -0,0 +1,5 @@ +from .shotgrid_module import ( + ShotgridModule, +) + +__all__ = ("ShotgridModule",) diff --git a/openpype/modules/shotgrid/lib/__init__.py b/openpype/modules/shotgrid/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/modules/shotgrid/lib/const.py b/openpype/modules/shotgrid/lib/const.py new file mode 100644 index 0000000000..2a34800fac --- /dev/null +++ b/openpype/modules/shotgrid/lib/const.py @@ -0,0 +1 @@ +MODULE_NAME = "shotgrid" diff --git a/openpype/modules/shotgrid/lib/credentials.py b/openpype/modules/shotgrid/lib/credentials.py new file mode 100644 index 0000000000..337c4f6ecb --- /dev/null +++ b/openpype/modules/shotgrid/lib/credentials.py @@ -0,0 +1,125 @@ + +from urllib.parse import urlparse + +import shotgun_api3 +from shotgun_api3.shotgun import AuthenticationFault + +from openpype.lib import OpenPypeSecureRegistry, OpenPypeSettingsRegistry +from openpype.modules.shotgrid.lib.record import Credentials + + +def _get_shotgrid_secure_key(hostname, key): + """Secure item key for entered hostname.""" + return f"shotgrid/{hostname}/{key}" + + +def _get_secure_value_and_registry( + hostname, + name, +): + key = _get_shotgrid_secure_key(hostname, name) + registry = OpenPypeSecureRegistry(key) + return registry.get_item(name, None), registry + + +def get_shotgrid_hostname(shotgrid_url): + + if not shotgrid_url: + raise Exception("Shotgrid url cannot be a null") + valid_shotgrid_url = ( + f"//{shotgrid_url}" if "//" not in shotgrid_url else shotgrid_url + ) + return urlparse(valid_shotgrid_url).hostname + + +# Credentials storing function (using keyring) + + +def get_credentials(shotgrid_url): + hostname = get_shotgrid_hostname(shotgrid_url) + if not hostname: + return None + login_value, _ = _get_secure_value_and_registry( + hostname, + Credentials.login_key_prefix(), + ) + password_value, _ = _get_secure_value_and_registry( + hostname, + Credentials.password_key_prefix(), + ) + return Credentials(login_value, password_value) + + +def save_credentials(login, password, shotgrid_url): + hostname = get_shotgrid_hostname(shotgrid_url) + _, login_registry = _get_secure_value_and_registry( + hostname, + Credentials.login_key_prefix(), + ) + _, password_registry = _get_secure_value_and_registry( + hostname, + Credentials.password_key_prefix(), + ) + clear_credentials(shotgrid_url) + login_registry.set_item(Credentials.login_key_prefix(), login) + password_registry.set_item(Credentials.password_key_prefix(), password) + + +def clear_credentials(shotgrid_url): + hostname = get_shotgrid_hostname(shotgrid_url) + login_value, login_registry = _get_secure_value_and_registry( + hostname, + Credentials.login_key_prefix(), + ) + password_value, password_registry = _get_secure_value_and_registry( + hostname, + Credentials.password_key_prefix(), + ) + + if login_value is not None: + login_registry.delete_item(Credentials.login_key_prefix()) + + if password_value is not None: + password_registry.delete_item(Credentials.password_key_prefix()) + + +# Login storing function (using json) + + +def get_local_login(): + reg = OpenPypeSettingsRegistry() + try: + return str(reg.get_item("shotgrid_login")) + except Exception: + return None + + +def save_local_login(login): + reg = OpenPypeSettingsRegistry() + reg.set_item("shotgrid_login", login) + + +def clear_local_login(): + reg = OpenPypeSettingsRegistry() + reg.delete_item("shotgrid_login") + + +def check_credentials( + login, + password, + shotgrid_url, +): + + if not shotgrid_url or not login or not password: + return False + try: + session = shotgun_api3.Shotgun( + shotgrid_url, + login=login, + password=password, + ) + session.preferences_read() + session.close() + except AuthenticationFault: + return False + return True diff --git a/openpype/modules/shotgrid/lib/record.py b/openpype/modules/shotgrid/lib/record.py new file mode 100644 index 0000000000..f62f4855d5 --- /dev/null +++ b/openpype/modules/shotgrid/lib/record.py @@ -0,0 +1,20 @@ + +class Credentials: + login = None + password = None + + def __init__(self, login, password) -> None: + super().__init__() + self.login = login + self.password = password + + def is_empty(self): + return not (self.login and self.password) + + @staticmethod + def login_key_prefix(): + return "login" + + @staticmethod + def password_key_prefix(): + return "password" diff --git a/openpype/modules/shotgrid/lib/settings.py b/openpype/modules/shotgrid/lib/settings.py new file mode 100644 index 0000000000..924099f04b --- /dev/null +++ b/openpype/modules/shotgrid/lib/settings.py @@ -0,0 +1,18 @@ +from openpype.api import get_system_settings, get_project_settings +from openpype.modules.shotgrid.lib.const import MODULE_NAME + + +def get_shotgrid_project_settings(project): + return get_project_settings(project).get(MODULE_NAME, {}) + + +def get_shotgrid_settings(): + return get_system_settings().get("modules", {}).get(MODULE_NAME, {}) + + +def get_shotgrid_servers(): + return get_shotgrid_settings().get("shotgrid_settings", {}) + + +def get_leecher_backend_url(): + return get_shotgrid_settings().get("leecher_backend_url") diff --git a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py new file mode 100644 index 0000000000..0b03ac2e5d --- /dev/null +++ b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_entities.py @@ -0,0 +1,100 @@ +import os + +import pyblish.api +from openpype.lib.mongo import OpenPypeMongoConnection + + +class CollectShotgridEntities(pyblish.api.ContextPlugin): + """Collect shotgrid entities according to the current context""" + + order = pyblish.api.CollectorOrder + 0.499 + label = "Shotgrid entities" + + def process(self, context): + + avalon_project = context.data.get("projectEntity") + avalon_asset = context.data.get("assetEntity") + avalon_task_name = os.getenv("AVALON_TASK") + + self.log.info(avalon_project) + self.log.info(avalon_asset) + + sg_project = _get_shotgrid_project(context) + sg_task = _get_shotgrid_task( + avalon_project, + avalon_asset, + avalon_task_name + ) + sg_entity = _get_shotgrid_entity(avalon_project, avalon_asset) + + if sg_project: + context.data["shotgridProject"] = sg_project + self.log.info( + "Collected correspondig shotgrid project : {}".format( + sg_project + ) + ) + + if sg_task: + context.data["shotgridTask"] = sg_task + self.log.info( + "Collected correspondig shotgrid task : {}".format(sg_task) + ) + + if sg_entity: + context.data["shotgridEntity"] = sg_entity + self.log.info( + "Collected correspondig shotgrid entity : {}".format(sg_entity) + ) + + def _find_existing_version(self, code, context): + + filters = [ + ["project", "is", context.data.get("shotgridProject")], + ["sg_task", "is", context.data.get("shotgridTask")], + ["entity", "is", context.data.get("shotgridEntity")], + ["code", "is", code], + ] + + sg = context.data.get("shotgridSession") + return sg.find_one("Version", filters, []) + + +def _get_shotgrid_collection(project): + client = OpenPypeMongoConnection.get_mongo_client() + return client.get_database("shotgrid_openpype").get_collection(project) + + +def _get_shotgrid_project(context): + shotgrid_project_id = context.data["project_settings"].get( + "shotgrid_project_id") + if shotgrid_project_id: + return {"type": "Project", "id": shotgrid_project_id} + return {} + + +def _get_shotgrid_task(avalon_project, avalon_asset, avalon_task): + sg_col = _get_shotgrid_collection(avalon_project["name"]) + shotgrid_task_hierarchy_row = sg_col.find_one( + { + "type": "Task", + "_id": {"$regex": "^" + avalon_task + "_[0-9]*"}, + "parent": {"$regex": ".*," + avalon_asset["name"] + ","}, + } + ) + if shotgrid_task_hierarchy_row: + return {"type": "Task", "id": shotgrid_task_hierarchy_row["src_id"]} + return {} + + +def _get_shotgrid_entity(avalon_project, avalon_asset): + sg_col = _get_shotgrid_collection(avalon_project["name"]) + shotgrid_entity_hierarchy_row = sg_col.find_one( + {"_id": avalon_asset["name"]} + ) + if shotgrid_entity_hierarchy_row: + return { + "type": shotgrid_entity_hierarchy_row["type"], + "id": shotgrid_entity_hierarchy_row["src_id"], + } + return {} diff --git a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py new file mode 100644 index 0000000000..9d5d2271bf --- /dev/null +++ b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py @@ -0,0 +1,123 @@ +import os + +import pyblish.api +import shotgun_api3 +from shotgun_api3.shotgun import AuthenticationFault + +from openpype.lib import OpenPypeSettingsRegistry +from openpype.modules.shotgrid.lib.settings import ( + get_shotgrid_servers, + get_shotgrid_project_settings, +) + + +class CollectShotgridSession(pyblish.api.ContextPlugin): + """Collect shotgrid session using user credentials""" + + order = pyblish.api.CollectorOrder + label = "Shotgrid user session" + + def process(self, context): + + certificate_path = os.getenv("SHOTGUN_API_CACERTS") + if certificate_path is None or not os.path.exists(certificate_path): + self.log.info( + "SHOTGUN_API_CACERTS does not contains a valid \ + path: {}".format( + certificate_path + ) + ) + certificate_path = get_shotgrid_certificate() + self.log.info("Get Certificate from shotgrid_api") + + if not os.path.exists(certificate_path): + self.log.error( + "Could not find certificate in shotgun_api3: \ + {}".format( + certificate_path + ) + ) + return + + set_shotgrid_certificate(certificate_path) + self.log.info("Set Certificate: {}".format(certificate_path)) + + avalon_project = os.getenv("AVALON_PROJECT") + + shotgrid_settings = get_shotgrid_project_settings(avalon_project) + self.log.info("shotgrid settings: {}".format(shotgrid_settings)) + shotgrid_servers_settings = get_shotgrid_servers() + self.log.info( + "shotgrid_servers_settings: {}".format(shotgrid_servers_settings) + ) + + shotgrid_server = shotgrid_settings.get("shotgrid_server", "") + if not shotgrid_server: + self.log.error( + "No Shotgrid server found, please choose a credential" + "in script name and script key in OpenPype settings" + ) + + shotgrid_server_setting = shotgrid_servers_settings.get( + shotgrid_server, {} + ) + shotgrid_url = shotgrid_server_setting.get("shotgrid_url", "") + + shotgrid_script_name = shotgrid_server_setting.get( + "shotgrid_script_name", "" + ) + shotgrid_script_key = shotgrid_server_setting.get( + "shotgrid_script_key", "" + ) + if not shotgrid_script_name and not shotgrid_script_key: + self.log.error( + "No Shotgrid api credential found, please enter " + "script name and script key in OpenPype settings" + ) + + login = get_login() or os.getenv("OPENPYPE_SG_USER") + + if not login: + self.log.error( + "No Shotgrid login found, please " + "login to shotgrid withing openpype Tray" + ) + + session = shotgun_api3.Shotgun( + base_url=shotgrid_url, + script_name=shotgrid_script_name, + api_key=shotgrid_script_key, + sudo_as_login=login, + ) + + try: + session.preferences_read() + except AuthenticationFault: + raise ValueError( + "Could not connect to shotgrid {} with user {}".format( + shotgrid_url, login + ) + ) + + self.log.info( + "Logged to shotgrid {} with user {}".format(shotgrid_url, login) + ) + context.data["shotgridSession"] = session + context.data["shotgridUser"] = login + + +def get_shotgrid_certificate(): + shotgun_api_path = os.path.dirname(shotgun_api3.__file__) + return os.path.join(shotgun_api_path, "lib", "certifi", "cacert.pem") + + +def set_shotgrid_certificate(certificate): + os.environ["SHOTGUN_API_CACERTS"] = certificate + + +def get_login(): + reg = OpenPypeSettingsRegistry() + try: + return str(reg.get_item("shotgrid_login")) + except Exception: + return None diff --git a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py new file mode 100644 index 0000000000..cfd2d10fd9 --- /dev/null +++ b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py @@ -0,0 +1,77 @@ +import os +import pyblish.api + + +class IntegrateShotgridPublish(pyblish.api.InstancePlugin): + """ + Create published Files from representations and add it to version. If + representation is tagged add shotgrid review, it will add it in + path to movie for a movie file or path to frame for an image sequence. + """ + + order = pyblish.api.IntegratorOrder + 0.499 + label = "Shotgrid Published Files" + + def process(self, instance): + + context = instance.context + + self.sg = context.data.get("shotgridSession") + + shotgrid_version = instance.data.get("shotgridVersion") + + for representation in instance.data.get("representations", []): + + local_path = representation.get("published_path") + code = os.path.basename(local_path) + + if representation.get("tags", []): + continue + + published_file = self._find_existing_publish( + code, context, shotgrid_version + ) + + published_file_data = { + "project": context.data.get("shotgridProject"), + "code": code, + "entity": context.data.get("shotgridEntity"), + "task": context.data.get("shotgridTask"), + "version": shotgrid_version, + "path": {"local_path": local_path}, + } + if not published_file: + published_file = self._create_published(published_file_data) + self.log.info( + "Create Shotgrid PublishedFile: {}".format(published_file) + ) + else: + self.sg.update( + published_file["type"], + published_file["id"], + published_file_data, + ) + self.log.info( + "Update Shotgrid PublishedFile: {}".format(published_file) + ) + + if instance.data["family"] == "image": + self.sg.upload_thumbnail( + published_file["type"], published_file["id"], local_path + ) + instance.data["shotgridPublishedFile"] = published_file + + def _find_existing_publish(self, code, context, shotgrid_version): + + filters = [ + ["project", "is", context.data.get("shotgridProject")], + ["task", "is", context.data.get("shotgridTask")], + ["entity", "is", context.data.get("shotgridEntity")], + ["version", "is", shotgrid_version], + ["code", "is", code], + ] + return self.sg.find_one("PublishedFile", filters, []) + + def _create_published(self, published_file_data): + + return self.sg.create("PublishedFile", published_file_data) diff --git a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py new file mode 100644 index 0000000000..a1b7140e22 --- /dev/null +++ b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py @@ -0,0 +1,92 @@ +import os +import pyblish.api + + +class IntegrateShotgridVersion(pyblish.api.InstancePlugin): + """Integrate Shotgrid Version""" + + order = pyblish.api.IntegratorOrder + 0.497 + label = "Shotgrid Version" + + sg = None + + def process(self, instance): + + context = instance.context + self.sg = context.data.get("shotgridSession") + + # TODO: Use path template solver to build version code from settings + anatomy = instance.data.get("anatomyData", {}) + code = "_".join( + [ + anatomy["project"]["code"], + anatomy["parent"], + anatomy["asset"], + anatomy["task"]["name"], + "v{:03}".format(int(anatomy["version"])), + ] + ) + + version = self._find_existing_version(code, context) + + if not version: + version = self._create_version(code, context) + self.log.info("Create Shotgrid version: {}".format(version)) + else: + self.log.info("Use existing Shotgrid version: {}".format(version)) + + data_to_update = {} + status = context.data.get("intent", {}).get("value") + if status: + data_to_update["sg_status_list"] = status + + for representation in instance.data.get("representations", []): + local_path = representation.get("published_path") + code = os.path.basename(local_path) + + if "shotgridreview" in representation.get("tags", []): + + if representation["ext"] in ["mov", "avi"]: + self.log.info( + "Upload review: {} for version shotgrid {}".format( + local_path, version.get("id") + ) + ) + self.sg.upload( + "Version", + version.get("id"), + local_path, + field_name="sg_uploaded_movie", + ) + + data_to_update["sg_path_to_movie"] = local_path + + elif representation["ext"] in ["jpg", "png", "exr", "tga"]: + path_to_frame = local_path.replace("0000", "#") + data_to_update["sg_path_to_frames"] = path_to_frame + + self.log.info("Update Shotgrid version with {}".format(data_to_update)) + self.sg.update("Version", version["id"], data_to_update) + + instance.data["shotgridVersion"] = version + + def _find_existing_version(self, code, context): + + filters = [ + ["project", "is", context.data.get("shotgridProject")], + ["sg_task", "is", context.data.get("shotgridTask")], + ["entity", "is", context.data.get("shotgridEntity")], + ["code", "is", code], + ] + return self.sg.find_one("Version", filters, []) + + def _create_version(self, code, context): + + version_data = { + "project": context.data.get("shotgridProject"), + "sg_task": context.data.get("shotgridTask"), + "entity": context.data.get("shotgridEntity"), + "code": code, + } + + return self.sg.create("Version", version_data) diff --git a/openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py b/openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py new file mode 100644 index 0000000000..c14c980e2a --- /dev/null +++ b/openpype/modules/shotgrid/plugins/publish/validate_shotgrid_user.py @@ -0,0 +1,38 @@ +import pyblish.api +import openpype.api + + +class ValidateShotgridUser(pyblish.api.ContextPlugin): + """ + Check if user is valid and have access to the project. + """ + + label = "Validate Shotgrid User" + order = openpype.api.ValidateContentsOrder + + def process(self, context): + sg = context.data.get("shotgridSession") + + login = context.data.get("shotgridUser") + self.log.info("Login shotgrid set in OpenPype is {}".format(login)) + project = context.data.get("shotgridProject") + self.log.info("Current shotgun project is {}".format(project)) + + if not (login and sg and project): + raise KeyError() + + user = sg.find_one("HumanUser", [["login", "is", login]], ["projects"]) + + self.log.info(user) + self.log.info(login) + user_projects_id = [p["id"] for p in user.get("projects", [])] + if not project.get("id") in user_projects_id: + raise PermissionError( + "Login {} don't have access to the project {}".format( + login, project + ) + ) + + self.log.info( + "Login {} have access to the project {}".format(login, project) + ) diff --git a/openpype/modules/shotgrid/server/README.md b/openpype/modules/shotgrid/server/README.md new file mode 100644 index 0000000000..15e056ff3e --- /dev/null +++ b/openpype/modules/shotgrid/server/README.md @@ -0,0 +1,5 @@ + +### Shotgrid server + +Please refer to the external project that covers Openpype/Shotgrid communication: + - https://github.com/Ellipsanime/shotgrid-leecher diff --git a/openpype/modules/shotgrid/shotgrid_module.py b/openpype/modules/shotgrid/shotgrid_module.py new file mode 100644 index 0000000000..5644f0c35f --- /dev/null +++ b/openpype/modules/shotgrid/shotgrid_module.py @@ -0,0 +1,58 @@ +import os + +from openpype_interfaces import ( + ITrayModule, + IPluginPaths, + ILaunchHookPaths, +) + +from openpype.modules import OpenPypeModule + +SHOTGRID_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class ShotgridModule( + OpenPypeModule, ITrayModule, IPluginPaths, ILaunchHookPaths +): + leecher_manager_url = None + name = "shotgrid" + enabled = False + project_id = None + tray_wrapper = None + + def initialize(self, modules_settings): + shotgrid_settings = modules_settings.get(self.name, dict()) + self.enabled = shotgrid_settings.get("enabled", False) + self.leecher_manager_url = shotgrid_settings.get( + "leecher_manager_url", "" + ) + + def connect_with_modules(self, enabled_modules): + pass + + def get_global_environments(self): + return {"PROJECT_ID": self.project_id} + + def get_plugin_paths(self): + return { + "publish": [ + os.path.join(SHOTGRID_MODULE_DIR, "plugins", "publish") + ] + } + + def get_launch_hook_paths(self): + return os.path.join(SHOTGRID_MODULE_DIR, "hooks") + + def tray_init(self): + from .tray.shotgrid_tray import ShotgridTrayWrapper + + self.tray_wrapper = ShotgridTrayWrapper(self) + + def tray_start(self): + return self.tray_wrapper.validate() + + def tray_exit(self, *args, **kwargs): + return self.tray_wrapper + + def tray_menu(self, tray_menu): + return self.tray_wrapper.tray_menu(tray_menu) diff --git a/openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py b/openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py new file mode 100644 index 0000000000..1f78cf77c9 --- /dev/null +++ b/openpype/modules/shotgrid/tests/shotgrid/lib/test_credentials.py @@ -0,0 +1,34 @@ +import pytest +from assertpy import assert_that + +import openpype.modules.shotgrid.lib.credentials as sut + + +def test_missing_shotgrid_url(): + with pytest.raises(Exception) as ex: + # arrange + url = "" + # act + sut.get_shotgrid_hostname(url) + # assert + assert_that(ex).is_equal_to("Shotgrid url cannot be a null") + + +def test_full_shotgrid_url(): + # arrange + url = "https://shotgrid.com/myinstance" + # act + actual = sut.get_shotgrid_hostname(url) + # assert + assert_that(actual).is_not_empty() + assert_that(actual).is_equal_to("shotgrid.com") + + +def test_incomplete_shotgrid_url(): + # arrange + url = "shotgrid.com/myinstance" + # act + actual = sut.get_shotgrid_hostname(url) + # assert + assert_that(actual).is_not_empty() + assert_that(actual).is_equal_to("shotgrid.com") diff --git a/openpype/modules/shotgrid/tray/credential_dialog.py b/openpype/modules/shotgrid/tray/credential_dialog.py new file mode 100644 index 0000000000..9d841d98be --- /dev/null +++ b/openpype/modules/shotgrid/tray/credential_dialog.py @@ -0,0 +1,201 @@ +import os +from Qt import QtCore, QtWidgets, QtGui + +from openpype import style +from openpype import resources +from openpype.modules.shotgrid.lib import settings, credentials + + +class CredentialsDialog(QtWidgets.QDialog): + SIZE_W = 450 + SIZE_H = 200 + + _module = None + _is_logged = False + url_label = None + login_label = None + password_label = None + url_input = None + login_input = None + password_input = None + input_layout = None + login_button = None + buttons_layout = None + main_widget = None + + login_changed = QtCore.Signal() + + def __init__(self, module, parent=None): + super(CredentialsDialog, self).__init__(parent) + + self._module = module + self._is_logged = False + + self.setWindowTitle("OpenPype - Shotgrid Login") + + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) + self.setMaximumSize(QtCore.QSize(self.SIZE_W + 100, self.SIZE_H + 100)) + self.setStyleSheet(style.load_stylesheet()) + + self.ui_init() + + def ui_init(self): + self.url_label = QtWidgets.QLabel("Shotgrid server:") + self.login_label = QtWidgets.QLabel("Login:") + self.password_label = QtWidgets.QLabel("Password:") + + self.url_input = QtWidgets.QComboBox() + # self.url_input.setReadOnly(True) + + self.login_input = QtWidgets.QLineEdit() + self.login_input.setPlaceholderText("login") + + self.password_input = QtWidgets.QLineEdit() + self.password_input.setPlaceholderText("password") + self.password_input.setEchoMode(QtWidgets.QLineEdit.Password) + + self.error_label = QtWidgets.QLabel("") + self.error_label.setStyleSheet("color: red;") + self.error_label.setWordWrap(True) + self.error_label.hide() + + self.input_layout = QtWidgets.QFormLayout() + self.input_layout.setContentsMargins(10, 15, 10, 5) + + self.input_layout.addRow(self.url_label, self.url_input) + self.input_layout.addRow(self.login_label, self.login_input) + self.input_layout.addRow(self.password_label, self.password_input) + self.input_layout.addRow(self.error_label) + + self.login_button = QtWidgets.QPushButton("Login") + self.login_button.setToolTip("Log in shotgrid instance") + self.login_button.clicked.connect(self._on_shotgrid_login_clicked) + + self.logout_button = QtWidgets.QPushButton("Logout") + self.logout_button.setToolTip("Log out shotgrid instance") + self.logout_button.clicked.connect(self._on_shotgrid_logout_clicked) + + self.buttons_layout = QtWidgets.QHBoxLayout() + self.buttons_layout.addWidget(self.logout_button) + self.buttons_layout.addWidget(self.login_button) + + self.main_widget = QtWidgets.QVBoxLayout(self) + self.main_widget.addLayout(self.input_layout) + self.main_widget.addLayout(self.buttons_layout) + self.setLayout(self.main_widget) + + def show(self, *args, **kwargs): + super(CredentialsDialog, self).show(*args, **kwargs) + self._fill_shotgrid_url() + self._fill_shotgrid_login() + + def _fill_shotgrid_url(self): + servers = settings.get_shotgrid_servers() + + if servers: + for _, v in servers.items(): + self.url_input.addItem("{}".format(v.get('shotgrid_url'))) + self._valid_input(self.url_input) + self.login_button.show() + self.logout_button.show() + enabled = True + else: + self.set_error("Ask your admin to add shotgrid server in settings") + self._invalid_input(self.url_input) + self.login_button.hide() + self.logout_button.hide() + enabled = False + + self.login_input.setEnabled(enabled) + self.password_input.setEnabled(enabled) + + def _fill_shotgrid_login(self): + login = credentials.get_local_login() + + if login: + self.login_input.setText(login) + + def _clear_shotgrid_login(self): + self.login_input.setText("") + self.password_input.setText("") + + def _on_shotgrid_login_clicked(self): + login = self.login_input.text().strip() + password = self.password_input.text().strip() + missing = [] + + if login == "": + missing.append("login") + self._invalid_input(self.login_input) + + if password == "": + missing.append("password") + self._invalid_input(self.password_input) + + url = self.url_input.currentText() + if url == "": + missing.append("url") + self._invalid_input(self.url_input) + + if len(missing) > 0: + self.set_error("You didn't enter {}".format(" and ".join(missing))) + return + + # if credentials.check_credentials( + # login=login, + # password=password, + # shotgrid_url=url, + # ): + credentials.save_local_login( + login=login + ) + os.environ['OPENPYPE_SG_USER'] = login + self._on_login() + + self.set_error("CANT LOGIN") + + def _on_shotgrid_logout_clicked(self): + credentials.clear_local_login() + del os.environ['OPENPYPE_SG_USER'] + self._clear_shotgrid_login() + self._on_logout() + + def set_error(self, msg): + self.error_label.setText(msg) + self.error_label.show() + + def _on_login(self): + self._is_logged = True + self.login_changed.emit() + self._close_widget() + + def _on_logout(self): + self._is_logged = False + self.login_changed.emit() + + def _close_widget(self): + self.hide() + + def _valid_input(self, input_widget): + input_widget.setStyleSheet("") + + def _invalid_input(self, input_widget): + input_widget.setStyleSheet("border: 1px solid red;") + + def login_with_credentials( + self, url, login, password + ): + verification = credentials.check_credentials(url, login, password) + if verification: + credentials.save_credentials(login, password, False) + self._module.set_credentials_to_env(login, password) + self.set_credentials(login, password) + self.login_changed.emit() + return verification diff --git a/openpype/modules/shotgrid/tray/shotgrid_tray.py b/openpype/modules/shotgrid/tray/shotgrid_tray.py new file mode 100644 index 0000000000..4038d77b03 --- /dev/null +++ b/openpype/modules/shotgrid/tray/shotgrid_tray.py @@ -0,0 +1,75 @@ +import os +import webbrowser + +from Qt import QtWidgets + +from openpype.modules.shotgrid.lib import credentials +from openpype.modules.shotgrid.tray.credential_dialog import ( + CredentialsDialog, +) + + +class ShotgridTrayWrapper: + module = None + credentials_dialog = None + logged_user_label = None + + def __init__(self, module): + self.module = module + self.credentials_dialog = CredentialsDialog(module) + self.credentials_dialog.login_changed.connect(self.set_login_label) + self.logged_user_label = QtWidgets.QAction("") + self.logged_user_label.setDisabled(True) + self.set_login_label() + + def show_batch_dialog(self): + if self.module.leecher_manager_url: + webbrowser.open(self.module.leecher_manager_url) + + def show_connect_dialog(self): + self.show_credential_dialog() + + def show_credential_dialog(self): + self.credentials_dialog.show() + self.credentials_dialog.activateWindow() + self.credentials_dialog.raise_() + + def set_login_label(self): + login = credentials.get_local_login() + if login: + self.logged_user_label.setText("{}".format(login)) + else: + self.logged_user_label.setText( + "No User logged in {0}".format(login) + ) + + def tray_menu(self, tray_menu): + # Add login to user menu + menu = QtWidgets.QMenu("Shotgrid", tray_menu) + show_connect_action = QtWidgets.QAction("Connect to Shotgrid", menu) + show_connect_action.triggered.connect(self.show_connect_dialog) + menu.addAction(self.logged_user_label) + menu.addSeparator() + menu.addAction(show_connect_action) + tray_menu.addMenu(menu) + + # Add manager to Admin menu + for m in tray_menu.findChildren(QtWidgets.QMenu): + if m.title() == "Admin": + shotgrid_manager_action = QtWidgets.QAction( + "Shotgrid manager", menu + ) + shotgrid_manager_action.triggered.connect( + self.show_batch_dialog + ) + m.addAction(shotgrid_manager_action) + + def validate(self): + login = credentials.get_local_login() + + if not login: + self.show_credential_dialog() + else: + os.environ["OPENPYPE_SG_USER"] = login + + return True diff --git a/openpype/resources/app_icons/shotgrid.png b/openpype/resources/app_icons/shotgrid.png new file mode 100644 index 0000000000000000000000000000000000000000..6d0cc047f9ed86e0db45ea557404ab7edb2f5bf2 GIT binary patch literal 45744 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 N8$~sR3OTc&{{b(fTU!7C literal 0 HcmV?d00001 diff --git a/openpype/settings/defaults/project_settings/shotgrid.json b/openpype/settings/defaults/project_settings/shotgrid.json new file mode 100644 index 0000000000..83b6f69074 --- /dev/null +++ b/openpype/settings/defaults/project_settings/shotgrid.json @@ -0,0 +1,22 @@ +{ + "shotgrid_project_id": 0, + "shotgrid_server": "", + "event": { + "enabled": false + }, + "fields": { + "asset": { + "type": "sg_asset_type" + }, + "sequence": { + "episode_link": "episode" + }, + "shot": { + "episode_link": "sg_episode", + "sequence_link": "sg_sequence" + }, + "task": { + "step": "step" + } + } +} diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 8cd4114cb0..9d8910689a 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -131,6 +131,12 @@ } } }, + "shotgrid": { + "enabled": false, + "leecher_manager_url": "http://127.0.0.1:3000", + "leecher_backend_url": "http://127.0.0.1:8090", + "shotgrid_settings": {} + }, "kitsu": { "enabled": false, "server": "" @@ -203,4 +209,4 @@ "linux": "" } } -} \ No newline at end of file +} diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index a173e2454f..b2cb2204f4 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -107,6 +107,7 @@ from .enum_entity import ( TaskTypeEnumEntity, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity, + ShotgridUrlEnumEntity ) from .list_entity import ListEntity @@ -171,6 +172,7 @@ __all__ = ( "ToolsEnumEntity", "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", + "ShotgridUrlEnumEntity", "AnatomyTemplatesEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 92a397afba..3b3dd47e61 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,10 +1,7 @@ import copy from .input_entities import InputEntity from .exceptions import EntitySchemaError -from .lib import ( - NOT_SET, - STRING_TYPE -) +from .lib import NOT_SET, STRING_TYPE class BaseEnumEntity(InputEntity): @@ -26,7 +23,7 @@ class BaseEnumEntity(InputEntity): for item in self.enum_items: key = tuple(item.keys())[0] if key in enum_keys: - reason = "Key \"{}\" is more than once in enum items.".format( + reason = 'Key "{}" is more than once in enum items.'.format( key ) raise EntitySchemaError(self, reason) @@ -34,7 +31,7 @@ class BaseEnumEntity(InputEntity): enum_keys.add(key) if not isinstance(key, STRING_TYPE): - reason = "Key \"{}\" has invalid type {}, expected {}.".format( + reason = 'Key "{}" has invalid type {}, expected {}.'.format( key, type(key), STRING_TYPE ) raise EntitySchemaError(self, reason) @@ -59,7 +56,7 @@ class BaseEnumEntity(InputEntity): for item in check_values: if item not in self.valid_keys: raise ValueError( - "{} Invalid value \"{}\". Expected one of: {}".format( + '{} Invalid value "{}". Expected one of: {}'.format( self.path, item, self.valid_keys ) ) @@ -84,7 +81,7 @@ class EnumEntity(BaseEnumEntity): self.valid_keys = set(all_keys) if self.multiselection: - self.valid_value_types = (list, ) + self.valid_value_types = (list,) value_on_not_set = [] if enum_default: if not isinstance(enum_default, list): @@ -109,7 +106,7 @@ class EnumEntity(BaseEnumEntity): self.value_on_not_set = key break - self.valid_value_types = (STRING_TYPE, ) + self.valid_value_types = (STRING_TYPE,) # GUI attribute self.placeholder = self.schema_data.get("placeholder") @@ -152,6 +149,7 @@ class HostsEnumEntity(BaseEnumEntity): Host name is not the same as application name. Host name defines implementation instead of application name. """ + schema_types = ["hosts-enum"] all_host_names = [ "aftereffects", @@ -169,7 +167,7 @@ class HostsEnumEntity(BaseEnumEntity): "tvpaint", "unreal", "standalonepublisher", - "webpublisher" + "webpublisher", ] def _item_initialization(self): @@ -210,7 +208,7 @@ class HostsEnumEntity(BaseEnumEntity): self.valid_keys = valid_keys if self.multiselection: - self.valid_value_types = (list, ) + self.valid_value_types = (list,) self.value_on_not_set = [] else: for key in valid_keys: @@ -218,7 +216,7 @@ class HostsEnumEntity(BaseEnumEntity): self.value_on_not_set = key break - self.valid_value_types = (STRING_TYPE, ) + self.valid_value_types = (STRING_TYPE,) # GUI attribute self.placeholder = self.schema_data.get("placeholder") @@ -226,14 +224,10 @@ class HostsEnumEntity(BaseEnumEntity): def schema_validations(self): if self.hosts_filter: enum_len = len(self.enum_items) - if ( - enum_len == 0 - or (enum_len == 1 and self.use_empty_value) - ): - joined_filters = ", ".join([ - '"{}"'.format(item) - for item in self.hosts_filter - ]) + if enum_len == 0 or (enum_len == 1 and self.use_empty_value): + joined_filters = ", ".join( + ['"{}"'.format(item) for item in self.hosts_filter] + ) reason = ( "All host names were removed after applying" " host filters. {}" @@ -246,24 +240,25 @@ class HostsEnumEntity(BaseEnumEntity): invalid_filters.add(item) if invalid_filters: - joined_filters = ", ".join([ - '"{}"'.format(item) - for item in self.hosts_filter - ]) - expected_hosts = ", ".join([ - '"{}"'.format(item) - for item in self.all_host_names - ]) - self.log.warning(( - "Host filters containt invalid host names:" - " \"{}\" Expected values are {}" - ).format(joined_filters, expected_hosts)) + joined_filters = ", ".join( + ['"{}"'.format(item) for item in self.hosts_filter] + ) + expected_hosts = ", ".join( + ['"{}"'.format(item) for item in self.all_host_names] + ) + self.log.warning( + ( + "Host filters containt invalid host names:" + ' "{}" Expected values are {}' + ).format(joined_filters, expected_hosts) + ) super(HostsEnumEntity, self).schema_validations() class AppsEnumEntity(BaseEnumEntity): """Enum of applications for project anatomy attributes.""" + schema_types = ["apps-enum"] def _item_initialization(self): @@ -271,7 +266,7 @@ class AppsEnumEntity(BaseEnumEntity): self.value_on_not_set = [] self.enum_items = [] self.valid_keys = set() - self.valid_value_types = (list, ) + self.valid_value_types = (list,) self.placeholder = None def _get_enum_values(self): @@ -352,7 +347,7 @@ class ToolsEnumEntity(BaseEnumEntity): self.value_on_not_set = [] self.enum_items = [] self.valid_keys = set() - self.valid_value_types = (list, ) + self.valid_value_types = (list,) self.placeholder = None def _get_enum_values(self): @@ -409,10 +404,10 @@ class TaskTypeEnumEntity(BaseEnumEntity): def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) if self.multiselection: - self.valid_value_types = (list, ) + self.valid_value_types = (list,) self.value_on_not_set = [] else: - self.valid_value_types = (STRING_TYPE, ) + self.valid_value_types = (STRING_TYPE,) self.value_on_not_set = "" self.enum_items = [] @@ -507,7 +502,8 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): enum_items_list = [] for server_name, url_entity in deadline_urls_entity.items(): enum_items_list.append( - {server_name: "{}: {}".format(server_name, url_entity.value)}) + {server_name: "{}: {}".format(server_name, url_entity.value)} + ) valid_keys.add(server_name) return enum_items_list, valid_keys @@ -530,6 +526,50 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): self._current_value = tuple(self.valid_keys)[0] +class ShotgridUrlEnumEntity(BaseEnumEntity): + schema_types = ["shotgrid_url-enum"] + + def _item_initialization(self): + self.multiselection = False + + self.enum_items = [] + self.valid_keys = set() + + self.valid_value_types = (STRING_TYPE,) + self.value_on_not_set = "" + + # GUI attribute + self.placeholder = self.schema_data.get("placeholder") + + def _get_enum_values(self): + shotgrid_settings = self.get_entity_from_path( + "system_settings/modules/shotgrid/shotgrid_settings" + ) + + valid_keys = set() + enum_items_list = [] + for server_name, settings in shotgrid_settings.items(): + enum_items_list.append( + { + server_name: "{}: {}".format( + server_name, settings["shotgrid_url"].value + ) + } + ) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + def set_override_state(self, *args, **kwargs): + super(ShotgridUrlEnumEntity, self).set_override_state(*args, **kwargs) + + self.enum_items, self.valid_keys = self._get_enum_values() + if not self.valid_keys: + self._current_value = "" + + elif self._current_value not in self.valid_keys: + self._current_value = tuple(self.valid_keys)[0] + + class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 6c07209de3..80b1baad1b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -62,6 +62,10 @@ "type": "schema", "name": "schema_project_ftrack" }, + { + "type": "schema", + "name": "schema_project_shotgrid" + }, { "type": "schema", "name": "schema_project_kitsu" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json b/openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json new file mode 100644 index 0000000000..4faeca89f3 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_shotgrid.json @@ -0,0 +1,98 @@ +{ + "type": "dict", + "key": "shotgrid", + "label": "Shotgrid", + "collapsible": true, + "is_file": true, + "children": [ + { + "type": "number", + "key": "shotgrid_project_id", + "label": "Shotgrid project id" + }, + { + "type": "shotgrid_url-enum", + "key": "shotgrid_server", + "label": "Shotgrid Server" + }, + { + "type": "dict", + "key": "event", + "label": "Event Handler", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, + { + "type": "dict", + "key": "fields", + "label": "Fields Template", + "collapsible": true, + "children": [ + { + "type": "dict", + "key": "asset", + "label": "Asset", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "type", + "label": "Asset Type" + } + ] + }, + { + "type": "dict", + "key": "sequence", + "label": "Sequence", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "episode_link", + "label": "Episode link" + } + ] + }, + { + "type": "dict", + "key": "shot", + "label": "Shot", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "episode_link", + "label": "Episode link" + }, + { + "type": "text", + "key": "sequence_link", + "label": "Sequence link" + } + ] + }, + { + "type": "dict", + "key": "task", + "label": "Task", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "step", + "label": "Step link" + } + ] + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json index 484fbf9d07..a4b28f47bc 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json @@ -13,6 +13,9 @@ { "ftrackreview": "Add review to Ftrack" }, + { + "shotgridreview": "Add review to Shotgrid" + }, { "delete": "Delete output" }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index d22b9016a7..952b38040c 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -48,6 +48,60 @@ "type": "schema", "name": "schema_kitsu" }, + { + "type": "dict", + "key": "shotgrid", + "label": "Shotgrid", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "leecher_manager_url", + "label": "Shotgrid Leecher Manager URL" + }, + { + "type": "text", + "key": "leecher_backend_url", + "label": "Shotgrid Leecher Backend URL" + }, + { + "type": "boolean", + "key": "filter_projects_by_login", + "label": "Filter projects by SG login" + }, + { + "type": "dict-modifiable", + "key": "shotgrid_settings", + "label": "Shotgrid Servers", + "object_type": { + "type": "dict", + "children": [ + { + "key": "shotgrid_url", + "label": "Server URL", + "type": "text" + }, + { + "key": "shotgrid_script_name", + "label": "Script Name", + "type": "text" + }, + { + "key": "shotgrid_script_key", + "label": "Script api key", + "type": "text" + } + ] + } + } + ] + }, { "type": "dict", "key": "timers_manager", diff --git a/poetry.lock b/poetry.lock index 7221e191ff..0033bc0d73 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1375,6 +1375,21 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "shotgun-api3" +version = "3.3.3" +description = "Shotgun Python API" +category = "main" +optional = false +python-versions = "*" +develop = false + +[package.source] +type = "git" +url = "https://github.com/shotgunsoftware/python-api.git" +reference = "v3.3.3" +resolved_reference = "b9f066c0edbea6e0733242e18f32f75489064840" + [[package]] name = "six" version = "1.16.0" @@ -2820,6 +2835,7 @@ semver = [ {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, ] +shotgun-api3 = [] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index bd5d3ad89d..306c7206fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ clique = "1.6.*" Click = "^7" dnspython = "^2.1.0" ftrack-python-api = "2.0.*" +shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"} gazu = "^0.8.28" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) jsonschema = "^2.6.0"