Skip to content

Commit

Permalink
Merge pull request #249 from ynput/248-graphql-kanban-resolver
Browse files Browse the repository at this point in the history
Server-side kanban resolver
  • Loading branch information
martastain authored Jun 21, 2024
2 parents e01f8c1 + c5c9d01 commit 70d246c
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 8 deletions.
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)
53 changes: 53 additions & 0 deletions ayon_server/graphql/nodes/kanban.py
Original file line number Diff line number Diff line change
@@ -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
179 changes: 179 additions & 0 deletions ayon_server/graphql/resolvers/kanban.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 21 additions & 8 deletions ayon_server/graphql/resolvers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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)

Expand Down

0 comments on commit 70d246c

Please sign in to comment.