Add query for activities on server

This commit is contained in:
Ramon Wenger 2024-02-26 10:48:40 +01:00
parent c7c406f0ba
commit 799e60e63a
6 changed files with 334 additions and 266 deletions

View File

@ -4,7 +4,9 @@ from graphene import relay
from graphene_django.debug import DjangoDebug from graphene_django.debug import DjangoDebug
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from api import graphene_wagtail # Keep this import exactly here, it's necessary for StreamField conversion from api import (
graphene_wagtail,
) # Keep this import exactly here, it's necessary for StreamField conversion
from assignments.schema.mutations import AssignmentMutations from assignments.schema.mutations import AssignmentMutations
from assignments.schema.queries import AssignmentsQuery, StudentSubmissionQuery from assignments.schema.queries import AssignmentsQuery, StudentSubmissionQuery
from basicknowledge.queries import InstrumentQuery from basicknowledge.queries import InstrumentQuery
@ -25,20 +27,42 @@ from rooms.schema import RoomsQuery, ModuleRoomsQuery
from users.schema import AllUsersQuery, UsersQuery, ProfileMutations from users.schema import AllUsersQuery, UsersQuery, ProfileMutations
class Query(UsersQuery, AllUsersQuery, ModuleRoomsQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, class Query(
StudentSubmissionQuery, InstrumentQuery, PortfolioQuery, SurveysQuery, AllNewsTeasersQuery, UsersQuery,
graphene.ObjectType): AllUsersQuery,
ModuleRoomsQuery,
RoomsQuery,
ObjectivesQuery,
BookQuery,
AssignmentsQuery,
StudentSubmissionQuery,
InstrumentQuery,
PortfolioQuery,
SurveysQuery,
AllNewsTeasersQuery,
graphene.ObjectType,
):
node = relay.Node.Field() node = relay.Node.Field()
if settings.DEBUG: if settings.DEBUG:
debug = graphene.Field(DjangoDebug, name='_debug') debug = graphene.Field(DjangoDebug, name="_debug")
class Mutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, OauthMutations, class Mutation(
PortfolioMutations, ProfileMutations, SurveyMutations, NoteMutations, SpellCheckMutations, BookMutations,
graphene.ObjectType): RoomMutations,
AssignmentMutations,
ObjectiveMutations,
OauthMutations,
PortfolioMutations,
ProfileMutations,
SurveyMutations,
NoteMutations,
SpellCheckMutations,
graphene.ObjectType,
):
if settings.DEBUG: if settings.DEBUG:
debug = graphene.Field(DjangoDebug, name='_debug') debug = graphene.Field(DjangoDebug, name="_debug")
schema = graphene.Schema(query=Query, mutation=Mutation) schema = graphene.Schema(query=Query, mutation=Mutation)

View File

@ -1,4 +1,5 @@
import graphene import graphene
from wagtail.models import Page
from assignments.models import StudentSubmission from assignments.models import StudentSubmission
from assignments.schema.types import AssignmentNode, StudentSubmissionNode from assignments.schema.types import AssignmentNode, StudentSubmissionNode
from books.models import Chapter, ContentBlock, Module, RecentModule from books.models import Chapter, ContentBlock, Module, RecentModule
@ -10,7 +11,12 @@ from django.db.models import Q
from graphene import relay from graphene import relay
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
from notes.models import ChapterBookmark, ContentBlockBookmark, ModuleBookmark from notes.models import (
ChapterBookmark,
ContentBlockBookmark,
Highlight,
ModuleBookmark,
)
from objectives.schema import ObjectiveGroupNode from objectives.schema import ObjectiveGroupNode
from surveys.models import Answer from surveys.models import Answer
from surveys.schema import AnswerNode from surveys.schema import AnswerNode
@ -57,6 +63,7 @@ class ModuleNode(DjangoObjectType):
category = graphene.Field(ModuleCategoryNode) category = graphene.Field(ModuleCategoryNode)
language = graphene.String() language = graphene.String()
highlights = graphene.List("notes.schema.HighlightNode") highlights = graphene.List("notes.schema.HighlightNode")
all_highlights = graphene.List("notes.schema.HighlightNode")
def resolve_chapters(self, info, **kwargs): def resolve_chapters(self, info, **kwargs):
return Chapter.get_by_parent(self) return Chapter.get_by_parent(self)
@ -128,6 +135,15 @@ class ModuleNode(DjangoObjectType):
def resolve_highlights(root: Module, info, **kwargs): def resolve_highlights(root: Module, info, **kwargs):
return root.highlights.filter(user=info.context.user) return root.highlights.filter(user=info.context.user)
@staticmethod
def resolve_all_highlights(root: Module, info, **kwargs):
# todo: is this too expensive, query-wise
pages = Page.objects.live().descendant_of(root)
highlights = Highlight.objects.filter(user=info.context.user).filter(
page__in=pages
)
return highlights
class RecentModuleNode(DjangoObjectType): class RecentModuleNode(DjangoObjectType):
class Meta: class Meta:

View File

@ -1,10 +1,13 @@
import graphene import graphene
from wagtail.models import Page
from books.models import Module, Topic from books.models import Module, Topic
from books.schema.nodes import ModuleNode from books.schema.nodes import ModuleNode
from graphene import relay from graphene import relay
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
from notes.models import Highlight
class NotFoundFailure: class NotFoundFailure:
reason = "Not Found" reason = "Not Found"
@ -16,7 +19,8 @@ class NotFound(graphene.ObjectType):
class TopicNode(DjangoObjectType): class TopicNode(DjangoObjectType):
pk = graphene.Int() pk = graphene.Int()
modules = DjangoFilterConnectionField("books.schema.nodes.ModuleNode") modules = graphene.List("books.schema.nodes.ModuleNode")
highlights = graphene.List("notes.schema.HighlightNode")
class Meta: class Meta:
model = Topic model = Topic
@ -45,6 +49,12 @@ class TopicNode(DjangoObjectType):
ids += list(translation.get_child_ids()) ids += list(translation.get_child_ids())
return Module.objects.filter(id__in=ids).live() return Module.objects.filter(id__in=ids).live()
@staticmethod
def resolve_highlights(root: Topic, info, **kwargs):
# todo: is this too expensive, query-wise?
pages = Page.objects.live().descendant_of(root)
return Highlight.objects.filter(user=info.context.user).filter(page__in=pages)
class TopicOr404Node(graphene.Union): class TopicOr404Node(graphene.Union):
class Meta: class Meta:

View File

