Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server-side kanban resolver #249

Merged
merged 14 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ayon_server/graphql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ayon_server.graphql.connections import (
EventsConnection,
InboxConnection,
KanbanConnection,
ProjectsConnection,
UsersConnection,
)
Expand All @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
6 changes: 6 additions & 0 deletions ayon_server/graphql/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
EventEdge,
FolderEdge,
InboxEdge,
KanbanEdge,
ProductEdge,
ProjectEdge,
RepresentationEdge,
Expand Down Expand Up @@ -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)
8 changes: 8 additions & 0 deletions ayon_server/graphql/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
45 changes: 45 additions & 0 deletions ayon_server/graphql/nodes/kanban.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from datetime import datetime
from typing import Any

import strawberry


@strawberry.type
class KanbanNode:
project_name: 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"

return KanbanNode(
project_name=project_name,
**record,
)


KanbanNode.from_record = staticmethod(kanban_node_from_record) # type: ignore
165 changes: 165 additions & 0 deletions ayon_server/graphql/resolvers/kanban.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# 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 bool2sql(value: bool | None) -> str:
if value is None:
return "NULL"
return "TRUE" if value else "FALSE"


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: 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 : 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].

Returns
-------
KanbanConnection
A connection object containing the fetched tasks.

"""
user = info.context["user"]

if not projects:
projects = []
q = "SELECT name FROM projects WHERE active IS TRUE"
async for row in Postgres.iterate(q):
projects.append(row["name"])

if not user.is_manager:
assignees = [user.name]
projects = [p for p in projects if user_has_access(user, p)]
elif assignees:
validate_name_list(assignees)

sub_query_conds = []
if assignees:
c = f"t.assignees @> {SQLTool.array(assignees, curly=True)}"
sub_query_conds.append(c)

union_queries = []
for project_name in projects:
project_schema = f"project_{project_name}"
uq = f"""
SELECT
'{project_name}' AS project_name,
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
#

# start_time = time.monotonic()
res = await resolve(
KanbanConnection,
KanbanEdge,
KanbanNode,
None,
query,
None,
last,
context=info.context,
)
# end_time = time.monotonic()
# print("Task count", len(res.edges))
# print("Project count", len(projects))
# print(f"Kanban query resolved in {end_time-start_time:.04f}s")
return res