diff --git a/client/ayon_kitsu/plugins/publish/collect_kitsu_family.py b/client/ayon_kitsu/plugins/publish/collect_kitsu_family.py new file mode 100644 index 0000000..a87e12f --- /dev/null +++ b/client/ayon_kitsu/plugins/publish/collect_kitsu_family.py @@ -0,0 +1,112 @@ +""" +Requires: + none + +Provides: + instance -> families ([]) +""" +import pyblish.api + +from ayon_core.lib import filter_profiles + +from ayon_kitsu.pipeline import plugin + + +class CollectKitsuFamily(plugin.KitsuPublishInstancePlugin): + """Adds explicitly 'kitsu' to families to upload instance to FTrack. + + Uses selection by combination of hosts/families/tasks names via + profiles resolution. + + Triggered everywhere, checks instance against configured. + + Checks advanced filtering which works on 'families' not on main + 'family', as some variants dynamically resolves addition of kitsu + based on 'families' (editorial drives it by presence of 'review') + """ + + label = "Collect Kitsu Family" + order = pyblish.api.CollectorOrder + 0.4990 + + profiles = None + + def process(self, instance): + if not self.profiles: + self.log.warning("No profiles present for adding Kitsu family") + return + + host_name = instance.context.data["hostName"] + product_type = instance.data["productType"] + task_name = instance.data.get("task") + + filtering_criteria = { + "host_names": host_name, + "product_types": product_type, + "task_names": task_name + } + profile = filter_profiles( + self.profiles, + filtering_criteria, + logger=self.log + ) + + add_kitsu_family = False + families = instance.data.setdefault("families", []) + + if profile: + add_kitsu_family = profile["add_kitsu_family"] + additional_filters = profile.get("advanced_filtering") + if additional_filters: + families_set = set(families) | {product_type} + self.log.info( + "'{}' families used for additional filtering".format( + families_set)) + add_kitsu_family = self._get_add_kitsu_f_from_addit_filters( + additional_filters, + families_set, + add_kitsu_family + ) + + result_str = "Not adding" + if add_kitsu_family: + result_str = "Adding" + if "kitsu" not in families: + families.append("kitsu") + + self.log.debug("{} 'kitsu' family for instance with '{}'".format( + result_str, product_type + )) + + def _get_add_kitsu_f_from_addit_filters( + self, additional_filters, families, add_kitsu_family + ): + """Compares additional filters - working on instance's families. + + Triggered for more detailed filtering when main family matches, + but content of 'families' actually matter. + (For example 'review' in 'families' should result in adding to + Kitsu) + + Args: + additional_filters (dict) - from Setting + families (set[str]) - subfamilies + add_kitsu_family (bool) - add kitsu to families if True + """ + + override_filter = None + override_filter_value = -1 + for additional_filter in additional_filters: + filter_families = set(additional_filter["families"]) + valid = filter_families <= set(families) # issubset + if not valid: + continue + + value = len(filter_families) + if value > override_filter_value: + override_filter = additional_filter + override_filter_value = value + + if override_filter: + add_kitsu_family = override_filter["add_kitsu_family"] + + return add_kitsu_family diff --git a/client/ayon_kitsu/plugins/publish/integrate_kitsu_note.py b/client/ayon_kitsu/plugins/publish/integrate_kitsu_note.py index abdd59b..1329100 100644 --- a/client/ayon_kitsu/plugins/publish/integrate_kitsu_note.py +++ b/client/ayon_kitsu/plugins/publish/integrate_kitsu_note.py @@ -12,7 +12,7 @@ class IntegrateKitsuNote(KitsuPublishContextPlugin): order = pyblish.api.IntegratorOrder label = "Kitsu Note and Status" - families = ["render", "image", "online", "plate", "kitsu"] + families = ["kitsu"] # status settings set_status_note = False @@ -68,7 +68,7 @@ def process(self, context): families = set( [instance.data["family"]] + instance.data.get("families", []) ) - if "review" not in families: + if "review" not in families or "kitsu" not in families: continue kitsu_task = instance.data.get("kitsuTask") diff --git a/client/ayon_kitsu/plugins/publish/integrate_kitsu_review.py b/client/ayon_kitsu/plugins/publish/integrate_kitsu_review.py index d859f16..7afc30b 100644 --- a/client/ayon_kitsu/plugins/publish/integrate_kitsu_review.py +++ b/client/ayon_kitsu/plugins/publish/integrate_kitsu_review.py @@ -10,7 +10,7 @@ class IntegrateKitsuReview(KitsuPublishInstancePlugin): order = pyblish.api.IntegratorOrder + 0.01 label = "Kitsu Review" - families = ["render", "image", "online", "plate", "kitsu"] + families = ["kitsu"] optional = True def process(self, instance): diff --git a/server/kitsu/addon_helpers.py b/server/kitsu/addon_helpers.py index 2bcc46f..5b973e3 100644 --- a/server/kitsu/addon_helpers.py +++ b/server/kitsu/addon_helpers.py @@ -1,4 +1,14 @@ -def required_values(entity: dict, keys: list[str], allow_empty_value=False): +import re +import unicodedata +from typing import Any + +""" +A collection of helper functions for Ayon Addons +minimal dependencies, pytest unit tests +""" + +def required_values(entity: dict[str, Any], keys: list[str], allow_empty_value: bool = False) -> list[Any]: + """check the entity dict has the required keys and a value for each""" values = [] for key in keys: @@ -8,3 +18,91 @@ def required_values(entity: dict, keys: list[str], allow_empty_value=False): raise ValueError(f"Value for '{key}' cannot be empty for entity: {entity}") values.append(entity.get(key)) return values + + +## ========== KITSU -> AYON NAME CONVERSIONS ===================== + + +def create_short_name(name: str) -> str: + """create a shortname from the full name when a shortname is not present""" + code = name.lower() + + if "_" in code: + subwords = code.split("_") + code = "".join([subword[0] for subword in subwords])[:4] + elif len(name) > 4: + vowels = ["a", "e", "i", "o", "u"] + filtered_word = "".join([char for char in code if char not in vowels]) + code = filtered_word[:4] + + # if there is a number at the end of the code, add it to the code + last_char = code[-1] + if last_char.isdigit(): + code += last_char + + return code + + +def to_username(first_name: str, last_name: str | None = None) -> str: + """converts usernames from kitsu - converts accents""" + + name = ( + f"{first_name.strip()}.{last_name.strip()}" if last_name else first_name.strip() + ) + + name = name.lower() + name = remove_accents(name) + return to_entity_name(name) + + +def remove_accents(input_str: str) -> str: + """swap accented characters for a-z equivilants ž => z""" + + nfkd_form = unicodedata.normalize("NFKD", input_str) + result = "".join([c for c in nfkd_form if not unicodedata.combining(c)]) + + # manually replace exceptions + # @see https://stackoverflow.com/questions/3194516/replace-special-characters-with-ascii-equivalent + replacement_map = { + "Æ": "AE", + "Ð": "D", + "Ø": "O", + "Þ": "TH", + "ß": "ss", + "æ": "ae", + "ð": "d", + "ø": "o", + "þ": "th", + "Œ": "OE", + "œ": "oe", + "ƒ": "f", + } + for k, v in replacement_map.items(): + if k in result: + result = result.replace(k, v) + + # remove any unsupported characters + result = re.sub(r"[^a-zA-Z0-9_\.\-]", "", result) + + return result + + +def to_entity_name(name: str) -> str: + r"""convert names so they will pass AYON Entity name validation + @see ayon_server.types.NAME_REGEX = r"^[a-zA-Z0-9_]([a-zA-Z0-9_\.\-]*[a-zA-Z0-9_])?$" + """ + + if not name: + raise ValueError("Entity name cannot be empty") + + name = name.strip() + + # replace whitespace + name = re.sub(r"\s+", "_", name) + # remove any invalid characters + name = re.sub(r"[^a-zA-Z0-9_\.\-]", "", name) + + # first and last characters cannot be . or - + name = re.sub(r"^[^a-zA-Z0-9_]+", "", name) + name = re.sub(r"[^a-zA-Z0-9_]+$", "", name) + return name diff --git a/server/kitsu/anatomy.py b/server/kitsu/anatomy.py index a40118b..e98d303 100644 --- a/server/kitsu/anatomy.py +++ b/server/kitsu/anatomy.py @@ -7,7 +7,7 @@ from ayon_server.settings.anatomy.statuses import Status from ayon_server.settings.anatomy.task_types import TaskType -from .utils import create_short_name, remove_accents +from .addon_helpers import create_short_name, remove_accents if TYPE_CHECKING: from .. import KitsuAddon diff --git a/server/kitsu/push.py b/server/kitsu/push.py index 87f0529..37f592c 100644 --- a/server/kitsu/push.py +++ b/server/kitsu/push.py @@ -26,11 +26,13 @@ get_user_by_kitsu_id, remove_accents, update_project, + update_folder, update_task, ) -from .addon_helpers import required_values + +from .addon_helpers import to_username, required_values if TYPE_CHECKING: from .. import KitsuAddon @@ -164,7 +166,7 @@ async def create_access_group( print(e) -def match_ayon_roles_with_kitsu_role(role: str) -> dict[str, bool] | None: +def match_ayon_roles_with_kitsu_role(role: str) -> dict[str, bool]: match role: case "admin": return { @@ -182,7 +184,7 @@ def match_ayon_roles_with_kitsu_role(role: str) -> dict[str, bool] | None: "isManager": False, } case _: - return + return {} async def generate_user_settings( @@ -190,7 +192,7 @@ async def generate_user_settings( entity_dict: "EntityDict", ): settings = await addon.get_studio_settings() - data: dict[str, str] = {} + data: dict[str, Any] = {} match entity_dict["role"]: case "admin": # Studio manager data = match_ayon_roles_with_kitsu_role( @@ -226,32 +228,42 @@ async def generate_user_settings( async def sync_person( addon: "KitsuAddon", user: "UserEntity", + existing_users: dict[str, Any], entity_dict: "EntityDict", ): - logging.info("sync_person") - username = remove_accents( - f"{entity_dict['first_name']}.{entity_dict['last_name']}".lower().strip() - ) + first_name, entity_id= required_values(entity_dict, ["first_name", "id"]) + last_name = entity_dict.get("last_name", '') + + # == check should Person entity be synced == + # do not sync Kitsu API bots + if entity_dict.get("is_bot"): + logging.info( + f"skipping sync_person for Kitsu Bot: {first_name} {last_name}" + ) + return + + logging.info(f"sync_person: {first_name} {last_name}") + username = to_username(first_name, last_name) payload = { "name": username, "attrib": { - "fullName": entity_dict["full_name"], - "email": entity_dict["email"], + "fullName": entity_dict.get("full_name", ""), + "email": entity_dict.get("email", ""), }, } | await generate_user_settings( addon, entity_dict, ) - payload["data"]["kitsuId"] = entity_dict["id"] + payload["data"]["kitsuId"] = entity_id ayon_user = None try: ayon_user = await UserEntity.load(username) except Exception: pass - target_user = await get_user_by_kitsu_id(entity_dict["id"]) + target_user = await get_user_by_kitsu_id(entity_id) # User exists but doesn't have a kitsuId assigned it it if ayon_user and not target_user: @@ -269,11 +281,10 @@ async def sync_person( headers=headers, ) # Rename the user - payload = { - "newName": remove_accents( - f"{entity_dict['first_name']}.{entity_dict['last_name']}".lower().strip() - ) - } + # TODO: We should discourage renaming users. + # Maybe just change the fullName in the case there's a typo, + # but changing username may have weird side effects. + payload = {"newName": username} async with httpx.AsyncClient() as client: await client.patch( f"{entity_dict['ayon_server_url']}/api/users/{target_user.name}/rename", @@ -288,6 +299,9 @@ async def sync_person( user.set_password(settings.sync_settings.sync_users.default_password) await user.save() + # update the id map + existing_users[entity_id] = username + async def sync_project( addon: "KitsuAddon", @@ -559,6 +573,7 @@ async def push_entities( folders = {} tasks = {} + users = {} settings = await addon.get_studio_settings() for entity_dict in payload.entities: @@ -582,6 +597,7 @@ async def push_entities( await sync_person( addon, user, + users, entity_dict, ) elif entity_dict["type"] != "Task": @@ -607,7 +623,7 @@ async def push_entities( ) # pass back the map of kitsu to ayon ids - return {"folders": folders, "tasks": tasks} + return {"folders": folders, "tasks": tasks, "users": users} async def remove_entities( diff --git a/server/kitsu/utils.py b/server/kitsu/utils.py index 423396e..6bffc35 100644 --- a/server/kitsu/utils.py +++ b/server/kitsu/utils.py @@ -1,4 +1,3 @@ -import unicodedata from typing import Any from nxtools import slugify, logging @@ -39,30 +38,6 @@ def calculate_end_frame( return frame_start + nb_frames - 1 -def remove_accents(input_str: str) -> str: - nfkd_form = unicodedata.normalize("NFKD", input_str) - return "".join([c for c in nfkd_form if not unicodedata.combining(c)]) - - -def create_short_name(name: str) -> str: - code = name.lower() - - if "_" in code: - subwords = code.split("_") - code = "".join([subword[0] for subword in subwords])[:4] - elif len(name) > 4: - vowels = ["a", "e", "i", "o", "u"] - filtered_word = "".join([char for char in code if char not in vowels]) - code = filtered_word[:4] - - # if there is a number at the end of the code, add it to the code - last_char = code[-1] - if last_char.isdigit(): - code += last_char - - return code - - def create_name_and_label(kitsu_name: str) -> dict[str, str]: """From a name coming from kitsu, create a name and label""" name_slug = slugify(kitsu_name, separator="_") diff --git a/server/settings/__init__.py b/server/settings/__init__.py index 4dcc2aa..6a1ef49 100644 --- a/server/settings/__init__.py +++ b/server/settings/__init__.py @@ -1,4 +1,7 @@ -__all__ = ['DEFAULT_VALUES', 'KitsuSettings'] +from .main import KitsuSettings, DEFAULT_VALUES -from .defaults import DEFAULT_VALUES -from .settings import KitsuSettings + +__all__ = ( + "DEFAULT_VALUES", + "KitsuSettings", +) diff --git a/server/settings/defaults.py b/server/settings/defaults.py index d99a5c6..e69de29 100644 --- a/server/settings/defaults.py +++ b/server/settings/defaults.py @@ -1,146 +0,0 @@ -DEFAULT_VALUES = { - "entities_naming_pattern": { - "episode": "E##", - "sequence": "SQ##", - "shot": "SH##", - }, - "publish": { - "IntegrateKitsuNote": { - "set_status_note": False, - "note_status_shortname": "wfa", - "status_change_conditions": { - "status_conditions": [], - "family_requirements": [], - }, - "custom_comment_template": { - "enabled": False, - "comment_template": """{comment} - -| | | -|--|--| -| version | `{version}` | -| family | `{family}` | -| name | `{name}` |""", - }, - } - }, - "sync_settings": { - "delete_projects": False, - "sync_users": { - "enabled": False, - "default_password": "default_password", - "access_group": "kitsu_group", - }, - "default_sync_info": { - "default_task_info": [ - { - "name": "Concept", - "short_name": "cncp", - "icon": "lightbulb", - }, - { - "name": "Modeling", - "short_name": "mdl", - "icon": "language", - }, - { - "name": "Shading", - "short_name": "shdn", - "icon": "format_paint", - }, - { - "name": "Rigging", - "short_name": "rig", - "icon": "construction", - }, - { - "name": "Edit", - "short_name": "edit", - "icon": "cut", - }, - { - "name": "Storyboard", - "short_name": "stry", - "icon": "image", - }, - { - "name": "Layout", - "short_name": "lay", - "icon": "nature_people", - }, - { - "name": "Animation", - "short_name": "anim", - "icon": "directions_run", - }, - { - "name": "Lighting", - "short_name": "lgt", - "icon": "highlight", - }, - { - "name": "FX", - "short_name": "fx", - "icon": "local_fire_department", - }, - { - "name": "Compositing", - "short_name": "comp", - "icon": "layers", - }, - { - "name": "Recording", - "short_name": "rcrd", - "icon": "video_camera_back", - }, - ], - "default_status_info": [ - { - "short_name": "todo", - "state": "not_started", - "icon": "fiber_new", - }, - { - "short_name": "neutral", - "state": "in_progress", - "icon": "timer", - }, - { - "short_name": "wip", - "state": "in_progress", - "icon": "play_arrow", - }, - { - "short_name": "wfa", - "state": "in_progress", - "icon": "visibility", - }, - { - "short_name": "retake", - "state": "in_progress", - "icon": "timer", - }, - { - "short_name": "done", - "state": "done", - "icon": "task_alt", - }, - { - "short_name": "ready", - "state": "not_started", - "icon": "timer", - }, - { - "short_name": "approved", - "state": "done", - "icon": "task_alt", - }, - { - "short_name": "rejected", - "state": "blocked", - "icon": "block", - }, - ], - }, - }, -} diff --git a/server/settings/main.py b/server/settings/main.py new file mode 100644 index 0000000..36efdd5 --- /dev/null +++ b/server/settings/main.py @@ -0,0 +1,64 @@ +from ayon_server.settings import BaseSettingsModel, SettingsField +from ayon_server.settings.enum import secrets_enum + +from .sync_settings import SyncSettings, SYNC_DEFAULT_VALUES +from .publish_plugins import PublishPlugins, PUBLISH_DEFAULT_VALUES + + +## Entities naming pattern +# +class EntityPattern(BaseSettingsModel): + episode: str = SettingsField(title="Episode") + sequence: str = SettingsField(title="Sequence") + shot: str = SettingsField(title="Shot") + + +class KitsuSettings(BaseSettingsModel): + # + ## Root fields + # + enabled: bool = SettingsField(True, title="Enabled") + server: str = SettingsField( + "", + title="Kitsu Server", + scope=["studio"], + ) + login_email: str = SettingsField( + "kitsu_email", + enum_resolver=secrets_enum, + title="Kitsu user email", + scope=["studio"], + ) + login_password: str | None = SettingsField( + "kitsu_password", + enum_resolver=secrets_enum, + title="Kitsu user password", + scope=["studio"], + ) + + # + ## Sub entities + # + entities_naming_pattern: EntityPattern = SettingsField( + default_factory=EntityPattern, + title="Entities naming pattern", + ) + publish: PublishPlugins = SettingsField( + default_factory=PublishPlugins, + title="Publish plugins", + ) + sync_settings: SyncSettings = SettingsField( + default_factory=SyncSettings, + title="Sync settings", + ) + + +DEFAULT_VALUES = { + "entities_naming_pattern": { + "episode": "E##", + "sequence": "SQ##", + "shot": "SH##", + }, + "publish": PUBLISH_DEFAULT_VALUES, + "sync_settings": SYNC_DEFAULT_VALUES, +} diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py new file mode 100644 index 0000000..643415d --- /dev/null +++ b/server/settings/publish_plugins.py @@ -0,0 +1,298 @@ +from ayon_server.settings import BaseSettingsModel, SettingsField + + +class CollectFamilyAdvancedFilterModel(BaseSettingsModel): + _layout = "expanded" + families: list[str] = SettingsField( + default_factory=list, + title="Additional Families" + ) + add_kitsu_family: bool = SettingsField( + True, + title="Add Kitsu Family" + ) + + +class CollectFamilyProfile(BaseSettingsModel): + _layout = "expanded" + host_names: list[str] = SettingsField( + default_factory=list, + title="Host names", + ) + product_types: list[str] = SettingsField( + default_factory=list, + title="Families", + ) + task_types: list[str] = SettingsField( + default_factory=list, + title="Task types", + ) + task_names: list[str] = SettingsField( + default_factory=list, + title="Task names", + ) + add_kitsu_family: bool = SettingsField( + True, + title="Add Kitsu Family", + ) + advanced_filtering: list[CollectFamilyAdvancedFilterModel] = SettingsField( + title="Advanced adding if additional families present", + default_factory=list, + ) + + +class CollectKitsuFamilyPluginModel(BaseSettingsModel): + _isGroup = True + enabled: bool = True + profiles: list[CollectFamilyProfile] = SettingsField( + default_factory=list, + title="Profiles", + ) + + +def _status_change_cond_enum(): + return [ + {"value": "equal", "label": "Equal"}, + {"value": "not_equal", "label": "Not equal"}, + ] + + +class StatusChangeCondition(BaseSettingsModel): + condition: str = SettingsField( + "equal", enum_resolver=_status_change_cond_enum, title="Condition" + ) + short_name: str = SettingsField("", title="Short name") + + +class StatusChangeFamilyRequirementModel(BaseSettingsModel): + condition: str = SettingsField( + "equal", enum_resolver=_status_change_cond_enum, title="Condition" + ) + product_type: str = SettingsField("", title="Family") + + +class StatusChangeConditionsModel(BaseSettingsModel): + status_conditions: list[StatusChangeCondition] = SettingsField( + default_factory=list, title="Status conditions" + ) + family_requirements: list[StatusChangeFamilyRequirementModel] = SettingsField( + default_factory=list, title="Family requirements" + ) + + +class CustomCommentTemplateModel(BaseSettingsModel): + """Kitsu supports markdown and here you can create a custom comment template. + + You can use data from your publishing instance's data. + """ + + enabled: bool = SettingsField(True) + comment_template: str = SettingsField( + "", widget="textarea", title="Custom comment" + ) + + +class IntegrateKitsuNotes(BaseSettingsModel): + set_status_note: bool = SettingsField(title="Set status on note") + note_status_shortname: str = SettingsField(title="Note shortname") + status_change_conditions: StatusChangeConditionsModel = SettingsField( + default_factory=StatusChangeConditionsModel, + title="Status change conditions" + ) + custom_comment_template: CustomCommentTemplateModel = SettingsField( + default_factory=CustomCommentTemplateModel, + title="Custom Comment Template", + ) + + +class PublishPlugins(BaseSettingsModel): + CollectKitsuFamily: CollectKitsuFamilyPluginModel = SettingsField( + default_factory=CollectKitsuFamilyPluginModel, + title="Collect Kitsu Family" + ) + IntegrateKitsuNote: IntegrateKitsuNotes = SettingsField( + default_factory=IntegrateKitsuNotes, + title="Integrate Kitsu Note" + ) + + +PUBLISH_DEFAULT_VALUES = { + "CollectKitsuFamily": { + "enabled": True, + "profiles": [ + { + "host_names": [ + "traypublisher" + ], + "product_types": [], + "task_types": [], + "task_names": [], + "add_ftrack_family": True, + "advanced_filtering": [] + }, + { + "host_names": [ + "traypublisher" + ], + "product_types": [ + "matchmove", + "shot" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": False, + "advanced_filtering": [] + }, + { + "host_names": [ + "traypublisher" + ], + "product_types": [ + "plate", + "review", + "audio" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": False, + "advanced_filtering": [ + { + "families": [ + "clip", + "review" + ], + "add_kitsu_family": True + } + ] + }, + { + "host_names": [ + "maya" + ], + "product_types": [ + "model", + "setdress", + "animation", + "look", + "rig", + "camera" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": True, + "advanced_filtering": [] + }, + { + "host_names": [ + "tvpaint" + ], + "product_types": [ + "renderPass" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": False, + "advanced_filtering": [] + }, + { + "host_names": [ + "tvpaint" + ], + "product_types": [], + "task_types": [], + "task_names": [], + "add_kitsu_family": True, + "advanced_filtering": [] + }, + { + "host_names": [ + "nuke" + ], + "product_types": [ + "write", + "render", + "prerender" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": False, + "advanced_filtering": [ + { + "families": [ + "review" + ], + "add_kitsu_family": True + } + ] + }, + { + "host_names": [ + "aftereffects" + ], + "product_types": [ + "render", + "workfile" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": True, + "advanced_filtering": [] + }, + { + "host_names": [ + "flame" + ], + "product_types": [ + "plate", + "take" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": True, + "advanced_filtering": [] + }, + { + "host_names": [ + "houdini" + ], + "product_types": [ + "usd" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": True, + "advanced_filtering": [] + }, + { + "host_names": [ + "photoshop" + ], + "product_types": [ + "review" + ], + "task_types": [], + "task_names": [], + "add_kitsu_family": True, + "advanced_filtering": [] + } + ] + }, + "IntegrateKitsuNote": { + "set_status_note": False, + "note_status_shortname": "wfa", + "status_change_conditions": { + "status_conditions": [], + "family_requirements": [], + }, + "custom_comment_template": { + "enabled": False, + "comment_template": """{comment} + +| | | +|--|--| +| version | `{version}` | +| family | `{family}` | +| name | `{name}` |""", + }, + } +} diff --git a/server/settings/settings.py b/server/settings/settings.py deleted file mode 100644 index 5458ddd..0000000 --- a/server/settings/settings.py +++ /dev/null @@ -1,212 +0,0 @@ -from ayon_server.settings import BaseSettingsModel, SettingsField -from ayon_server.settings.enum import secrets_enum -from ayon_server.types import NAME_REGEX - - -# -## Entities naming pattern -# -class EntityPattern(BaseSettingsModel): - episode: str = SettingsField(title="Episode") - sequence: str = SettingsField(title="Sequence") - shot: str = SettingsField(title="Shot") - - -# -## Publish plugins -# -def _status_change_cond_enum(): - return [ - {"value": "equal", "label": "Equal"}, - {"value": "not_equal", "label": "Not equal"}, - ] - - -class StatusChangeCondition(BaseSettingsModel): - condition: str = SettingsField( - "equal", enum_resolver=_status_change_cond_enum, title="Condition" - ) - short_name: str = SettingsField("", title="Short name") - - -class StatusChangeFamilyRequirementModel(BaseSettingsModel): - condition: str = SettingsField( - "equal", enum_resolver=_status_change_cond_enum, title="Condition" - ) - product_type: str = SettingsField("", title="Family") - - -class StatusChangeConditionsModel(BaseSettingsModel): - status_conditions: list[StatusChangeCondition] = SettingsField( - default_factory=list, title="Status conditions" - ) - family_requirements: list[StatusChangeFamilyRequirementModel] = SettingsField( - default_factory=list, title="Family requirements" - ) - - -class CustomCommentTemplateModel(BaseSettingsModel): - """Kitsu supports markdown and here you can create a custom comment template. - - You can use data from your publishing instance's data. - """ - - enabled: bool = SettingsField(True) - comment_template: str = SettingsField("", widget="textarea", title="Custom comment") - - -class IntegrateKitsuNotes(BaseSettingsModel): - set_status_note: bool = SettingsField(title="Set status on note") - note_status_shortname: str = SettingsField(title="Note shortname") - status_change_conditions: StatusChangeConditionsModel = SettingsField( - default_factory=StatusChangeConditionsModel, title="Status change conditions" - ) - custom_comment_template: CustomCommentTemplateModel = SettingsField( - default_factory=CustomCommentTemplateModel, - title="Custom Comment Template", - ) - - -class PublishPlugins(BaseSettingsModel): - IntegrateKitsuNote: IntegrateKitsuNotes = SettingsField( - default_factory=IntegrateKitsuNotes, title="Integrate Kitsu Note" - ) - - -# -## Sync users -# -def _roles_enum(): - return [ - {"value": "user", "label": "User"}, - {"value": "manager", "label": "Manager"}, - {"value": "admin", "label": "Admin"}, - ] - - -class RolesCondition(BaseSettingsModel): - """Set what Ayon role users should get based in their Kitsu role""" - - admin: str = SettingsField( - "admin", enum_resolver=_roles_enum, title="Studio manager" - ) - vendor: str = SettingsField( - "user", enum_resolver=_roles_enum, title="Vendor" - ) - client: str = SettingsField( - "user", enum_resolver=_roles_enum, title="Client" - ) - manager: str = SettingsField( - "manager", enum_resolver=_roles_enum, title="Production manager" - ) - supervisor: str = SettingsField( - "manager", enum_resolver=_roles_enum, title="Supervisor" - ) - user: str = SettingsField( - "user", enum_resolver=_roles_enum, title="Artist" - ) - - -class SyncUsers(BaseSettingsModel): - """When a Kitsu user is synced, the default password will be set for the newly created user. - Please ask the user to change the password inside Ayon. - """ - - enabled: bool = SettingsField(True) - default_password: str = SettingsField(title="Default Password") - access_group: str = SettingsField(title="Access Group", regex=NAME_REGEX) - roles: RolesCondition = SettingsField(default_factory=RolesCondition, title="Roles") - - -# -## Default task info -# -def _states_enum(): - return [ - {"value": "not_started", "label": "Not started"}, - {"value": "in_progress", "label": "In progress"}, - {"value": "done", "label": "Done"}, - {"value": "blocked", "label": "Blocked"}, - ] - - -class TaskCondition(BaseSettingsModel): - _layout: str = "compact" - name: str = SettingsField("", title="Name") - short_name: str = SettingsField("", title="Short name") - icon: str = SettingsField("task_alt", title="Icon", widget="icon") - - -class StatusCondition(BaseSettingsModel): - _layout: str = "compact" - short_name: str = SettingsField("", title="Short name") - state: str = SettingsField("in_progress", enum_resolver=_states_enum, title="State") - icon: str = SettingsField("task_alt", title="Icon", widget="icon") - - -class DefaultSyncInfo(BaseSettingsModel): - """As statuses already have names and short names we only need the short name to match Kitsu with Ayon""" - - default_task_info: list[TaskCondition] = SettingsField( - default_factory=list, title="Tasks" - ) - default_status_info: list[StatusCondition] = SettingsField( - default_factory=list, title="Statuses" - ) - - -# -## Sync settings -# -class SyncSettings(BaseSettingsModel): - """Enabling 'Delete projects' will remove projects on Ayon when they get deleted on Kitsu""" - - delete_projects: bool = SettingsField(title="Delete projects") - sync_users: SyncUsers = SettingsField( - default_factory=SyncUsers, - title="Sync users", - ) - default_sync_info: DefaultSyncInfo = SettingsField( - default_factory=DefaultSyncInfo, - title="Default sync info", - ) - - -class KitsuSettings(BaseSettingsModel): - # - ## Root fields - # - enabled: bool = SettingsField(True, title="Enabled") - server: str = SettingsField( - "", - title="Kitsu Server", - scope=["studio"], - ) - login_email: str = SettingsField( - "kitsu_email", - enum_resolver=secrets_enum, - title="Kitsu user email", - scope=["studio"], - ) - login_password: str | None = SettingsField( - "kitsu_password", - enum_resolver=secrets_enum, - title="Kitsu user password", - scope=["studio"], - ) - - # - ## Sub entities - # - entities_naming_pattern: EntityPattern = SettingsField( - default_factory=EntityPattern, - title="Entities naming pattern", - ) - publish: PublishPlugins = SettingsField( - default_factory=PublishPlugins, - title="Publish plugins", - ) - sync_settings: SyncSettings = SettingsField( - default_factory=SyncSettings, - title="Sync settings", - ) diff --git a/server/settings/sync_settings.py b/server/settings/sync_settings.py new file mode 100644 index 0000000..7fdf61f --- /dev/null +++ b/server/settings/sync_settings.py @@ -0,0 +1,219 @@ +from ayon_server.settings import BaseSettingsModel, SettingsField +from ayon_server.types import NAME_REGEX + + +# +## Sync users +# +def _roles_enum(): + return [ + {"value": "user", "label": "User"}, + {"value": "manager", "label": "Manager"}, + {"value": "admin", "label": "Admin"}, + ] + + +class RolesCondition(BaseSettingsModel): + """Set what Ayon role users should get based in their Kitsu role""" + + admin: str = SettingsField( + "admin", enum_resolver=_roles_enum, title="Studio manager" + ) + vendor: str = SettingsField( + "user", enum_resolver=_roles_enum, title="Vendor" + ) + client: str = SettingsField( + "user", enum_resolver=_roles_enum, title="Client" + ) + manager: str = SettingsField( + "manager", enum_resolver=_roles_enum, title="Production manager" + ) + supervisor: str = SettingsField( + "manager", enum_resolver=_roles_enum, title="Supervisor" + ) + user: str = SettingsField( + "user", enum_resolver=_roles_enum, title="Artist" + ) + + +class SyncUsers(BaseSettingsModel): + """When a Kitsu user is synced, the default password will be set for the newly created user. + Please ask the user to change the password inside Ayon. + """ + + enabled: bool = SettingsField(True) + default_password: str = SettingsField(title="Default Password") + access_group: str = SettingsField(title="Access Group", regex=NAME_REGEX) + roles: RolesCondition = SettingsField(default_factory=RolesCondition, title="Roles") + + +# +## Default task info +# +class TaskCondition(BaseSettingsModel): + _layout: str = "compact" + name: str = SettingsField("", title="Name") + short_name: str = SettingsField("", title="Short name") + icon: str = SettingsField("task_alt", title="Icon", widget="icon") + + +def _states_enum(): + return [ + {"value": "not_started", "label": "Not started"}, + {"value": "in_progress", "label": "In progress"}, + {"value": "done", "label": "Done"}, + {"value": "blocked", "label": "Blocked"}, + ] + + +class StatusCondition(BaseSettingsModel): + _layout: str = "compact" + short_name: str = SettingsField("", title="Short name") + state: str = SettingsField("in_progress", enum_resolver=_states_enum, title="State") + icon: str = SettingsField("task_alt", title="Icon", widget="icon") + + +class DefaultSyncInfo(BaseSettingsModel): + """As statuses already have names and short names we only need the short name to match Kitsu with Ayon""" + + default_task_info: list[TaskCondition] = SettingsField( + default_factory=list, title="Tasks" + ) + default_status_info: list[StatusCondition] = SettingsField( + default_factory=list, title="Statuses" + ) + + +class SyncSettings(BaseSettingsModel): + """Enabling 'Delete projects' will remove projects on Ayon when they get deleted on Kitsu""" + + delete_projects: bool = SettingsField(title="Delete projects") + sync_users: SyncUsers = SettingsField( + default_factory=SyncUsers, + title="Sync users", + ) + default_sync_info: DefaultSyncInfo = SettingsField( + default_factory=DefaultSyncInfo, + title="Default sync info", + ) + + +SYNC_DEFAULT_VALUES = { + "delete_projects": False, + "sync_users": { + "enabled": False, + "default_password": "default_password", + "access_group": "kitsu_group", + }, + "default_sync_info": { + "default_task_info": [ + { + "name": "Concept", + "short_name": "cncp", + "icon": "lightbulb", + }, + { + "name": "Modeling", + "short_name": "mdl", + "icon": "language", + }, + { + "name": "Shading", + "short_name": "shdn", + "icon": "format_paint", + }, + { + "name": "Rigging", + "short_name": "rig", + "icon": "construction", + }, + { + "name": "Edit", + "short_name": "edit", + "icon": "cut", + }, + { + "name": "Storyboard", + "short_name": "stry", + "icon": "image", + }, + { + "name": "Layout", + "short_name": "lay", + "icon": "nature_people", + }, + { + "name": "Animation", + "short_name": "anim", + "icon": "directions_run", + }, + { + "name": "Lighting", + "short_name": "lgt", + "icon": "highlight", + }, + { + "name": "FX", + "short_name": "fx", + "icon": "local_fire_department", + }, + { + "name": "Compositing", + "short_name": "comp", + "icon": "layers", + }, + { + "name": "Recording", + "short_name": "rcrd", + "icon": "video_camera_back", + }, + ], + "default_status_info": [ + { + "short_name": "todo", + "state": "not_started", + "icon": "fiber_new", + }, + { + "short_name": "neutral", + "state": "in_progress", + "icon": "timer", + }, + { + "short_name": "wip", + "state": "in_progress", + "icon": "play_arrow", + }, + { + "short_name": "wfa", + "state": "in_progress", + "icon": "visibility", + }, + { + "short_name": "retake", + "state": "in_progress", + "icon": "timer", + }, + { + "short_name": "done", + "state": "done", + "icon": "task_alt", + }, + { + "short_name": "ready", + "state": "not_started", + "icon": "timer", + }, + { + "short_name": "approved", + "state": "done", + "icon": "task_alt", + }, + { + "short_name": "rejected", + "state": "blocked", + "icon": "block", + }, + ], + }, +} diff --git a/services/Makefile b/services/Makefile deleted file mode 100644 index 720403a..0000000 --- a/services/Makefile +++ /dev/null @@ -1,64 +0,0 @@ -include .env - -AYON_ADDON_VERSION=$(shell python -c "import os;import sys;content={};f=open('$(CURDIR)/../package.py');exec(f.read(),content);f.close();print(content['version'])") -AYON_ADDON_NAME=kitsu -BASE_NAME := ayon-$(AYON_ADDON_NAME)-$(SERVICE) -IMAGE := ynput/$(BASE_NAME):$(AYON_ADDON_VERSION) -SERVICE = $(error Please specify the service to build with 'SERVICE', for example: 'make build SERVICE=processor') - -default: - @echo * ************************* - @echo * Ayon Kitsu $(AYON_ADDON_VERSION) Service Builder - @echo * Docker image name: $(IMAGE) - @echo * ************************* - @echo * - @echo * Usage: make SERVICE=[service-name] [target] - @echo * - @echo * Passing SERVICE is required for any of the targets to work, possible services: - @echo * - @echo * processor - Syncs a kitsu project to Ayon - @echo * sync-service - Listen for events on Kitsu and sync it to Ayon - @echo * - @echo * Targets: - @echo * run Run service without building. - @echo * build Build docker image. - @echo * build-all Build docker image for 'procesor' and 'sync-service'. - @echo * clean Remove local images. - @echo * clean-build Remove local images and build without docker cache. - @echo * dev Run a service locally - @echo * dist Upload the image to a registry - -export AYON_ADDON_VERSION -export AYON_ADDON_NAME -export AYON_API_KEY -export AYON_SERVER_URL - -run: - poetry run python -m $(SERVICE).$(SERVICE) - -build: - docker build -t $(IMAGE) -f $(SERVICE)/Dockerfile . - -build-all: - $(foreach service,processor sync-service, docker build -t ynput/ayon-kitsu-$(service):$(ADDON_VERSION) -f $(service)/Dockerfile . &) - -clean: - docker rmi $(IMAGE) - -clean-build: clean - build - -clean-build-all: - $(foreach service,processor sync-service, docker build --no-cache -t ynput/ayon-kitsu-$(service):$(ADDON_VERSION) -f $(service)/Dockerfile . &) - -dist: build - docker push $(IMAGE) - -dev: build - docker run --rm -u ayonuser -ti \ - --hostname kitsu-dev-worker \ - --env AYON_API_KEY=${AYON_API_KEY} \ - --env AYON_SERVER_URL=${AYON_SERVER_URL} \ - --env AYON_ADDON_NAME=${AYON_ADDON_NAME} \ - --env AYON_ADDON_VERSION=$(AYON_ADDON_VERSION) \ - $(IMAGE) python -m $(SERVICE) diff --git a/services/kitsu_common/__init__.py b/services/kitsu_common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/processor/Dockerfile b/services/processor/Dockerfile index 8350d77..5c8ecc2 100644 --- a/services/processor/Dockerfile +++ b/services/processor/Dockerfile @@ -4,7 +4,7 @@ ENV PYTHONUNBUFFERED=1 # Create Working directory `/service` and copy `processor` RUN mkdir /service -COPY processor/pyproject.toml /service/pyproject.toml +COPY pyproject.toml /service/pyproject.toml WORKDIR /service # Install dependencies with poetry @@ -20,8 +20,7 @@ RUN addgroup -S ayonuser && adduser -SH ayonuser -G ayonuser RUN chown ayonuser:ayonuser -R /service RUN chmod 777 -R /service -COPY processor/processor /service/processor -COPY kitsu_common /service/kitsu_common +COPY processor /service/processor # Tell docker that all future commands should run as the appuser user USER ayonuser diff --git a/services/processor/Makefile b/services/processor/Makefile new file mode 100644 index 0000000..eefaf99 --- /dev/null +++ b/services/processor/Makefile @@ -0,0 +1,50 @@ +include .env + +AYON_ADDON_VERSION=$(shell python -c "import os;import sys;content={};f=open('$(CURDIR)/../../package.py');exec(f.read(),content);f.close();print(content['version'])") +AYON_ADDON_NAME=kitsu +SERVICE_NAME=processor +BASE_NAME := ayon-$(AYON_ADDON_NAME)-$(SERVICE_NAME) +IMAGE := ynput/$(BASE_NAME):$(AYON_ADDON_VERSION) +default: + @echo * ************************* + @echo * AYON Kitsu $(AYON_ADDON_VERSION) $(SERVICE_NAME) Service Builder + @echo * Docker image name: $(IMAGE) + @echo * ************************* + @echo * + @echo * Usage: make [target] + @echo * + @echo * Targets: + @echo * run Run service without building. + @echo * build Build docker image. + @echo * clean Remove local images. + @echo * dev Run a service locally + @echo * dist Upload the image to a registry + +export AYON_ADDON_VERSION +export AYON_ADDON_NAME +export AYON_API_KEY +export AYON_SERVER_URL + +run: + poetry run python -m processor + +build: + docker build -t $(IMAGE) -f Dockerfile . + +clean: + docker rmi $(IMAGE) + +clean-build: clean + build + +dist: build + docker push $(IMAGE) + +dev: build + docker run --rm -u ayonuser -ti \ + --hostname kitsu-dev-worker \ + --env AYON_API_KEY=${AYON_API_KEY} \ + --env AYON_SERVER_URL=${AYON_SERVER_URL} \ + --env AYON_ADDON_NAME=${AYON_ADDON_NAME} \ + --env AYON_ADDON_VERSION=$(AYON_ADDON_VERSION) \ + $(IMAGE) python -m processor diff --git a/services/manage.ps1 b/services/processor/manage.ps1 similarity index 64% rename from services/manage.ps1 rename to services/processor/manage.ps1 index 98d5f4b..d3972bd 100644 --- a/services/manage.ps1 +++ b/services/processor/manage.ps1 @@ -1,7 +1,4 @@ # Receive first positional argument -param ( - [string]$SERVICE -) $FunctionName=$ARGS[0] $arguments=@() if ($ARGS.Length -gt 1) { @@ -11,26 +8,22 @@ if ($ARGS.Length -gt 1) { $script_dir_rel = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $script_dir = (Get-Item $script_dir_rel).FullName -$AYON_ADDON_VERSION = Invoke-Expression -Command "python -c ""import os;import sys;content={};f=open(r'$($script_dir)/../package.py');exec(f.read(),content);f.close();print(content['version'])""" +$AYON_ADDON_VERSION = Invoke-Expression -Command "python -c ""import os;import sys;content={};f=open(r'$($script_dir)/../../package.py');exec(f.read(),content);f.close();print(content['version'])""" $AYON_ADDON_NAME = "kitsu" -$BASE_NAME = "ayon-$AYON_ADDON_NAME-$SERVICE" +$SERVICE_NAME = "processor" +$BASE_NAME = "ayon-$AYON_ADDON_NAME-$SERVICE_NAME" $IMAGE = "ynput/$($BASE_NAME):$($AYON_ADDON_VERSION)" $BASH_CONTAINER_NAME = "$BASE_NAME-bash-$AYON_ADDON_VERSION" -function defaultfunc { +function DefaultFunc { Write-Host "" Write-Host "*************************" - Write-Host "AYON Kitsu $AYON_ADDON_VERSION Service Builder" + Write-Host "AYON Kitsu $AYON_ADDON_VERSION Service $SERVICE_NAME Builder" Write-Host " Docker image name: $IMAGE" Write-Host "*************************" Write-Host "" - Write-Host "Usage: .\manage.ps1 -SERVICE [service-name] [target]" - Write-Host "" - Write-Host "Passing SERVICE is required for any of the targets to work, possible services:" - Write-Host "" - Write-Host " processor - Syncs a kitsu project to Ayon" - Write-Host " sync-service - Listen for events on Kitsu and sync it to Ayon" + Write-Host "Usage: .\manage.ps1 [target]" Write-Host "" Write-Host "Runtime targets:" Write-Host " run Run service out of docker (for development purposes)" @@ -41,26 +34,26 @@ function defaultfunc { Write-Host " bash Run bash in docker image (for development purposes)" } -function run { +function RunService { load-env - & poetry run python -m $SERVICE.$SERVICE + & poetry run python -m processor } -function build { - & docker build -t "$IMAGE" -f "$($script_dir)/$($SERVICE)/Dockerfile" . +function BuildImage { + & docker build -t "$IMAGE" -f "$($script_dir)/Dockerfile" . } -function clean { +function RemoveImage { & docker rmi $IMAGE } -function clean-build { - clean - build +function RemoveAndBuild { + RemoveImage + BuildImage } -function dist { - build +function DistributeImage { + BuildImage # Publish the docker image to the registry docker push "$IMAGE" } @@ -77,7 +70,7 @@ function load-env { } } -function dev { +function RunDocker { load-env & docker run --rm -u ayonuser -ti ` -v "$($script_dir):/service" ` @@ -86,34 +79,33 @@ function dev { --env AYON_SERVER_URL=$env:AYON_SERVER_URL ` --env AYON_ADDON_NAME=$AYON_ADDON_NAME ` --env AYON_ADDON_VERSION=$AYON_ADDON_VERSION ` - "$IMAGE" python -m $SERVICE + "$IMAGE" python -m processor } -function bash { +function RunDockerBash { & docker run --name "$($BASH_CONTAINER_NAME)" --rm -it --entrypoint /bin/bash "$($IMAGE)" } function main { - if ($SERVICE -eq $null -or $SERVICE -eq "") { - Write-Host "Please specify the service to build with 'SERVICE', for example: '.\manage.ps1 build SERVICE=processor'" - } elseif ($FunctionName -eq "build") { - build + if ($FunctionName -eq "build") { + BuildImage } elseif ($FunctionName -eq "clean") { - clean + RemoveImage } elseif ($FunctionName -eq "clean-build") { - clean-build + RemoveAndBuild } elseif ($FunctionName -eq "run") { - run + RunService } elseif ($FunctionName -eq "dev") { - dev + RunDocker } elseif ($FunctionName -eq "dist") { - dist + DistributeImage } elseif ($FunctionName -eq "bash") { - bash - } elseif ($FunctionName -eq $null) { - defaultfunc + RunDockerBash + } elseif ($null -eq $FunctionName) { + DefaultFunc } else { Write-Host "Unknown function ""$FunctionName""" + DefaultFunc } } diff --git a/tests/pytest.ini b/tests/pytest.ini index 10c224a..66c85c8 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,2 +1,2 @@ [pytest] -pythonpath = . ../services/processor ../server/kitsu +pythonpath = . ../services/processor ../server/kitsu \ No newline at end of file diff --git a/tests/tests/fixtures.py b/tests/tests/fixtures.py index a55c7f3..5ef33cf 100644 --- a/tests/tests/fixtures.py +++ b/tests/tests/fixtures.py @@ -16,6 +16,9 @@ PROJECT_CODE = "TK" PAIR_PROJECT_NAME = "another_test_kitsu_project" PAIR_PROJECT_CODE = "ATK" +USER1_NAME = "testkitsu.user1" +USER2_NAME = "testkitsu.user2" +USER3_NAME = "testkitsu.user3" PROJECT_META = { "code": PROJECT_CODE, @@ -103,6 +106,29 @@ def init_data(api, kitsu_url): assert res.status_code == 200 +@pytest.fixture(scope="module") +def users(api, kitsu_url): + """create ayon users""" + api.delete(f"/users/{USER1_NAME}") + api.delete(f"/users/{USER2_NAME}") + api.delete(f"/users/{USER3_NAME}") + + # only create 2 users so the other one can be created if missing + api.put(f"/users/{USER1_NAME}") + api.put(f"/users/{USER2_NAME}") + print(f"created user: {USER1_NAME}") + print(f"created user: {USER2_NAME}") + + yield + + api.delete(f"/users/{USER1_NAME}") + api.delete(f"/users/{USER2_NAME}") + api.delete(f"/users/{USER3_NAME}") + + # ensure renamed user is deleted + api.delete("/users/testkitsu.newusername") + + @pytest.fixture(scope="module") def gazu(): # host = os.environ.get('KITSU_API_URL', 'http://localhost/api') @@ -128,15 +154,15 @@ def get_paired_ayon_project(self, kitsu_project_id): return MockProcessor() + +# ======= Studio Settings Fixtures ========== @pytest.fixture() def ensure_kitsu_server_setting(api, kitsu_url): """update kitsu addon settings.kitsu_server if not set""" - # lets get the settings for the addon res = api.get(f"{kitsu_url}/settings") assert res.status_code == 200 settings = res.data - - # get original values + value = settings["server"] # set settings for tests @@ -146,7 +172,81 @@ def ensure_kitsu_server_setting(api, kitsu_url): yield - # set settings back to orginal values if not value: settings["server"] = "" res = api.post(f"{kitsu_url}/settings", **settings) + +@pytest.fixture() +def users_enabled(api, kitsu_url): + """update kitsu addon settings.sync_settings.sync_users.enabled""" + # lets get the settings for the addon + res = api.get(f"{kitsu_url}/settings") + assert res.status_code == 200 + settings = res.data + + # get original values + users_enabled = settings["sync_settings"]["sync_users"]["enabled"] + + # set settings for tests + if not users_enabled: + settings["sync_settings"]["sync_users"]["enabled"] = True + res = api.post(f"{kitsu_url}/settings", **settings) + + yield + + # set settings back to orginal values + if not users_enabled: + settings["sync_settings"]["sync_users"]["enabled"] = users_enabled + res = api.post(f"{kitsu_url}/settings", **settings) + + +@pytest.fixture() +def users_disabled(api, kitsu_url): + """update kitsu addon settings.sync_settings.sync_users.enabled""" + + # lets get the settings for the addon + res = api.get(f"{kitsu_url}/settings") + assert res.status_code == 200 + settings = res.data + + # get original values + + value = settings["sync_settings"]["sync_users"]["enabled"] + print(f"users_disabled: {value}") + + # set settings for tests + if value: + settings["sync_settings"]["sync_users"]["enabled"] = False + res = api.post(f"{kitsu_url}/settings", **settings) + + yield + + if value: + settings["sync_settings"]["sync_users"]["enabled"] = value + res = api.post(f"{kitsu_url}/settings", **settings) + + +@pytest.fixture() +def access_group(api, kitsu_url): + """update kitsu addon settings.sync_settings.sync_users.access_group""" + + # lets get the settings for the addon + res = api.get(f"{kitsu_url}/settings") + assert res.status_code == 200 + settings = res.data + + # get original values + value = settings["sync_settings"]["sync_users"]["access_group"] + + # set settings for tests + if value != "test_kitsu_group": + settings["sync_settings"]["sync_users"]["access_group"] = "test_kitsu_group" + res = api.post(f"{kitsu_url}/settings", **settings) + + yield + + # set settings back to orginal values + if value != "test_kitsu_group": + settings["sync_settings"]["sync_users"]["access_group"] = value + res = api.post(f"{kitsu_url}/settings", **settings) + diff --git a/tests/tests/mock_data.py b/tests/tests/mock_data.py index 50fe18a..562b75a 100644 --- a/tests/tests/mock_data.py +++ b/tests/tests/mock_data.py @@ -418,10 +418,10 @@ "email_otp_enabled": False, "fido_devices": [], "fido_enabled": False, - "first_name": "Temp", - "full_name": "Temp User 1", + "first_name": "Test Kitsu", + "full_name": "Test kitsu User 1", "has_avatar": True, - "id": "7910f71b-245b-4168-9c92-8bf38d0e403d", + "id": "person-id-1", "is_generated_from_ldap": False, "last_login_failed": "2024-02-12T12:47:19", "last_name": "User 1", @@ -456,10 +456,10 @@ "email_otp_enabled": False, "fido_devices": [], "fido_enabled": False, - "first_name": "Temp", - "full_name": "Temp User 2", + "first_name": "Test Kitsu", + "full_name": "Test Kitsu User 2", "has_avatar": True, - "id": "5015730c-dc2b-4d7c-8ec0-fe92e6561282", + "id": "person-id-2", "is_generated_from_ldap": False, "last_login_failed": None, "last_name": "User 2", @@ -476,7 +476,7 @@ "notifications_slack_userid": "", "phone": "", "preferred_two_factor_authentication": None, - "role": "manager", + "role": "supervisor", "shotgun_id": None, "timezone": "Europe/Paris", "totp_enabled": False, @@ -494,10 +494,10 @@ "email_otp_enabled": False, "fido_devices": [], "fido_enabled": False, - "first_name": "Temp", - "full_name": "Temp User 3", + "first_name": "Test Kitsu", + "full_name": "Test Kitsu User 3", "has_avatar": False, - "id": "e6113388-5e30-4612-bb6a-3c9bda37f48a", + "id": "person-id-3", "is_generated_from_ldap": False, "last_login_failed": None, "last_name": "User 3", @@ -514,7 +514,7 @@ "notifications_slack_userid": "", "phone": "", "preferred_two_factor_authentication": None, - "role": "supervisor", + "role": "user", "shotgun_id": None, "timezone": "Europe/Paris", "totp_enabled": False, @@ -523,6 +523,7 @@ }, ] + all_edits_for_project = [ { "canceled": False, diff --git a/tests/tests/test_addon_helpers.py b/tests/tests/test_addon_helpers.py new file mode 100644 index 0000000..be250f7 --- /dev/null +++ b/tests/tests/test_addon_helpers.py @@ -0,0 +1,54 @@ +import pytest + +from addon_helpers import ( + to_entity_name, + to_username, +) + + +""" tests for formatting values to pass Ayon validation + + $ poetry run pytest tests/test_addon_helpers.py +""" + + +def test_to_username(): + assert to_username("Bobby") == "bobby", "first name only" + assert to_username("Bob", "McBobertson") == "bob.mcbobertson", "first and last name" + assert to_username("Bob J", "Mc Bobertson") == "bobj.mcbobertson", "spaces in names" + # assert to_username("Bob J.", "McBobertson") == "bobj.mcbobertson", "spaces in names" + assert to_username("François", "Kožušček") == "francois.kozuscek", "unicode accents" + assert ( + to_username("Bøb", "Brown") == "bob.brown" + ), "some unicode accents not supported" + assert ( + to_username("äöü", "Brown") == "aou.brown" + ), "some unicode accents not supported" + + +def test_to_entity_name(): + assert ( + to_entity_name("Test Project") == "Test_Project" + ), "spaces should be replaced with an underscore" + assert ( + to_entity_name("Test Project") == "Test_Project" + ), "multiple spaces should be replaced with a single underscore" + assert ( + to_entity_name("Test_Project") == "Test_Project" + ), "underscores should be maintained" + assert ( + to_entity_name("Test#* Project?") == "Test_Project" + ), "non contain alphanumeric characters should be removed" + assert to_entity_name("Test Project 123") == "Test_Project_123", "numbers supported" + assert ( + to_entity_name(" Test Project 123 ") == "Test_Project_123" + ), "trailing whitespace should be removed" + assert ( + to_entity_name(".Test Project 123--") == "Test_Project_123" + ), "trailing non contain alphanumeric characters should be removed" + assert ( + to_entity_name("Test-Project.123 ") == "Test-Project.123" + ), "hyphens and dots ARE allowed" + + with pytest.raises(Exception, match="Entity name cannot be empty"): + to_entity_name("") diff --git a/tests/tests/test_push_person.py b/tests/tests/test_push_person.py new file mode 100644 index 0000000..a4ee8c7 --- /dev/null +++ b/tests/tests/test_push_person.py @@ -0,0 +1,122 @@ +"""tests for endpoint 'api/addons/kitsu/{version}/push' +with entities of kitsu type: Person + +$ poetry run pytest tests/test_push_person.py +""" + +from pprint import pprint + +import pytest + +from . import mock_data +from .fixtures import ( + PAIR_PROJECT_CODE, + PAIR_PROJECT_NAME, + PROJECT_CODE, + PROJECT_ID, + PROJECT_NAME, + api, + kitsu_url, + users, + users_enabled, + users_disabled, + access_group, +) + + +def test_user_names(api, kitsu_url, users, users_enabled, access_group): + # test for names with special characters + entities = [ + { + "email": "user-id-1@temp.com", + "first_name": "Esbjörn Bøb", + "full_name": "Esbjörn Bøb Kožušček 1", + "id": "person-id-0", + "last_name": "Kožušček 1", + "type": "Person", + "role": "user", + }, + ] + + res = api.post( + f"{kitsu_url}/push", + project_name=PROJECT_NAME, + entities=entities, + ) + + assert res.status_code == 200 + assert "users" in res.data + assert res.data["users"] == { + "person-id-0": "esbjornbob.kozuscek1", + } + user = api.get_user("esbjornbob.kozuscek1") + + assert user["name"] == "esbjornbob.kozuscek1" + assert user["attrib"]["fullName"] == "Esbjörn Bøb Kožušček 1" + assert user["data"]["kitsuId"] == "person-id-0" + + # delete the person afterwards + api.delete("/users/esbjornbob.kozuscek1") + + +def test_push_bot(api, kitsu_url, users_enabled): + """test for new API token feature in Kitsu 0.19.2 - Person where is_bot=True""" + + # ensure user is deleted + api.delete("/users/test.bot") + + bot = { + "is_bot": True, + "first_name": "Test", + "last_name": "Bot", + "email": "test.bot@studio.com", + "phone": None, + "contract_type": "open-ended", + "active": True, + "archived": False, + "last_presence": None, + "desktop_login": None, + "login_failed_attemps": 0, + "last_login_failed": None, + "totp_enabled": False, + "email_otp_enabled": False, + "fido_enabled": False, + "preferred_two_factor_authentication": None, + "shotgun_id": None, + "timezone": "Europe/Paris", + "locale": "en_US", + "data": None, + "role": "admin", + "has_avatar": True, + "notifications_enabled": False, + "notifications_slack_enabled": False, + "notifications_slack_userid": "", + "notifications_mattermost_enabled": False, + "notifications_mattermost_userid": "", + "notifications_discord_enabled": False, + "notifications_discord_userid": "", + "expiration_date": "2024-04-30", + "is_generated_from_ldap": False, + "ldap_uid": None, + "full_name": "Test Bot", + "id": "bot-id-1", + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00", + "type": "Person", + "fido_devices": [], + } + + + res = api.post( + f"{kitsu_url}/push", + project_name=PROJECT_NAME, + entities=[bot], + ) + assert res.status_code == 200 + + with pytest.raises(Exception) as exc_info: + api.get_user("test.bot") + + assert str(exc_info.value).startswith("404 Client Error: Not Found for url:") + + api.delete("/users/test.bot")