diff --git a/schema.graphql b/schema.graphql index 9bedcc40..496eed48 100644 --- a/schema.graphql +++ b/schema.graphql @@ -428,7 +428,7 @@ type CustomQuery { chapter(id: ID!): ChapterNode contentBlock(id: ID!): ContentBlockNode books(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]): BookNodeConnection - topics(before: String, after: String, first: Int, last: Int): TopicConnection + topics(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]): TopicNodeConnection 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 chapters(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection objectiveGroup(id: ID!): ObjectiveGroupNode @@ -955,16 +955,6 @@ type TeamNode implements Node { pk: Int } -type TopicConnection { - pageInfo: PageInfo! - edges: [TopicEdge]! -} - -type TopicEdge { - node: TopicNode - cursor: String! -} - type TopicNode implements Node { title: String! slug: String! diff --git a/server/books/schema/connections.py b/server/books/schema/connections.py new file mode 100644 index 00000000..49e034aa --- /dev/null +++ b/server/books/schema/connections.py @@ -0,0 +1,44 @@ +import graphene +from graphene import relay + +from books.schema.nodes import ModuleNode, TopicNode + + +class NodeConnection(relay.Connection): + """ + Custom connection type, so we don't need to deal with the edges and nodes if we don't want to, while still + adhering to the relay specification + Idea from: https://javascript.plainenglish.io/graphql-pagination-using-edges-vs-nodes-in-connections-f2ddb8edffa0 + + Always has property called `nodes` which is filled with the Meta node class. + + Example: + class TopicConnection(NodeConnection): + class Meta: + node = TopicNode + # will have + # nodes = graphene.List(TopicNode) + + """ + class Meta: + abstract = True + + def resolve_nodes(self, *args, **kwargs): + return [edge.node for edge in self.edges] + + @classmethod + def __init_subclass_with_meta__(cls, node=None, name=None, **options): + cls.nodes = graphene.List(node) + return super().__init_subclass_with_meta__( + node, name, **options + ) + + +class TopicConnection(NodeConnection): + class Meta: + node = TopicNode + + +class ModuleConnection(NodeConnection): + class Meta: + node = ModuleNode diff --git a/server/books/schema/mutations/chapter.py b/server/books/schema/mutations/chapter.py index 48f62da1..d5331884 100644 --- a/server/books/schema/mutations/chapter.py +++ b/server/books/schema/mutations/chapter.py @@ -4,7 +4,7 @@ from graphene import relay from api.utils import get_object from books.models import Chapter from books.schema.inputs import UserGroupBlockVisibility -from books.schema.queries import ChapterNode +from books.schema.nodes import ChapterNode from users.models import SchoolClass diff --git a/server/books/schema/mutations/contentblock.py b/server/books/schema/mutations/contentblock.py index f20500b9..7d30d5bf 100644 --- a/server/books/schema/mutations/contentblock.py +++ b/server/books/schema/mutations/contentblock.py @@ -8,7 +8,7 @@ from graphql_relay import from_global_id from api.utils import get_object, get_errors from books.models import ContentBlock, Chapter, SchoolClass from books.schema.inputs import ContentBlockInput -from books.schema.queries import ContentBlockNode +from ..nodes import ContentBlockNode from core.utils import set_hidden_for, set_visible_for from .utils import handle_content_block, set_user_defined_block_type diff --git a/server/books/schema/mutations/module.py b/server/books/schema/mutations/module.py index d50e8b20..2a788058 100644 --- a/server/books/schema/mutations/module.py +++ b/server/books/schema/mutations/module.py @@ -5,7 +5,7 @@ from graphene import relay from api.utils import get_object from books.models import Module, RecentModule -from books.schema.queries import ModuleNode +from books.schema.nodes import ModuleNode from users.models import SchoolClass diff --git a/server/books/schema/mutations/topic.py b/server/books/schema/mutations/topic.py index fafadae1..32862773 100644 --- a/server/books/schema/mutations/topic.py +++ b/server/books/schema/mutations/topic.py @@ -3,7 +3,7 @@ from graphene import relay from api.utils import get_object from books.models import Topic -from books.schema.queries import TopicNode +from books.schema.nodes import TopicNode class UpdateLastTopic(relay.ClientIDMutation): diff --git a/server/books/schema/nodes.py b/server/books/schema/nodes.py new file mode 100644 index 00000000..73b8d34a --- /dev/null +++ b/server/books/schema/nodes.py @@ -0,0 +1,260 @@ +import graphene +from django.db.models import Q +from graphene import relay +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField + +from assignments.models import StudentSubmission +from assignments.schema.types import StudentSubmissionNode +from books.models import ContentBlock, Chapter, Module, RecentModule, Topic, Book +from books.utils import are_solutions_enabled_for +from core.logger import get_logger +from notes.models import ContentBlockBookmark, ChapterBookmark, ModuleBookmark +from notes.schema import ContentBlockBookmarkNode, ChapterBookmarkNode, ModuleBookmarkNode +from rooms.models import ModuleRoomSlug +from surveys.models import Answer +from surveys.schema import AnswerNode + +logger = get_logger(__name__) + + +class TextBlockNode(graphene.ObjectType): + text = graphene.String() + + def resolve_text(root, info, **kwargs): + return root['value']['text'] + + +class ContentNode(graphene.Union): + class Meta: + types = (TextBlockNode,) + + @classmethod + def resolve_type(cls, instance, info): + logger.info(instance) + if instance['type'] == 'text_block': + return TextBlockNode + + +class ContentBlockNode(DjangoObjectType): + mine = graphene.Boolean() + bookmarks = graphene.List(ContentBlockBookmarkNode) + + # contents = graphene.List(ContentNode) + + class Meta: + model = ContentBlock + only_fields = [ + 'slug', 'title', 'type', 'contents', 'hidden_for', 'visible_for', 'user_created' + ] + filter_fields = [ + 'slug', 'title', + ] + interfaces = (relay.Node,) + + def resolve_mine(self, info, **kwargs): + return self.owner is not None and self.owner.pk == info.context.user.pk + + def resolve_contents(self, info, **kwargs): + updated_stream_data = [] + for content in self.contents.stream_data: + # only show solutions to teachers and students for whom their teachers have them enabled + if content['type'] == 'solution' \ + and not (are_solutions_enabled_for(info.context.user, self.module) or info.context.user.is_teacher()): + logger.debug('Solution is hidden for this user') + continue + + if content['type'] == 'content_list_item': + for index, list_block in enumerate(content['value']): + content['value'][index] = process_module_room_slug_block(list_block) + + content = process_module_room_slug_block(content) + updated_stream_data.append(content) + + self.contents.stream_data = updated_stream_data + return self.contents + + def resolve_bookmarks(self, info, **kwargs): + return ContentBlockBookmark.objects.filter( + user=info.context.user, + content_block=self + ) + + +class ChapterNode(DjangoObjectType): + content_blocks = DjangoFilterConnectionField(ContentBlockNode) + bookmark = graphene.Field(ChapterBookmarkNode) + + class Meta: + model = Chapter + only_fields = [ + 'slug', 'title', 'description', 'title_hidden_for', 'description_hidden_for' + ] + filter_fields = [ + 'slug', 'title', + ] + interfaces = (relay.Node,) + + def resolve_content_blocks(self, info, **kwargs): + user = info.context.user + school_classes = user.school_classes.values_list('pk') + + by_parent = ContentBlock.get_by_parent(self) \ + .prefetch_related('visible_for') \ + .prefetch_related('hidden_for') + + # don't filter the hidden blocks on the server any more, we do this on the client now, as they are not secret + default_blocks = Q(user_created=False) + owned_by_user = Q(user_created=True, owner=user) + teacher_created_and_visible = Q(Q(user_created=True) & Q(visible_for__in=school_classes)) + + if user.has_perm('users.can_manage_school_class_content'): # teacher + return by_parent.filter(default_blocks | owned_by_user | teacher_created_and_visible).distinct() + else: # student + return by_parent.filter(default_blocks | teacher_created_and_visible).distinct() + + def resolve_bookmark(self, info, **kwargs): + return ChapterBookmark.objects.filter( + user=info.context.user, + chapter=self + ).first() + + +class ModuleNode(DjangoObjectType): + pk = graphene.Int() + chapters = DjangoFilterConnectionField(ChapterNode) + topic = graphene.Field('books.schema.queries.TopicNode') + hero_image = graphene.String() + solutions_enabled = graphene.Boolean() + bookmark = graphene.Field(ModuleBookmarkNode) + my_submissions = DjangoFilterConnectionField(StudentSubmissionNode) + my_answers = DjangoFilterConnectionField(AnswerNode) + my_content_bookmarks = DjangoFilterConnectionField(ContentBlockBookmarkNode) + my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode) + + class Meta: + model = Module + only_fields = [ + 'slug', 'title', 'meta_title', 'teaser', 'intro', 'objective_groups', 'assignments', 'hero_image', 'topic' + ] + filter_fields = { + 'slug': ['exact', 'icontains', 'in'], + 'title': ['exact', 'icontains', 'in'], + } + interfaces = (relay.Node,) + + def resolve_pk(self, info, **kwargs): + return self.id + + def resolve_hero_image(self, info, **kwargs): + if self.hero_image: + return self.hero_image.file.url + + def resolve_chapters(self, info, **kwargs): + return Chapter.get_by_parent(self) + + def resolve_topic(self, info, **kwargs): + return self.get_parent().specific + + def resolve_solutions_enabled(self, info, **kwargs): + school_class = info.context.user.selected_class() + return self.solutions_enabled_for.filter(pk=school_class.pk).exists() if school_class is not None else False + + def resolve_bookmark(self, info, **kwags): + return ModuleBookmark.objects.filter( + user=info.context.user, + module=self + ).first() + + def resolve_my_submissions(self, info, **kwargs): + user = info.context.user + return StudentSubmission.objects.filter(student=user, assignment__module=self) + # we want: + # StudentSubmission + + def resolve_my_answers(self, info, **kwargs): + user = info.context.user + return Answer.objects.filter(owner=user, survey__module=self) + # Survey + + def resolve_my_content_bookmarks(self, info, **kwargs): + user = info.context.user + content_blocks = ContentBlock.objects.live().descendant_of(self) + return ContentBlockBookmark.objects.filter(content_block__in=content_blocks, user=user) + # Bookmark Text + # Bookmark Image etc + # Bookmark Other + # Note + # + + def resolve_my_chapter_bookmarks(self, info, **kwargs): + user = info.context.user + chapters = Chapter.objects.live().descendant_of(self) + return ChapterBookmark.objects.filter(chapter__in=chapters, user=user) + + def resolve_objective_groups(self, root, **kwargs): + return self.objective_groups.all() \ + .prefetch_related('hidden_for') + + +class RecentModuleNode(DjangoObjectType): + class Meta: + model = RecentModule + interfaces = (relay.Node,) + + +class TopicNode(DjangoObjectType): + pk = graphene.Int() + modules = DjangoFilterConnectionField(ModuleNode) + + class Meta: + model = Topic + only_fields = [ + 'slug', 'title', 'teaser', 'description', 'vimeo_id', 'order', 'instructions' + ] + filter_fields = { + 'slug': ['exact', 'icontains', 'in'], + 'title': ['exact', 'icontains', 'in'], + } + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + def resolve_modules(self, *args, **kwargs): + return Module.get_by_parent(self) + + +class BookNode(DjangoObjectType): + pk = graphene.Int() + topics = DjangoFilterConnectionField(TopicNode) + + class Meta: + model = Book + only_fields = [ + 'slug', 'title', + ] + filter_fields = { + 'slug': ['exact', 'icontains', 'in'], + 'title': ['exact', 'icontains', 'in'], + } + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + def resolve_topics(self, *args, **kwargs): + return Topic.get_by_parent(self) + + +def process_module_room_slug_block(content): + if content['type'] == 'module_room_slug': + try: + module_room_slug = ModuleRoomSlug.objects.get(title=content['value']['title']) + content['value'] = { + 'title': content['value']['title'], + 'slug': module_room_slug.slug + } + except ModuleRoomSlug.DoesNotExist: + pass + return content diff --git a/server/books/schema/queries.py b/server/books/schema/queries.py index bcf41e0e..7099beed 100644 --- a/server/books/schema/queries.py +++ b/server/books/schema/queries.py @@ -1,244 +1,15 @@ import graphene -from django.db.models import Q from graphene import relay -from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField from api.utils import get_object -from assignments.models import StudentSubmission -from assignments.schema.types import StudentSubmissionNode -from books.utils import are_solutions_enabled_for -from notes.models import ContentBlockBookmark, ChapterBookmark, ModuleBookmark -from notes.schema import ContentBlockBookmarkNode, ChapterBookmarkNode, ModuleBookmarkNode -from rooms.models import ModuleRoomSlug -from surveys.models import Answer -from surveys.schema import AnswerNode -from ..models import Book, Topic, Module, Chapter, ContentBlock, RecentModule +from core.logger import get_logger +from ..models import Book, Topic, Module, Chapter +from .nodes import ContentBlockNode, ChapterNode, ModuleNode, TopicNode, BookNode +from .connections import TopicConnection, ModuleConnection -def process_module_room_slug_block(content): - if content['type'] == 'module_room_slug': - try: - module_room_slug = ModuleRoomSlug.objects.get(title=content['value']['title']) - content['value'] = { - 'title': content['value']['title'], - 'slug': module_room_slug.slug - } - except ModuleRoomSlug.DoesNotExist: - pass - return content - - -class ContentBlockNode(DjangoObjectType): - mine = graphene.Boolean() - bookmarks = graphene.List(ContentBlockBookmarkNode) - - class Meta: - model = ContentBlock - only_fields = [ - 'slug', 'title', 'type', 'contents', 'hidden_for', 'visible_for', 'user_created' - ] - filter_fields = [ - 'slug', 'title', - ] - interfaces = (relay.Node,) - - def resolve_mine(self, info, **kwargs): - return self.owner is not None and self.owner.pk == info.context.user.pk - - def resolve_contents(self, info, **kwargs): - updated_stream_data = [] - for content in self.contents.stream_data: - # only show solutions to teachers and students for whom their teachers have them enabled - if content['type'] == 'solution' and not (are_solutions_enabled_for(info.context.user, self.module) or info.context.user.is_teacher()): - continue - - if content['type'] == 'content_list_item': - for index, list_block in enumerate(content['value']): - content['value'][index] = process_module_room_slug_block(list_block) - - content = process_module_room_slug_block(content) - updated_stream_data.append(content) - - self.contents.stream_data = updated_stream_data - return self.contents - - def resolve_bookmarks(self, info, **kwargs): - return ContentBlockBookmark.objects.filter( - user=info.context.user, - content_block=self - ) - - -class ChapterNode(DjangoObjectType): - content_blocks = DjangoFilterConnectionField(ContentBlockNode) - bookmark = graphene.Field(ChapterBookmarkNode) - - class Meta: - model = Chapter - only_fields = [ - 'slug', 'title', 'description', 'title_hidden_for', 'description_hidden_for' - ] - filter_fields = [ - 'slug', 'title', - ] - interfaces = (relay.Node,) - - def resolve_content_blocks(self, info, **kwargs): - user = info.context.user - school_classes = user.school_classes.values_list('pk') - - by_parent = ContentBlock.get_by_parent(self) \ - .prefetch_related('visible_for') \ - .prefetch_related('hidden_for') - - # don't filter the hidden blocks on the server any more, we do this on the client now, as they are not secret - default_blocks = Q(user_created=False) - owned_by_user = Q(user_created=True, owner=user) - teacher_created_and_visible = Q(Q(user_created=True) & Q(visible_for__in=school_classes)) - - if user.has_perm('users.can_manage_school_class_content'): # teacher - return by_parent.filter(default_blocks | owned_by_user | teacher_created_and_visible).distinct() - else: # student - return by_parent.filter(default_blocks | teacher_created_and_visible).distinct() - - def resolve_bookmark(self, info, **kwags): - return ChapterBookmark.objects.filter( - user=info.context.user, - chapter=self - ).first() - - -class ModuleNode(DjangoObjectType): - pk = graphene.Int() - chapters = DjangoFilterConnectionField(ChapterNode) - topic = graphene.Field('books.schema.queries.TopicNode') - hero_image = graphene.String() - solutions_enabled = graphene.Boolean() - bookmark = graphene.Field(ModuleBookmarkNode) - my_submissions = DjangoFilterConnectionField(StudentSubmissionNode) - my_answers = DjangoFilterConnectionField(AnswerNode) - my_content_bookmarks = DjangoFilterConnectionField(ContentBlockBookmarkNode) - my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode) - - class Meta: - model = Module - only_fields = [ - 'slug', 'title', 'meta_title', 'teaser', 'intro', 'objective_groups', 'assignments', 'hero_image', 'topic' - ] - filter_fields = { - 'slug': ['exact', 'icontains', 'in'], - 'title': ['exact', 'icontains', 'in'], - } - interfaces = (relay.Node,) - - def resolve_pk(self, info, **kwargs): - return self.id - - def resolve_hero_image(self, info, **kwargs): - if self.hero_image: - return self.hero_image.file.url - - def resolve_chapters(self, info, **kwargs): - return Chapter.get_by_parent(self) - - def resolve_topic(self, info, **kwargs): - return self.get_parent().specific - - def resolve_solutions_enabled(self, info, **kwargs): - school_class = info.context.user.selected_class() - return self.solutions_enabled_for.filter(pk=school_class.pk).exists() if school_class is not None else False - - def resolve_bookmark(self, info, **kwags): - return ModuleBookmark.objects.filter( - user=info.context.user, - module=self - ).first() - - def resolve_my_submissions(self, info, **kwargs): - user = info.context.user - return StudentSubmission.objects.filter(student=user, assignment__module=self) - # we want: - # StudentSubmission - - def resolve_my_answers(self, info, **kwargs): - user = info.context.user - return Answer.objects.filter(owner=user, survey__module=self) - # Survey - - def resolve_my_content_bookmarks(self, info, **kwargs): - user = info.context.user - content_blocks = ContentBlock.objects.live().descendant_of(self) - return ContentBlockBookmark.objects.filter(content_block__in=content_blocks, user=user) - # Bookmark Text - # Bookmark Image etc - # Bookmark Other - # Note - # - - def resolve_my_chapter_bookmarks(self, info, **kwargs): - user = info.context.user - chapters = Chapter.objects.live().descendant_of(self) - return ChapterBookmark.objects.filter(chapter__in=chapters, user=user) - - def resolve_objective_groups(self, root, **kwargs): - return self.objective_groups.all() \ - .prefetch_related('hidden_for') - - -class RecentModuleNode(DjangoObjectType): - class Meta: - model = RecentModule - interfaces = (relay.Node,) - - -class TopicNode(DjangoObjectType): - pk = graphene.Int() - modules = DjangoFilterConnectionField(ModuleNode) - - class Meta: - model = Topic - only_fields = [ - 'slug', 'title', 'teaser', 'description', 'vimeo_id', 'order', 'instructions' - ] - filter_fields = { - 'slug': ['exact', 'icontains', 'in'], - 'title': ['exact', 'icontains', 'in'], - } - interfaces = (relay.Node,) - - def resolve_pk(self, *args, **kwargs): - return self.id - - def resolve_modules(self, *args, **kwargs): - return Module.get_by_parent(self) - - -class BookNode(DjangoObjectType): - pk = graphene.Int() - topics = DjangoFilterConnectionField(TopicNode) - - class Meta: - model = Book - only_fields = [ - 'slug', 'title', - ] - filter_fields = { - 'slug': ['exact', 'icontains', 'in'], - 'title': ['exact', 'icontains', 'in'], - } - interfaces = (relay.Node,) - - def resolve_pk(self, *args, **kwargs): - return self.id - - def resolve_topics(self, *args, **kwargs): - return Topic.get_by_parent(self) - - -class TopicConnection(relay.Connection): - class Meta: - node = TopicNode +logger = get_logger(__name__) class BookQuery(object): @@ -250,7 +21,9 @@ class BookQuery(object): content_block = relay.Node.Field(ContentBlockNode) books = DjangoFilterConnectionField(BookNode) - topics = relay.ConnectionField(TopicConnection) + topics = DjangoFilterConnectionField(TopicNode) + # topics = relay.ConnectionField(TopicConnection) + # modules = relay.ConnectionField(ModuleConnection) modules = DjangoFilterConnectionField(ModuleNode) chapters = DjangoFilterConnectionField(ChapterNode)