mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into enhancement/AY-8004_template-build-using-linked-folders
This commit is contained in:
commit
103ebb584f
18 changed files with 185 additions and 64 deletions
|
|
@ -8,6 +8,7 @@ import inspect
|
|||
import logging
|
||||
import threading
|
||||
import collections
|
||||
import warnings
|
||||
from uuid import uuid4
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
|
@ -815,10 +816,26 @@ class AddonsManager:
|
|||
|
||||
Unknown keys are logged out.
|
||||
|
||||
Deprecated:
|
||||
Use targeted methods 'collect_launcher_action_paths',
|
||||
'collect_create_plugin_paths', 'collect_load_plugin_paths',
|
||||
'collect_publish_plugin_paths' and
|
||||
'collect_inventory_action_paths' to collect plugin paths.
|
||||
|
||||
Returns:
|
||||
dict: Output is dictionary with keys "publish", "create", "load",
|
||||
"actions" and "inventory" each containing list of paths.
|
||||
|
||||
"""
|
||||
warnings.warn(
|
||||
"Used deprecated method 'collect_plugin_paths'. Please use"
|
||||
" targeted methods 'collect_launcher_action_paths',"
|
||||
" 'collect_create_plugin_paths', 'collect_load_plugin_paths'"
|
||||
" 'collect_publish_plugin_paths' and"
|
||||
" 'collect_inventory_action_paths'",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# Output structure
|
||||
output = {
|
||||
"publish": [],
|
||||
|
|
@ -874,24 +891,28 @@ class AddonsManager:
|
|||
if not isinstance(addon, IPluginPaths):
|
||||
continue
|
||||
|
||||
paths = None
|
||||
method = getattr(addon, method_name)
|
||||
try:
|
||||
paths = method(*args, **kwargs)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
(
|
||||
"Failed to get plugin paths from addon"
|
||||
" '{}' using '{}'."
|
||||
).format(addon.__class__.__name__, method_name),
|
||||
"Failed to get plugin paths from addon"
|
||||
f" '{addon.name}' using '{method_name}'.",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if not paths:
|
||||
continue
|
||||
|
||||
if paths:
|
||||
# Convert to list if value is not list
|
||||
if not isinstance(paths, (list, tuple, set)):
|
||||
paths = [paths]
|
||||
output.extend(paths)
|
||||
if isinstance(paths, str):
|
||||
paths = [paths]
|
||||
self.log.warning(
|
||||
f"Addon '{addon.name}' returned invalid output type"
|
||||
f" from '{method_name}'."
|
||||
f" Got 'str' expected 'list[str]'."
|
||||
)
|
||||
output.extend(paths)
|
||||
return output
|
||||
|
||||
def collect_launcher_action_paths(self):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Addon interfaces for AYON."""
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Type
|
||||
|
||||
|
|
@ -39,26 +40,29 @@ class AYONInterface(metaclass=_AYONInterfaceMeta):
|
|||
|
||||
|
||||
class IPluginPaths(AYONInterface):
|
||||
"""Addon has plugin paths to return.
|
||||
"""Addon wants to register plugin paths."""
|
||||
|
||||
Expected result is dictionary with keys "publish", "create", "load",
|
||||
"actions" or "inventory" and values as list or string.
|
||||
{
|
||||
"publish": ["path/to/publish_plugins"]
|
||||
}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_plugin_paths(self) -> dict[str, list[str]]:
|
||||
"""Return plugin paths for addon.
|
||||
|
||||
This method was abstract (required) in the past, so raise the required
|
||||
'core' addon version when 'get_plugin_paths' is removed from
|
||||
addon.
|
||||
|
||||
Deprecated:
|
||||
Please implement specific methods 'get_create_plugin_paths',
|
||||
'get_load_plugin_paths', 'get_inventory_action_paths' and
|
||||
'get_publish_plugin_paths' to return plugin paths.
|
||||
|
||||
Returns:
|
||||
dict[str, list[str]]: Plugin paths for addon.
|
||||
|
||||
"""
|
||||
return {}
|
||||
|
||||
def _get_plugin_paths_by_type(
|
||||
self, plugin_type: str) -> list[str]:
|
||||
self, plugin_type: str
|
||||
) -> list[str]:
|
||||
"""Get plugin paths by type.
|
||||
|
||||
Args:
|
||||
|
|
@ -78,6 +82,24 @@ class IPluginPaths(AYONInterface):
|
|||
|
||||
if not isinstance(paths, (list, tuple, set)):
|
||||
paths = [paths]
|
||||
|
||||
new_function_name = "get_launcher_action_paths"
|
||||
if plugin_type == "create":
|
||||
new_function_name = "get_create_plugin_paths"
|
||||
elif plugin_type == "load":
|
||||
new_function_name = "get_load_plugin_paths"
|
||||
elif plugin_type == "publish":
|
||||
new_function_name = "get_publish_plugin_paths"
|
||||
elif plugin_type == "inventory":
|
||||
new_function_name = "get_inventory_action_paths"
|
||||
|
||||
warnings.warn(
|
||||
f"Addon '{self.name}' returns '{plugin_type}' paths using"
|
||||
" 'get_plugin_paths' method. Please implement"
|
||||
f" '{new_function_name}' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
return paths
|
||||
|
||||
def get_launcher_action_paths(self) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -944,6 +944,8 @@ class IWorkfileHost:
|
|||
self._emit_workfile_save_event(event_data, after_save=False)
|
||||
|
||||
workdir = os.path.dirname(filepath)
|
||||
if not os.path.exists(workdir):
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
|
||||
# Set 'AYON_WORKDIR' environment variable
|
||||
os.environ["AYON_WORKDIR"] = workdir
|
||||
|
|
@ -1072,10 +1074,13 @@ class IWorkfileHost:
|
|||
prepared_data=prepared_data,
|
||||
)
|
||||
|
||||
workfile_entities_by_path = {
|
||||
workfile_entity["path"]: workfile_entity
|
||||
for workfile_entity in list_workfiles_context.workfile_entities
|
||||
}
|
||||
workfile_entities_by_path = {}
|
||||
for workfile_entity in list_workfiles_context.workfile_entities:
|
||||
rootless_path = workfile_entity["path"]
|
||||
path = os.path.normpath(
|
||||
list_workfiles_context.anatomy.fill_root(rootless_path)
|
||||
)
|
||||
workfile_entities_by_path[path] = workfile_entity
|
||||
|
||||
workdir_data = get_template_data(
|
||||
list_workfiles_context.project_entity,
|
||||
|
|
@ -1114,10 +1119,10 @@ class IWorkfileHost:
|
|||
|
||||
rootless_path = f"{rootless_workdir}/{filename}"
|
||||
workfile_entity = workfile_entities_by_path.pop(
|
||||
rootless_path, None
|
||||
filepath, None
|
||||
)
|
||||
version = comment = None
|
||||
if workfile_entity:
|
||||
if workfile_entity is not None:
|
||||
_data = workfile_entity["data"]
|
||||
version = _data.get("version")
|
||||
comment = _data.get("comment")
|
||||
|
|
@ -1137,7 +1142,7 @@ class IWorkfileHost:
|
|||
)
|
||||
items.append(item)
|
||||
|
||||
for workfile_entity in workfile_entities_by_path.values():
|
||||
for filepath, workfile_entity in workfile_entities_by_path.items():
|
||||
# Workfile entity is not in the filesystem
|
||||
# but it is in the database
|
||||
rootless_path = workfile_entity["path"]
|
||||
|
|
@ -1154,13 +1159,13 @@ class IWorkfileHost:
|
|||
version = parsed_data.version
|
||||
comment = parsed_data.comment
|
||||
|
||||
filepath = list_workfiles_context.anatomy.fill_root(rootless_path)
|
||||
available = os.path.exists(filepath)
|
||||
items.append(WorkfileInfo.new(
|
||||
filepath,
|
||||
rootless_path,
|
||||
version=version,
|
||||
comment=comment,
|
||||
available=False,
|
||||
available=available,
|
||||
workfile_entity=workfile_entity,
|
||||
))
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ def get_addons_resources_dir(addon_name: str, *args) -> str:
|
|||
return os.path.join(addons_resources_dir, addon_name, *args)
|
||||
|
||||
|
||||
class _FakeException(Exception):
|
||||
"""Placeholder exception used if real exception is not available."""
|
||||
|
||||
|
||||
class AYONSecureRegistry:
|
||||
"""Store information using keyring.
|
||||
|
||||
|
|
@ -205,7 +209,17 @@ class AYONSecureRegistry:
|
|||
"""
|
||||
import keyring
|
||||
|
||||
value = keyring.get_password(self._name, name)
|
||||
# Capture 'ItemNotFoundException' exception (on linux)
|
||||
try:
|
||||
from secretstorage.exceptions import ItemNotFoundException
|
||||
except ImportError:
|
||||
ItemNotFoundException = _FakeException
|
||||
|
||||
try:
|
||||
value = keyring.get_password(self._name, name)
|
||||
except ItemNotFoundException:
|
||||
value = None
|
||||
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
|
|
|
|||
|
|
@ -1403,7 +1403,12 @@ def _get_display_view_colorspace_name(config_path, display, view):
|
|||
|
||||
"""
|
||||
config = _get_ocio_config(config_path)
|
||||
return config.getDisplayViewColorSpaceName(display, view)
|
||||
colorspace = config.getDisplayViewColorSpaceName(display, view)
|
||||
# Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa
|
||||
if colorspace == "<USE_DISPLAY_NAME>":
|
||||
colorspace = display
|
||||
|
||||
return colorspace
|
||||
|
||||
|
||||
def _get_ocio_config_colorspaces(config_path):
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
|
|||
"blender",
|
||||
"houdini",
|
||||
"max",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,7 @@ This module contains a unified plugin that handles:
|
|||
|
||||
from pprint import pformat
|
||||
|
||||
import opentimelineio as otio
|
||||
import pyblish.api
|
||||
from ayon_core.pipeline.editorial import (
|
||||
get_media_range_with_retimes,
|
||||
otio_range_to_frame_range,
|
||||
otio_range_with_handles,
|
||||
)
|
||||
|
||||
|
||||
def validate_otio_clip(instance, logger):
|
||||
|
|
@ -74,6 +68,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
if not validate_otio_clip(instance, self.log):
|
||||
return
|
||||
|
||||
import opentimelineio as otio
|
||||
|
||||
otio_clip = instance.data["otioClip"]
|
||||
|
||||
# Collect timeline ranges if workfile start frame is available
|
||||
|
|
@ -100,6 +96,11 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
|
||||
def _collect_timeline_ranges(self, instance, otio_clip):
|
||||
"""Collect basic timeline frame ranges."""
|
||||
from ayon_core.pipeline.editorial import (
|
||||
otio_range_to_frame_range,
|
||||
otio_range_with_handles,
|
||||
)
|
||||
|
||||
workfile_start = instance.data["workfileFrameStart"]
|
||||
|
||||
# Get timeline ranges
|
||||
|
|
@ -129,6 +130,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
|
||||
def _collect_source_ranges(self, instance, otio_clip):
|
||||
"""Collect source media frame ranges."""
|
||||
import opentimelineio as otio
|
||||
|
||||
# Get source ranges
|
||||
otio_src_range = otio_clip.source_range
|
||||
otio_available_range = otio_clip.available_range()
|
||||
|
|
@ -178,6 +181,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
|
|||
|
||||
def _collect_retimed_ranges(self, instance, otio_clip):
|
||||
"""Handle retimed clip frame ranges."""
|
||||
from ayon_core.pipeline.editorial import get_media_range_with_retimes
|
||||
|
||||
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
|
||||
self.log.debug(f"Retimed attributes: {retimed_attributes}")
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class ExtractBurnin(publish.Extractor):
|
|||
"max",
|
||||
"blender",
|
||||
"unreal",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from ayon_core.lib import (
|
|||
get_ffmpeg_tool_args,
|
||||
run_subprocess
|
||||
)
|
||||
from ayon_core.pipeline import editorial
|
||||
|
||||
|
||||
class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
||||
|
|
@ -159,6 +158,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
|||
"""
|
||||
# Not all hosts can import this module.
|
||||
import opentimelineio as otio
|
||||
from ayon_core.pipeline.editorial import OTIO_EPSILON
|
||||
|
||||
output = []
|
||||
# go trough all audio tracks
|
||||
|
|
@ -177,7 +177,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
|||
# Avoid rounding issue on media available range.
|
||||
if clip_start.almost_equal(
|
||||
conformed_av_start,
|
||||
editorial.OTIO_EPSILON
|
||||
OTIO_EPSILON
|
||||
):
|
||||
conformed_av_start = clip_start
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ from ayon_core.lib import (
|
|||
)
|
||||
from ayon_core.pipeline import (
|
||||
KnownPublishError,
|
||||
editorial,
|
||||
publish,
|
||||
)
|
||||
|
||||
|
|
@ -359,6 +358,7 @@ class ExtractOTIOReview(
|
|||
import opentimelineio as otio
|
||||
from ayon_core.pipeline.editorial import (
|
||||
trim_media_range,
|
||||
OTIO_EPSILON,
|
||||
)
|
||||
|
||||
def _round_to_frame(rational_time):
|
||||
|
|
@ -380,7 +380,7 @@ class ExtractOTIOReview(
|
|||
# Avoid rounding issue on media available range.
|
||||
if start.almost_equal(
|
||||
avl_start,
|
||||
editorial.OTIO_EPSILON
|
||||
OTIO_EPSILON
|
||||
):
|
||||
avl_start = start
|
||||
|
||||
|
|
@ -406,7 +406,7 @@ class ExtractOTIOReview(
|
|||
# Avoid rounding issue on media available range.
|
||||
if end_point.almost_equal(
|
||||
avl_end_point,
|
||||
editorial.OTIO_EPSILON
|
||||
OTIO_EPSILON
|
||||
):
|
||||
avl_end_point = end_point
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
"aftereffects",
|
||||
"flame",
|
||||
"unreal",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
"photoshop"
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
|
|||
"photoshop",
|
||||
"unreal",
|
||||
"houdini",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
enabled = False
|
||||
|
|
|
|||
|
|
@ -3,25 +3,26 @@ import os
|
|||
import ayon_api
|
||||
|
||||
from ayon_core.host import IWorkfileHost
|
||||
from ayon_core.lib import Logger
|
||||
from ayon_core.lib import Logger, get_ayon_username
|
||||
from ayon_core.lib.events import QueuedEventSystem
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.pipeline import Anatomy, registered_host
|
||||
from ayon_core.pipeline.context_tools import get_global_context
|
||||
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.tools.common_models import (
|
||||
HierarchyModel,
|
||||
HierarchyExpectedSelection,
|
||||
HierarchyModel,
|
||||
ProjectsModel,
|
||||
UsersModel,
|
||||
)
|
||||
|
||||
from .abstract import (
|
||||
AbstractWorkfilesFrontend,
|
||||
AbstractWorkfilesBackend,
|
||||
AbstractWorkfilesFrontend,
|
||||
)
|
||||
from .models import SelectionModel, WorkfilesModel
|
||||
|
||||
NOT_SET = object()
|
||||
|
||||
|
||||
class WorkfilesToolExpectedSelection(HierarchyExpectedSelection):
|
||||
def __init__(self, controller):
|
||||
|
|
@ -143,6 +144,7 @@ class BaseWorkfileController(
|
|||
self._project_settings = None
|
||||
self._event_system = None
|
||||
self._log = None
|
||||
self._username = NOT_SET
|
||||
|
||||
self._current_project_name = None
|
||||
self._current_folder_path = None
|
||||
|
|
@ -588,6 +590,20 @@ class BaseWorkfileController(
|
|||
description,
|
||||
)
|
||||
|
||||
def get_my_tasks_entity_ids(self, project_name: str):
|
||||
username = self._get_my_username()
|
||||
assignees = []
|
||||
if username:
|
||||
assignees.append(username)
|
||||
return self._hierarchy_model.get_entity_ids_for_assignees(
|
||||
project_name, assignees
|
||||
)
|
||||
|
||||
def _get_my_username(self):
|
||||
if self._username is NOT_SET:
|
||||
self._username = get_ayon_username()
|
||||
return self._username
|
||||
|
||||
def _emit_event(self, topic, data=None):
|
||||
self.emit_event(topic, data, "controller")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
from qtpy import QtCore, QtWidgets, QtGui
|
||||
|
||||
from ayon_core import style, resources
|
||||
from ayon_core.tools.utils import (
|
||||
PlaceholderLineEdit,
|
||||
MessageOverlayObject,
|
||||
)
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from ayon_core.tools.workfiles.control import BaseWorkfileController
|
||||
from ayon_core import resources, style
|
||||
from ayon_core.tools.utils import (
|
||||
GoToCurrentButton,
|
||||
RefreshButton,
|
||||
FoldersWidget,
|
||||
GoToCurrentButton,
|
||||
MessageOverlayObject,
|
||||
NiceCheckbox,
|
||||
PlaceholderLineEdit,
|
||||
RefreshButton,
|
||||
TasksWidget,
|
||||
)
|
||||
from ayon_core.tools.utils.lib import checkstate_int_to_enum
|
||||
from ayon_core.tools.workfiles.control import BaseWorkfileController
|
||||
|
||||
from .side_panel import SidePanelWidget
|
||||
from .files_widget import FilesWidget
|
||||
from .side_panel import SidePanelWidget
|
||||
from .utils import BaseOverlayFrame
|
||||
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
|
|||
split_widget.addWidget(tasks_widget)
|
||||
split_widget.addWidget(col_3_widget)
|
||||
split_widget.addWidget(side_panel)
|
||||
split_widget.setSizes([255, 175, 550, 190])
|
||||
split_widget.setSizes([350, 175, 550, 190])
|
||||
|
||||
body_layout.addWidget(split_widget)
|
||||
|
||||
|
|
@ -157,6 +157,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
|
|||
self._home_body_widget = home_body_widget
|
||||
self._split_widget = split_widget
|
||||
|
||||
self._project_name = self._controller.get_current_project_name()
|
||||
|
||||
self._tasks_widget = tasks_widget
|
||||
self._side_panel = side_panel
|
||||
|
||||
|
|
@ -186,11 +188,24 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
|
|||
controller, col_widget, handle_expected_selection=True
|
||||
)
|
||||
|
||||
my_tasks_tooltip = (
|
||||
"Filter folders and task to only those you are assigned to."
|
||||
)
|
||||
|
||||
my_tasks_label = QtWidgets.QLabel("My tasks")
|
||||
my_tasks_label.setToolTip(my_tasks_tooltip)
|
||||
|
||||
my_tasks_checkbox = NiceCheckbox(folder_widget)
|
||||
my_tasks_checkbox.setChecked(False)
|
||||
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
|
||||
|
||||
header_layout = QtWidgets.QHBoxLayout(header_widget)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.addWidget(folder_filter_input, 1)
|
||||
header_layout.addWidget(go_to_current_btn, 0)
|
||||
header_layout.addWidget(refresh_btn, 0)
|
||||
header_layout.addWidget(my_tasks_label, 0)
|
||||
header_layout.addWidget(my_tasks_checkbox, 0)
|
||||
|
||||
col_layout = QtWidgets.QVBoxLayout(col_widget)
|
||||
col_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
|
@ -200,6 +215,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
|
|||
folder_filter_input.textChanged.connect(self._on_folder_filter_change)
|
||||
go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
|
||||
refresh_btn.clicked.connect(self._on_refresh_clicked)
|
||||
my_tasks_checkbox.stateChanged.connect(
|
||||
self._on_my_tasks_checkbox_state_changed
|
||||
)
|
||||
|
||||
self._folder_filter_input = folder_filter_input
|
||||
self._folders_widget = folder_widget
|
||||
|
|
@ -385,3 +403,16 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
|
|||
)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _on_my_tasks_checkbox_state_changed(self, state):
|
||||
folder_ids = None
|
||||
task_ids = None
|
||||
state = checkstate_int_to_enum(state)
|
||||
if state == QtCore.Qt.Checked:
|
||||
entity_ids = self._controller.get_my_tasks_entity_ids(
|
||||
self._project_name
|
||||
)
|
||||
folder_ids = entity_ids["folder_ids"]
|
||||
task_ids = entity_ids["task_ids"]
|
||||
self._folders_widget.set_folder_ids_filter(folder_ids)
|
||||
self._tasks_widget.set_task_ids_filter(task_ids)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON addon 'core' version."""
|
||||
__version__ = "1.5.0+dev"
|
||||
__version__ = "1.5.2+dev"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue