diff --git a/ayon_server/graphql/__init__.py b/ayon_server/graphql/__init__.py index e07b71fe..c6ea69fd 100644 --- a/ayon_server/graphql/__init__.py +++ b/ayon_server/graphql/__init__.py @@ -14,6 +14,7 @@ from ayon_server.graphql.connections import ( EventsConnection, InboxConnection, + KanbanConnection, ProjectsConnection, UsersConnection, ) @@ -38,6 +39,7 @@ from ayon_server.graphql.resolvers.activities import get_activities from ayon_server.graphql.resolvers.events import get_events from ayon_server.graphql.resolvers.inbox import get_inbox +from ayon_server.graphql.resolvers.kanban import get_kanban from ayon_server.graphql.resolvers.links import get_links from ayon_server.graphql.resolvers.projects import get_project, get_projects from ayon_server.graphql.resolvers.users import get_user, get_users @@ -112,6 +114,11 @@ class Query: resolver=get_inbox, ) + kanban: KanbanConnection = strawberry.field( + description="Get kanban board", + resolver=get_kanban, + ) + @strawberry.field(description="Current user") def me(self, info: Info) -> UserNode: user = info.context["user"] diff --git a/ayon_server/graphql/connections.py b/ayon_server/graphql/connections.py index 707b956b..438234ec 100644 --- a/ayon_server/graphql/connections.py +++ b/ayon_server/graphql/connections.py @@ -5,6 +5,7 @@ EventEdge, FolderEdge, InboxEdge, + KanbanEdge, ProductEdge, ProjectEdge, RepresentationEdge, @@ -69,3 +70,8 @@ class ActivitiesConnection(BaseConnection): @strawberry.type class InboxConnection(BaseConnection): edges: list[InboxEdge] = strawberry.field(default_factory=list) + + +@strawberry.type +class KanbanConnection(BaseConnection): + edges: list[KanbanEdge] = strawberry.field(default_factory=list) diff --git a/ayon_server/graphql/edges.py b/ayon_server/graphql/edges.py index beaac871..16ecc6fb 100644 --- a/ayon_server/graphql/edges.py +++ b/ayon_server/graphql/edges.py @@ -9,6 +9,7 @@ from ayon_server.graphql.nodes.activity import ActivityNode from ayon_server.graphql.nodes.event import EventNode from ayon_server.graphql.nodes.folder import FolderNode + from ayon_server.graphql.nodes.kanban import KanbanNode from ayon_server.graphql.nodes.product import ProductNode from ayon_server.graphql.nodes.project import ProjectNode from ayon_server.graphql.nodes.representation import RepresentationNode @@ -21,6 +22,7 @@ ProjectNode = LazyType["ProjectNode", ".nodes.project"] UserNode = LazyType["UserNode", ".nodes.user"] FolderNode = LazyType["FolderNode", ".nodes.folder"] + KanbanNode = LazyType["KanbanNode", ".nodes.kanban"] TaskNode = LazyType["TaskNode", ".nodes.task"] ProductNode = LazyType["ProductNode", ".nodes.product"] VersionNode = LazyType["VersionNode", ".nodes.version"] @@ -94,3 +96,9 @@ class ActivityEdge(BaseEdge): class InboxEdge(BaseEdge): node: ActivityNode = strawberry.field(description="The inbox node") cursor: str | None = strawberry.field(default=None) + + +@strawberry.type +class KanbanEdge(BaseEdge): + node: KanbanNode = strawberry.field(description="The kanban node") + cursor: str | None = strawberry.field(default=None) diff --git a/ayon_server/graphql/nodes/kanban.py b/ayon_server/graphql/nodes/kanban.py new file mode 100644 index 00000000..f9c794d9 --- /dev/null +++ b/ayon_server/graphql/nodes/kanban.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Any + +import strawberry + + +@strawberry.type +class KanbanNode: + project_name: str = strawberry.field() + project_code: str = strawberry.field() + id: str = strawberry.field() + name: str = strawberry.field() + label: str | None = strawberry.field() + status: str = strawberry.field() + tags: list[str] = strawberry.field() + task_type: str = strawberry.field() + assignees: list[str] = strawberry.field() + updated_at: datetime = strawberry.field() + created_at: datetime = strawberry.field() + due_date: datetime | None = strawberry.field(default=None) + folder_id: str = strawberry.field() + folder_name: str = strawberry.field() + folder_label: str | None = strawberry.field() + folder_path: str = strawberry.field() + thumbnail_id: str | None = strawberry.field(default=None) + last_version_with_thumbnail_id: str | None = strawberry.field(default=None) + + +def kanban_node_from_record( + project_name: str | None, + record: dict[str, Any], + context: dict[str, Any], +) -> KanbanNode: + record = dict(record) + record.pop("cursor", None) + + project_name = record.pop("project_name", project_name) + assert project_name, "project_name is required" + + due_date = record.pop("due_date", None) + if isinstance(due_date, datetime): + due_date = due_date.replace(tzinfo=None) + elif isinstance(due_date, str): + due_date = datetime.fromisoformat(due_date) + record["due_date"] = due_date + + return KanbanNode( + project_name=project_name, + **record, + ) + + +KanbanNode.from_record = staticmethod(kanban_node_from_record) # type: ignore diff --git a/ayon_server/graphql/resolvers/kanban.py b/ayon_server/graphql/resolvers/kanban.py new file mode 100644 index 00000000..5135c8b0 --- /dev/null +++ b/ayon_server/graphql/resolvers/kanban.py @@ -0,0 +1,179 @@ +# import time + +from ayon_server.entities import UserEntity +from ayon_server.graphql.connections import KanbanConnection +from ayon_server.graphql.edges import KanbanEdge +from ayon_server.graphql.nodes.kanban import KanbanNode +from ayon_server.graphql.resolvers.common import ( + ARGBefore, + ARGLast, + resolve, +) +from ayon_server.graphql.types import Info +from ayon_server.lib.postgres import Postgres +from ayon_server.types import validate_name_list +from ayon_server.utils import SQLTool + + +def user_has_access(user: UserEntity, project_name: str) -> bool: + if user.is_manager: + return True + return project_name in user.data.get("accessGroups", {}) + + +async def get_kanban( + root, + info: Info, + last: ARGLast = 2000, + before: ARGBefore = None, + projects: list[str] | None = None, + assignees_any: list[str] | None = None, + task_ids: list[str] | None = None, +) -> KanbanConnection: + """ + Fetches tasks for the Kanban board. + + Parameters + ---------- + last : ARGLast, optional + The number of tasks to return, by default 2000. + + before : ARGBefore, optional + The cursor to fetch tasks before, by default None. + + projects : list[str], optional + List of project IDs to filter tasks. + If not specified, tasks from all projects are listed. + For non-managers, the result is limited to projects the user has access to. + Inactive projects are never included. + + assignees_any : list[str], optional + List of user names to filter tasks. + If the invoking user is a manager, tasks assigned + to the specified users are listed. + If not provided, all tasks are listed regardless of assignees. + For non-managers, this is always set to [user.name]. + + task_ids : list[str], optional + If set, return explicit tasks by their IDs. + This is used for fetching updates when a entity.task.* event is received. + + Returns + ------- + KanbanConnection + A connection object containing the fetched tasks. + + """ + user = info.context["user"] + + project_data: list[dict[str, str]] = [] + + if not projects: + q = "SELECT name, code FROM projects WHERE active IS TRUE" + else: + validate_name_list(projects) + q = f""" + SELECT name, code + FROM projects + WHERE name = ANY({SQLTool.array(projects, curly=True)}) + """ + async for row in Postgres.iterate(q): + project_data.append(row) + + if not user.is_manager: + assignees_any = [user.name] + project_data = [p for p in project_data if user_has_access(user, p["name"])] + elif assignees_any: + validate_name_list(assignees_any) + + # Sub-query conditions + + sub_query_conds = [] + + if task_ids: + # id_array sanitizes the input + c = f"t.id IN {SQLTool.id_array(task_ids)}" + sub_query_conds.append(c) + + if assignees_any: + # assignees list is already sanitized at this point + c = f"t.assignees && {SQLTool.array(assignees_any, curly=True)}" + sub_query_conds.append(c) + + union_queries = [] + for pdata in project_data: + project_name = pdata["name"] + project_code = pdata["code"] + project_schema = f"project_{project_name}" + uq = f""" + SELECT + '{project_name}' AS project_name, + '{project_code}' AS project_code, + t.id as id, + t.name as name, + t.label as label, + t.status as status, + t.tags as tags, + t.task_type as task_type, + t.assignees as assignees, + t.updated_at as updated_at, + t.created_at as created_at, + t.attrib->>'endDate' as due_date, + f.id as folder_id, + f.name as folder_name, + f.label as folder_label, + h.path as folder_path, + t.thumbnail_id as thumbnail_id, + vs.version_id as last_version_with_thumbnail_id + FROM {project_schema}.tasks t + JOIN {project_schema}.folders f ON f.id = t.folder_id + JOIN {project_schema}.hierarchy h ON h.id = f.id + LEFT JOIN LATERAL ( + SELECT + v.id AS version_id + FROM + {project_schema}.versions v + WHERE + v.task_id = t.id + AND v.thumbnail_id IS NOT NULL + ORDER BY + CASE + WHEN v.version < 0 THEN 1 + ELSE 0 + END DESC, + v.version DESC + LIMIT 1 + ) vs ON true + + {SQLTool.conditions(sub_query_conds)} + """ + union_queries.append(uq) + + unions = " UNION ALL ".join(union_queries) + + cursor = "updated_at" + + query = f""" + SELECT + {cursor} as cursor, + * FROM ({unions}) dummy + ORDER BY + due_date DESC NULLS LAST, + updated_at DESC + """ + + # + # Execute the query + # + + res = await resolve( + KanbanConnection, + KanbanEdge, + KanbanNode, + None, + query, + None, + last, + context=info.context, + ) + return res diff --git a/ayon_server/graphql/resolvers/users.py b/ayon_server/graphql/resolvers/users.py index 0de448c4..ea52e9cb 100644 --- a/ayon_server/graphql/resolvers/users.py +++ b/ayon_server/graphql/resolvers/users.py @@ -14,7 +14,7 @@ resolve, ) from ayon_server.graphql.types import Info -from ayon_server.types import validate_user_name +from ayon_server.types import validate_name_list, validate_user_name from ayon_server.utils import SQLTool @@ -40,6 +40,9 @@ async def get_users( project_name: Annotated[ str | None, argdesc("List only users assigned to a given project") ] = None, + projects: Annotated[ + list[str] | None, argdesc("List only users assigned to projects") + ] = None, first: ARGFirst = None, after: ARGAfter = None, last: ARGLast = None, @@ -48,9 +51,11 @@ async def get_users( """Return a list of users.""" user = info.context["user"] - if (not user.is_manager) and (project_name is None): + if (not user.is_manager) and (project_name is None and projects is None): raise ForbiddenException("Only managers and administrators can view all users") + # Filter by name + sql_conditions = [] if name is not None: validate_user_name(name) @@ -63,15 +68,23 @@ async def get_users( validate_user_name(name) sql_conditions.append(f"users.name IN {SQLTool.array(names)}") - if project_name is not None: - if not user.is_manager: - if project_name not in user.data.get("accessGroups", {}): - raise ForbiddenException("You don't have access to this project") + # Filter by project + + if projects is None: + projects = [] + if project_name and project_name not in projects: + projects.append(project_name) + if not projects: + validate_name_list(projects) + if projects: cnd1 = "users.data->>'isAdmin' = 'true'" cnd2 = "users.data->>'isManager' = 'true'" - cnd3 = f"""(users.data->'accessGroups'->'{project_name}' IS NOT NULL - AND users.data->'accessGroups'->>'{project_name}' != '[]')""" + cnd3 = f"""( + SELECT COUNT(*) + FROM jsonb_object_keys(users.data->'accessGroups') keys + WHERE keys IN ({', '.join([f"'{project}'" for project in projects])}) + ) > 0""" cnd = f"({cnd1} OR {cnd2} OR {cnd3})" sql_conditions.append(cnd)