@ -53,6 +53,8 @@ type Query {
allUsers(offset: Int, before: String, after: String, first: Int, last: Int, username: String, email: String): PrivateUserNodeConnection allUsers(offset: Int, before: String, after: String, first: Int, last: Int, username: String, email: String): PrivateUserNodeConnection
myActivity(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection myActivity(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection
myInstrumentActivity(offset: Int, before: String, after: String, first: Int, last: Int, slug: String): InstrumentNodeConnection myInstrumentActivity(offset: Int, before: String, after: String, first: Int, last: Int, slug: String): InstrumentNodeConnection
myActivities: ActivityNode
something: String!
_debug: DjangoDebug _debug: DjangoDebug
} }
@ -129,6 +131,7 @@ type ModuleNode implements ModuleInterface {
snapshots: [SnapshotNode] snapshots: [SnapshotNode]
language: String language: String
highlights: [HighlightNode] highlights: [HighlightNode]
allHighlights: [HighlightNode]
} }
interface ModuleInterface { interface ModuleInterface {
@ -158,107 +161,25 @@ type TopicNode implements Node {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
pk: Int pk: Int
modules(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection modules: [ModuleNode]
highlights: [HighlightNode]
} }
type ModuleNodeConnection { type HighlightNode implements Node {
"""Pagination data for this connection."""
pageInfo: PageInfo!
"""Contains the nodes in this connection."""
edges: [ModuleNodeEdge]!
}
"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
"""
type PageInfo {
"""When paginating forwards, are there more items?"""
hasNextPage: Boolean!
"""When paginating backwards, are there more items?"""
hasPreviousPage: Boolean!
"""When paginating backwards, the cursor to continue."""
startCursor: String
"""When paginating forwards, the cursor to continue."""
endCursor: String
}
"""A Relay edge containing a `ModuleNode` and its cursor."""
type ModuleNodeEdge {
"""The item at the end of the edge"""
node: ModuleNode
"""A cursor for use in pagination"""
cursor: String!
}
type ModuleLevelNode implements Node {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
name: String! user: PrivateUserNode!
filterAttributeType: BooksModuleLevelFilterAttributeTypeChoices! page: HighlightableNode
contentIndex: Int
"""Order in the Dropdown List""" contentUuid: UUID
order: Int! paragraphIndex: Int!
moduleSet(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection! startPosition: Int!
selectionLength: Int!
text: String!
note: NoteNode
color: String!
} }
"""An enumeration."""
enum BooksModuleLevelFilterAttributeTypeChoices {
"""All"""
ALL
"""Exact"""
EXACT
}
type ModuleCategoryNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
filterAttributeType: BooksModuleCategoryFilterAttributeTypeChoices!
"""Order in the Dropdown List"""
order: Int!
moduleSet(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection!
}
"""An enumeration."""
enum BooksModuleCategoryFilterAttributeTypeChoices {
"""All"""
ALL
"""Exact"""
EXACT
}
type AssignmentNode implements Node {
"""The ID of the object"""
id: ID!
created: DateTime!
modified: DateTime!
title: String!
assignment: String!
solution: String
deleted: Boolean!
owner: PrivateUserNode
module: ModuleNode!
userCreated: Boolean!
taskbaseId: String
submissions: [StudentSubmissionNode]
submission: StudentSubmissionNode
}
"""
The `DateTime` scalar type represents a DateTime
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
"""
scalar DateTime
type PrivateUserNode implements Node { type PrivateUserNode implements Node {
firstName: String! firstName: String!
lastName: String! lastName: String!
@ -298,6 +219,60 @@ type PrivateUserNode implements Node {
readOnly: Boolean readOnly: Boolean
} }
type ModuleLevelNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
filterAttributeType: BooksModuleLevelFilterAttributeTypeChoices!
"""Order in the Dropdown List"""
order: Int!
moduleSet(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection!
}
"""An enumeration."""
enum BooksModuleLevelFilterAttributeTypeChoices {
"""All"""
ALL
"""Exact"""
EXACT
}
type ModuleNodeConnection {
"""Pagination data for this connection."""
pageInfo: PageInfo!
"""Contains the nodes in this connection."""
edges: [ModuleNodeEdge]!
}
"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
"""
type PageInfo {
"""When paginating forwards, are there more items?"""
hasNextPage: Boolean!
"""When paginating backwards, are there more items?"""
hasPreviousPage: Boolean!
"""When paginating backwards, the cursor to continue."""
startCursor: String
"""When paginating forwards, the cursor to continue."""
endCursor: String
}
"""A Relay edge containing a `ModuleNode` and its cursor."""
type ModuleNodeEdge {
"""The item at the end of the edge"""
node: ModuleNode
"""A cursor for use in pagination"""
cursor: String!
}
type TeamNode implements Node { type TeamNode implements Node {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
@ -345,6 +320,194 @@ type ClassMemberNode {
isMe: Boolean isMe: Boolean
} }
union HighlightableNode = ContentBlockNode | InstrumentNode | ModuleNode | ChapterNode
type ContentBlockNode implements Node & ContentBlockInterface {
title: String
"""
Der Name der Seite, wie er in URLs angezeigt werden soll, z.B. http://domain.com/blog/[my-slug]/
"""
slug: String!
hiddenFor: [SchoolClassNode]
visibleFor: [SchoolClassNode]
userCreated: Boolean!
contents: GenericStreamFieldType
type: String!
"""The ID of the object"""
id: ID!
mine: Boolean
bookmarks: [ContentBlockBookmarkNode]
originalCreator: PublicUserNode
instrumentCategory: InstrumentCategoryNode
path: String
highlights: [HighlightNode]
}
interface ContentBlockInterface {
title: String
contents: GenericStreamFieldType
type: String!
}
scalar GenericStreamFieldType
type ContentBlockBookmarkNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
note: NoteNode
uuid: UUID
contentBlock: ContentBlockNode!
}
type NoteNode implements Node {
"""The ID of the object"""
id: ID!
text: String!
contentblockbookmark: ContentBlockBookmarkNode
modulebookmark: ModuleBookmarkNode
chapterbookmark: ChapterBookmarkNode
instrumentbookmark: InstrumentBookmarkNode
highlight: HighlightNode
pk: Int
}
type ModuleBookmarkNode {
id: ID!
user: PrivateUserNode!
note: NoteNode
module: ModuleNode!
}
type ChapterBookmarkNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
note: NoteNode
chapter: ChapterNode!
}
type ChapterNode implements Node & ChapterInterface {
title: String
"""
Der Name der Seite, wie er in URLs angezeigt werden soll, z.B. http://domain.com/blog/[my-slug]/
"""
slug: String!
description: String
titleHiddenFor: [SchoolClassNode]
descriptionHiddenFor: [SchoolClassNode]
"""The ID of the object"""
id: ID!
bookmark: ChapterBookmarkNode
contentBlocks: [ContentBlockNode]
path: String
highlights: [HighlightNode]
}
interface ChapterInterface {
description: String
title: String
}
type InstrumentBookmarkNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
note: NoteNode
uuid: UUID
instrument: InstrumentNode!
}
"""
Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects
in fields, resolvers and input.
"""
scalar UUID
type InstrumentNode implements Node {
"""Der Seitentitel, der öffentlich angezeigt werden soll"""
title: String!
"""
Der Name der Seite, wie er in URLs angezeigt werden soll, z.B. http://domain.com/blog/[my-slug]/
"""
slug: String!
intro: String!
contents: GenericStreamFieldType
"""The ID of the object"""
id: ID!
bookmarks: [InstrumentBookmarkNode]
type: InstrumentTypeNode
language: String
highlights: [HighlightNode]
}
type InstrumentTypeNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
category: InstrumentCategoryNode
type: String!
}
type InstrumentCategoryNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
background: String!
foreground: String!
types: [InstrumentTypeNode]
}
type ModuleCategoryNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
filterAttributeType: BooksModuleCategoryFilterAttributeTypeChoices!
"""Order in the Dropdown List"""
order: Int!
moduleSet(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, slug_In: [String], title: String, title_Icontains: String, title_In: [String]): ModuleNodeConnection!
}
"""An enumeration."""
enum BooksModuleCategoryFilterAttributeTypeChoices {
"""All"""
ALL
"""Exact"""
EXACT
}
type AssignmentNode implements Node {
"""The ID of the object"""
id: ID!
created: DateTime!
modified: DateTime!
title: String!
assignment: String!
solution: String
deleted: Boolean!
owner: PrivateUserNode
module: ModuleNode!
userCreated: Boolean!
taskbaseId: String
submissions: [StudentSubmissionNode]
submission: StudentSubmissionNode
}
"""
The `DateTime` scalar type represents a DateTime
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
"""
scalar DateTime
type StudentSubmissionNode implements Node { type StudentSubmissionNode implements Node {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
@ -453,11 +616,6 @@ type SnapshotChapterNode implements Node & ChapterInterface {
titleHidden: Boolean titleHidden: Boolean
} }
interface ChapterInterface {
description: String
title: String
}
type SnapshotContentBlockNode implements Node & ContentBlockInterface { type SnapshotContentBlockNode implements Node & ContentBlockInterface {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
@ -467,14 +625,6 @@ type SnapshotContentBlockNode implements Node & ContentBlockInterface {
hidden: Boolean hidden: Boolean
} }
interface ContentBlockInterface {
title: String
contents: GenericStreamFieldType
type: String!
}
scalar GenericStreamFieldType
type ContentBlockNodeConnection { type ContentBlockNodeConnection {
"""Pagination data for this connection.""" """Pagination data for this connection."""
pageInfo: PageInfo! pageInfo: PageInfo!
@ -492,152 +642,6 @@ type ContentBlockNodeEdge {
cursor: String! cursor: String!
} }
type ContentBlockNode implements Node & ContentBlockInterface {
title: String
"""
Der Name der Seite, wie er in URLs angezeigt werden soll, z.B. http://domain.com/blog/[my-slug]/
"""
slug: String!
hiddenFor: [SchoolClassNode]
visibleFor: [SchoolClassNode]
userCreated: Boolean!
contents: GenericStreamFieldType
type: String!
"""The ID of the object"""
id: ID!
mine: Boolean
bookmarks: [ContentBlockBookmarkNode]
originalCreator: PublicUserNode
instrumentCategory: InstrumentCategoryNode
path: String
highlights: [HighlightNode]
}
type ContentBlockBookmarkNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
note: NoteNode
uuid: UUID
contentBlock: ContentBlockNode!
}
type NoteNode implements Node {
"""The ID of the object"""
id: ID!
text: String!
contentblockbookmark: ContentBlockBookmarkNode
modulebookmark: ModuleBookmarkNode
chapterbookmark: ChapterBookmarkNode
instrumentbookmark: InstrumentBookmarkNode
highlight: HighlightNode
pk: Int
}
type ModuleBookmarkNode {
id: ID!
user: PrivateUserNode!
note: NoteNode
module: ModuleNode!
}
type ChapterBookmarkNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
note: NoteNode
chapter: ChapterNode!
}
type ChapterNode implements Node & ChapterInterface {
title: String
"""
Der Name der Seite, wie er in URLs angezeigt werden soll, z.B. http://domain.com/blog/[my-slug]/
"""
slug: String!
description: String
titleHiddenFor: [SchoolClassNode]
descriptionHiddenFor: [SchoolClassNode]
"""The ID of the object"""
id: ID!
bookmark: ChapterBookmarkNode
contentBlocks: [ContentBlockNode]
path: String
highlights: [HighlightNode]
}
type HighlightNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
page: HighlightableNode
contentIndex: Int
contentUuid: UUID
paragraphIndex: Int!
startPosition: Int!
selectionLength: Int!
text: String!
note: NoteNode
color: String!
}
union HighlightableNode = ContentBlockNode | InstrumentNode | ModuleNode | ChapterNode
type InstrumentNode implements Node {
"""Der Seitentitel, der öffentlich angezeigt werden soll"""
title: String!
"""
Der Name der Seite, wie er in URLs angezeigt werden soll, z.B. http://domain.com/blog/[my-slug]/
"""
slug: String!
intro: String!
contents: GenericStreamFieldType
"""The ID of the object"""
id: ID!
bookmarks: [InstrumentBookmarkNode]
type: InstrumentTypeNode
language: String
highlights: [HighlightNode]
}
type InstrumentBookmarkNode implements Node {
"""The ID of the object"""
id: ID!
user: PrivateUserNode!
note: NoteNode
uuid: UUID
instrument: InstrumentNode!
}
"""
Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects
in fields, resolvers and input.
"""
scalar UUID
type InstrumentTypeNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
category: InstrumentCategoryNode
type: String!
}
type InstrumentCategoryNode implements Node {
"""The ID of the object"""
id: ID!
name: String!
background: String!
foreground: String!
types: [InstrumentTypeNode]
}
type SnapshotObjectiveGroupNode implements Node { type SnapshotObjectiveGroupNode implements Node {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
@ -973,6 +977,10 @@ type InstrumentNodeEdge {
cursor: String! cursor: String!
} }
type ActivityNode {
topics: [TopicNode]
}
"""Debugging information for the current query.""" """Debugging information for the current query."""
type DjangoDebug { type DjangoDebug {
"""Executed SQL queries for this API query.""" """Executed SQL queries for this API query."""

View File

@ -2,9 +2,10 @@ import graphene
from basicknowledge.models import BasicKnowledge from basicknowledge.models import BasicKnowledge
from books.models import Module from books.models import Module
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
from books.models.topic import Topic
from users.models import User from users.models import User
from .types import PrivateUserNode from .types import ActivityNode, PrivateUserNode
class UsersQuery(object): class UsersQuery(object):
@ -14,6 +15,7 @@ class UsersQuery(object):
my_instrument_activity = DjangoFilterConnectionField( my_instrument_activity = DjangoFilterConnectionField(
"basicknowledge.queries.InstrumentNode" "basicknowledge.queries.InstrumentNode"
) )
my_activities = graphene.Field(ActivityNode)
def resolve_me(self, info, **kwargs): def resolve_me(self, info, **kwargs):
return info.context.user return info.context.user
@ -30,6 +32,10 @@ class UsersQuery(object):
def resolve_my_instrument_activity(self, info, **kwargs): def resolve_my_instrument_activity(self, info, **kwargs):
return BasicKnowledge.objects.filter(instrumentbookmark__user=info.context.user) return BasicKnowledge.objects.filter(instrumentbookmark__user=info.context.user)
def resolve_my_activities(self, info, **kwargs):
topics = Topic.objects.all()
return {"topics": topics}
class AllUsersQuery(object): class AllUsersQuery(object):
me = graphene.Field(PrivateUserNode) me = graphene.Field(PrivateUserNode)

View File

@ -239,3 +239,7 @@ class FieldError(graphene.ObjectType):
class UpdateError(graphene.ObjectType): class UpdateError(graphene.ObjectType):
field = graphene.String() field = graphene.String()
errors = graphene.List(FieldError) errors = graphene.List(FieldError)
class ActivityNode(graphene.ObjectType):
topics = graphene.List("books.schema.nodes.TopicNode")