diff --git a/server/books/migrations/0025_auto_20210414_2116.py b/server/books/migrations/0025_auto_20210414_2116.py new file mode 100644 index 00000000..3dd6bb09 --- /dev/null +++ b/server/books/migrations/0025_auto_20210414_2116.py @@ -0,0 +1,49 @@ +# Generated by Django 2.2.19 on 2021-04-14 21:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0024_auto_20210218_1336'), + ] + + operations = [ + migrations.CreateModel( + name='ChapterSnapshot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title_hidden', models.BooleanField(default=False)), + ('description_hidden', models.BooleanField(default=False)), + ('chapter', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='chapter_snapshots', to='books.Chapter')), + ], + ), + migrations.CreateModel( + name='Snapshot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chapters', models.ManyToManyField(through='books.ChapterSnapshot', to='books.Chapter')), + ('hidden_content_blocks', models.ManyToManyField(related_name='hidden_for_snapshots', to='books.ContentBlock')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='books.Module')), + ], + ), + migrations.CreateModel( + name='ContentBlockSnapshot', + fields=[ + ('contentblock_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='books.ContentBlock')), + ('hidden', models.BooleanField(default=False)), + ('snapshot', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='custom_content_blocks', to='books.Snapshot')), + ], + options={ + 'abstract': False, + }, + bases=('books.contentblock',), + ), + migrations.AddField( + model_name='chaptersnapshot', + name='snapshot', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapter_snapshots', to='books.Snapshot'), + ), + ] diff --git a/server/books/models/__init__.py b/server/books/models/__init__.py index ab27e557..22837b3d 100644 --- a/server/books/models/__init__.py +++ b/server/books/models/__init__.py @@ -3,3 +3,4 @@ from .module import * from .topic import * from .chapter import * from .contentblock import * +from .snapshot import * diff --git a/server/books/models/contentblock.py b/server/books/models/contentblock.py index d9974a4f..7ff57ebf 100644 --- a/server/books/models/contentblock.py +++ b/server/books/models/contentblock.py @@ -110,3 +110,13 @@ class ContentBlock(StrictHierarchyPage): survey.module = module survey.save() super().save(*args, **kwargs) + + +class ContentBlockSnapshot(ContentBlock): + hidden = models.BooleanField(default=False) + snapshot = models.ForeignKey( + 'books.snapshot', + on_delete=models.SET_NULL, + null=True, + related_name='custom_content_blocks' + ) diff --git a/server/books/models/snapshot.py b/server/books/models/snapshot.py new file mode 100644 index 00000000..5d517bb4 --- /dev/null +++ b/server/books/models/snapshot.py @@ -0,0 +1,73 @@ +from django.db import models +from django.db.models import Q + +from books.models import Chapter, ContentBlock, ContentBlockSnapshot + + +class ChapterSnapshot(models.Model): + """ + Captures the state of a chapter at the time when the snapshot was taken, for the school class that was selected + for the user creating the snapshot + """ + chapter = models.ForeignKey( + 'books.Chapter', + related_name='chapter_snapshots', + on_delete=models.PROTECT + ) + snapshot = models.ForeignKey( + 'books.Snapshot', + related_name='chapter_snapshots', + on_delete=models.CASCADE + ) + title_hidden = models.BooleanField(default=False) + description_hidden = models.BooleanField(default=False) + + +class SnapshotManager(models.Manager): + def create_snapshot(self, module, school_class, user, *args, **kwargs): + snapshot = self.create(module=module, *args, **kwargs) + chapters = Chapter.get_by_parent(module).filter( + Q(description_hidden_for=school_class) + | Q(title_hidden_for=school_class) + ) + for chapter in chapters: + ChapterSnapshot.objects.create( + chapter=chapter, + snapshot=snapshot, + title_hidden=chapter.title_hidden_for.filter(id=school_class.id).exists(), + description_hidden=chapter.description_hidden_for.filter(id=school_class.id).exists() + ) + base_qs = ContentBlock.get_by_parent(chapter).filter(snapshotcontentblock__isnull=True) + for content_block in base_qs.filter(user_created=False): + if content_block.hidden_for.filter(id=school_class.id).exists(): + snapshot.hidden_content_blocks.add(content_block) + for content_block in base_qs.filter(user_created=True).filter(owner=user): + new_content_block = SnapshotContentBlock( + hidden=False, + snapshot=snapshot, + contents=content_block.contents, + type=content_block.type, + title=content_block.title + ) + content_block.add_sibling(instance=new_content_block, pos='right') + revision = new_content_block.save_revision() + revision.publish() + new_content_block.save() + + return snapshot + + +class Snapshot(models.Model): + module = models.ForeignKey( + 'books.Module', + on_delete=models.PROTECT + ) + chapters = models.ManyToManyField( + 'books.Chapter', + through=ChapterSnapshot + ) + hidden_content_blocks = models.ManyToManyField( + 'books.ContentBlock', + related_name='hidden_for_snapshots' + ) + objects = SnapshotManager() diff --git a/server/books/schema/interfaces/__init__.py b/server/books/schema/interfaces/__init__.py new file mode 100644 index 00000000..2119ff7d --- /dev/null +++ b/server/books/schema/interfaces/__init__.py @@ -0,0 +1,2 @@ +from .chapter import * +from .module import * diff --git a/server/books/schema/interfaces/chapter.py b/server/books/schema/interfaces/chapter.py new file mode 100644 index 00000000..6fcd3deb --- /dev/null +++ b/server/books/schema/interfaces/chapter.py @@ -0,0 +1,5 @@ +import graphene +from graphene_django.filter import DjangoFilterConnectionField + +class ChapterInterface(graphene.Interface): + content_blocks = DjangoFilterConnectionField('books.schema.nodes.ContentBlockNode') diff --git a/server/books/schema/interfaces/module.py b/server/books/schema/interfaces/module.py new file mode 100644 index 00000000..ff885054 --- /dev/null +++ b/server/books/schema/interfaces/module.py @@ -0,0 +1,8 @@ +import graphene + + +class ModuleInterface(graphene.Interface): + pk = graphene.Int() + + def resolve_pk(self, info, **kwargs): + return self.id diff --git a/server/books/schema/mutations/__init__.py b/server/books/schema/mutations/__init__.py index 999faf9b..ba8f1c00 100644 --- a/server/books/schema/mutations/__init__.py +++ b/server/books/schema/mutations/__init__.py @@ -1,6 +1,7 @@ from books.schema.mutations.chapter import UpdateChapterVisibility from books.schema.mutations.contentblock import MutateContentBlock, AddContentBlock, DeleteContentBlock from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility +from books.schema.mutations.snapshot import CreateSnapshot from books.schema.mutations.topic import UpdateLastTopic @@ -13,3 +14,4 @@ class BookMutations(object): update_last_topic = UpdateLastTopic.Field() update_chapter_visibility = UpdateChapterVisibility.Field() sync_module_visibility = SyncModuleVisibility.Field() + create_snapshot = CreateSnapshot.Field() diff --git a/server/books/schema/mutations/snapshot.py b/server/books/schema/mutations/snapshot.py new file mode 100644 index 00000000..ba149b1b --- /dev/null +++ b/server/books/schema/mutations/snapshot.py @@ -0,0 +1,27 @@ +import graphene +from graphene import relay + +from api.utils import get_object +from books.models import Module +from books.models.snapshot import Snapshot +from books.schema.nodes import SnapshotNode +from users.models import SchoolClass + + +class CreateSnapshot(relay.ClientIDMutation): + class Input: + module = graphene.ID(required=True) + selected_class = graphene.ID(required=True) + + snapshot = graphene.Field(SnapshotNode) + success = graphene.Boolean() + + @classmethod + def mutate_and_get_payload(cls, root, info, **args): + module_id = args.get('module') + module = get_object(Module, module_id) + user = info.context.user + selected_class_id = args.get('selected_class') + selected_class = get_object(SchoolClass, selected_class_id) + snapshot = Snapshot.objects.create_snapshot(module, selected_class, user) + return cls(snapshot=snapshot, success=True) diff --git a/server/books/schema/nodes.py b/server/books/schema/nodes.py deleted file mode 100644 index 73b8d34a..00000000 --- a/server/books/schema/nodes.py +++ /dev/null @@ -1,260 +0,0 @@ -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/nodes/__init__.py b/server/books/schema/nodes/__init__.py new file mode 100644 index 00000000..0ef86466 --- /dev/null +++ b/server/books/schema/nodes/__init__.py @@ -0,0 +1,6 @@ +from .chapter import * +from .module import * +from .content import * +from .snapshot import * +from .topic import * + diff --git a/server/books/schema/nodes/chapter.py b/server/books/schema/nodes/chapter.py new file mode 100644 index 00000000..248d4146 --- /dev/null +++ b/server/books/schema/nodes/chapter.py @@ -0,0 +1,100 @@ +import graphene +from django.db.models import Q +from graphene import relay +from graphene_django import DjangoObjectType +from graphql_relay import to_global_id + +from books.models import Chapter, ContentBlock +from books.models.snapshot import ChapterSnapshot +from books.schema.interfaces import ChapterInterface +from notes.models import ChapterBookmark +from notes.schema import ChapterBookmarkNode + + +class ChapterNode(DjangoObjectType): + 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, ChapterInterface,) + + 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.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 SnapshotChapterNode(DjangoObjectType): + title_hidden = graphene.Boolean() + description_hidden = graphene.Boolean() + title = graphene.String() + description = graphene.String() + id = graphene.ID(required=True) + + class Meta: + model = ChapterSnapshot + only_fields = '__all__' + filter_fields = [ + 'id', + ] + interfaces = (relay.Node, ChapterInterface,) + + @staticmethod + def resolve_title_hidden(parent, info): + return parent + + @staticmethod + def resolve_title(parent, info): + return parent.chapter.title + + @staticmethod + def resolve_description(parent, info): + return parent.chapter.description + + @staticmethod + def resolve_content_blocks(parent, info, **kwargs): + snapshot = parent.snapshot + + user_created = Q(user_created=True) + hidden_for_snapshot = Q(hidden_for_snapshots=snapshot) + custom_hidden = Q(snapshotcontentblock__hidden=True) + + qs = ContentBlock.get_by_parent(parent.chapter) \ + .exclude(user_created) \ + .exclude(hidden_for_snapshot) \ + .exclude(custom_hidden) + + + # exclude hidden for snapshot + # exclude with owner + # include visible snapshot content blocks + # todo + return qs + + def resolve_id(self, *args): + return to_global_id('SnapshotChapterNode', self.chapter.pk) diff --git a/server/books/schema/nodes/content.py b/server/books/schema/nodes/content.py new file mode 100644 index 00000000..756643b8 --- /dev/null +++ b/server/books/schema/nodes/content.py @@ -0,0 +1,91 @@ +import graphene +from graphene import relay +from graphene_django import DjangoObjectType + +from books.models import ContentBlock +from books.utils import are_solutions_enabled_for +from notes.models import ContentBlockBookmark +from notes.schema import ContentBlockBookmarkNode +from rooms.models import ModuleRoomSlug + + +from core.logger import get_logger + +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 + ) + + +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/nodes/module.py b/server/books/schema/nodes/module.py new file mode 100644 index 00000000..6cf35cdc --- /dev/null +++ b/server/books/schema/nodes/module.py @@ -0,0 +1,93 @@ +import graphene +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 Module, Chapter, ContentBlock, RecentModule +from books.schema.interfaces.module import ModuleInterface +from books.schema.nodes.chapter import ChapterNode +from notes.models import ModuleBookmark, ContentBlockBookmark, ChapterBookmark +from notes.schema import ModuleBookmarkNode, ContentBlockBookmarkNode, ChapterBookmarkNode +from surveys.models import Answer +from surveys.schema import AnswerNode + + +class ModuleNode(DjangoObjectType): + 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, ModuleInterface, ) + + 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,) diff --git a/server/books/schema/nodes/snapshot.py b/server/books/schema/nodes/snapshot.py new file mode 100644 index 00000000..33fda7de --- /dev/null +++ b/server/books/schema/nodes/snapshot.py @@ -0,0 +1,19 @@ +from graphene import relay +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField +from .chapter import SnapshotChapterNode + +from books.models.snapshot import Snapshot + + +class SnapshotNode(DjangoObjectType): + class Meta: + model = Snapshot + interfaces = (relay.Node,) + + # chapters = relay.ConnectionField('books.schema.connections.ChapterSnapshotConnection') + chapters = DjangoFilterConnectionField(SnapshotChapterNode) + + def resolve_chapters(self, info, **kwargs): + # return Chapter.objects.filter(chapter_snapshots__snapshot=self) + return self.chapters.through.objects.all() diff --git a/server/books/schema/nodes/topic.py b/server/books/schema/nodes/topic.py new file mode 100644 index 00000000..067120f2 --- /dev/null +++ b/server/books/schema/nodes/topic.py @@ -0,0 +1,29 @@ +import graphene +from graphene import relay +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField + +from books.models import Topic, Module +from books.schema.nodes import ModuleNode + + +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) diff --git a/server/books/tests/test_create_snapshot.py b/server/books/tests/test_create_snapshot.py index 40f6d1be..a0261d9f 100644 --- a/server/books/tests/test_create_snapshot.py +++ b/server/books/tests/test_create_snapshot.py @@ -1,6 +1,6 @@ from django.test import TestCase, RequestFactory from graphene.test import Client -from graphql_relay import to_global_id +from graphql_relay import to_global_id, from_global_id from api.schema import schema from books.factories import ModuleFactory, ChapterFactory, ContentBlockFactory @@ -44,11 +44,45 @@ query ModulesQuery($slug: String!) { } """ +CREATE_SNAPSHOT_MUTATION = """ +mutation CreateSnapshot($input: CreateSnapshotInput!) { + createSnapshot(input: $input) { + snapshot { + id + chapters { + edges { + node { + id + descriptionHidden + titleHidden + title + description + contentBlocks { + edges { + node { + id + title + } + } + } + } + } + } + } + success + } +} +""" + + +def edges_to_array(entity): + return [edge['node'] for edge in entity.get('edges')] + class CreateSnapshotTestCase(TestCase): def setUp(self): create_users() - skillbox_class = SchoolClass.objects.get(name='skillbox') + self.skillbox_class = SchoolClass.objects.get(name='skillbox') second_class = SchoolClass.objects.get(name='second_class') # teacher will create snapshot self.teacher = User.objects.get(username='teacher') @@ -56,38 +90,68 @@ class CreateSnapshotTestCase(TestCase): # module M has a chapter self.chapter = ChapterFactory(parent=self.module, slug='some-chapter') # chapter has some content blocks a, b, c - self.a = ContentBlockFactory(parent=self.chapter, module=self.module, slug='cb-a') - self.b = ContentBlockFactory(parent=self.chapter, module=self.module, slug='cb-b') + self.title_visible = 'visible' + self.title_hidden = 'hidden' + self.title_custom = 'custom' + self.visible_content_block = ContentBlockFactory(parent=self.chapter, module=self.module, + title=self.title_visible, slug='cb-a') + self.hidden_content_block = ContentBlockFactory(parent=self.chapter, module=self.module, title=self.title_hidden, slug='cb-b') # content block c is user created - self.c = ContentBlockFactory(parent=self.chapter, owner=self.teacher, user_created=True, module=self.module, slug='cb-c') + self.custom_content_block = ContentBlockFactory(parent=self.chapter, owner=self.teacher, user_created=True, + module=self.module, title=self.title_custom, + slug='cb-c') # content block a and c are visible to school class X - self.b.hidden_for.add(skillbox_class) - self.c.visible_for.add(skillbox_class) + self.hidden_content_block.hidden_for.add(self.skillbox_class) + self.custom_content_block.visible_for.add(self.skillbox_class) + + # chapter description is hidden for school class X + self.chapter.title_hidden_for.add(self.skillbox_class) request = RequestFactory().get('/') request.user = self.teacher self.client = Client(schema=schema, context_value=request) - # we make a snapshot S of the module M # snapshot S looks like module M for school class X - def test_visible_and_hidden(self): + def test_setup(self): + # make sure everything is setup correctly result = self.client.execute(MODULE_QUERY, variables={ 'slug': self.module.slug }) self.assertIsNone(result.get('errors')) module = result.get('data').get('module') - chapter = module.get('chapters').get('edges')[0]['node'] + chapter = edges_to_array(module.get('chapters'))[0] self.assertIsNotNone(chapter) - content_blocks = [edge['node'] for edge in chapter.get('contentBlocks').get('edges')] + content_blocks = edges_to_array(chapter.get('contentBlocks')) content_block_ids = [node['id'] for node in content_blocks] - self.assertTrue(to_global_id('ContentBlockNode', self.a.id) in content_block_ids) - self.assertTrue(to_global_id('ContentBlockNode', self.b.id) in content_block_ids) - self.assertTrue(to_global_id('ContentBlockNode', self.c.id) in content_block_ids) - b = [node for node in content_blocks if node['id'] == to_global_id('ContentBlockNode', self.b.id)][0] - c = [node for node in content_blocks if node['id'] == to_global_id('ContentBlockNode', self.c.id)][0] - self.assertTrue('skillbox' in [edge['node']['name'] for edge in b.get('hiddenFor').get('edges')]) - self.assertTrue('skillbox' in [edge['node']['name'] for edge in c.get('visibleFor').get('edges')]) + self.assertTrue(to_global_id('ContentBlockNode', self.visible_content_block.id) in content_block_ids) + self.assertTrue(to_global_id('ContentBlockNode', self.hidden_content_block.id) in content_block_ids) + self.assertTrue(to_global_id('ContentBlockNode', self.custom_content_block.id) in content_block_ids) + b = [node for node in content_blocks if + node['id'] == to_global_id('ContentBlockNode', self.hidden_content_block.id)][0] + c = [node for node in content_blocks if + node['id'] == to_global_id('ContentBlockNode', self.custom_content_block.id)][0] + self.assertTrue('skillbox' in [school_class['name'] for school_class in edges_to_array(b.get('hiddenFor'))]) + self.assertTrue('skillbox' in [school_class['name'] for school_class in edges_to_array(c.get('visibleFor'))]) + + def test_create_snapshot(self): + result = self.client.execute(CREATE_SNAPSHOT_MUTATION, variables={ + 'input': { + 'module': to_global_id('ContentBlockNode', self.module.pk), + 'selectedClass': to_global_id('SchoolClassNode', self.skillbox_class.pk), + } + }) + self.assertIsNone(result.get('errors')) + snapshot = result.get('data').get('createSnapshot').get('snapshot') + chapter = snapshot.get('chapters').get('edges')[0]['node'] + self.assertTrue(chapter['titleHidden']) + self.assertFalse(chapter['descriptionHidden']) + _, chapter_id = from_global_id(chapter['id']) + self.assertEqual(int(chapter_id), self.chapter.id) + content_blocks = [edge['node'] for edge in chapter['contentBlocks']['edges']] + self.assertEqual(len(content_blocks), 2) + self.assertEqual(content_blocks[0]['title'], self.title_visible) + self.assertEqual(content_blocks[1]['title'], self.title_custom)