From 4b93b410a5b04ebb5aed7bc2e28ea373d8a8b3d3 Mon Sep 17 00:00:00 2001 From: Ramon Wenger Date: Tue, 5 Feb 2019 18:45:03 +0100 Subject: [PATCH] Add mutation to enable solutions by module --- server/books/factories.py | 22 +++- .../migrations/0007_auto_20190205_1529.py | 30 +++++ server/books/models/contentblock.py | 4 +- server/books/models/module.py | 4 + server/books/schema/mutations/main.py | 2 + server/books/schema/mutations/module.py | 39 ++++++ server/books/schema/queries.py | 13 ++ server/books/tests/test_module_mutations.py | 2 +- .../tests/test_module_solution_visibility.py | 118 ++++++++++++++++++ 9 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 server/books/migrations/0007_auto_20190205_1529.py create mode 100644 server/books/schema/mutations/module.py create mode 100644 server/books/tests/test_module_solution_visibility.py diff --git a/server/books/factories.py b/server/books/factories.py index 180ef308..00e3951b 100644 --- a/server/books/factories.py +++ b/server/books/factories.py @@ -4,6 +4,7 @@ import factory import wagtail_factories from django.contrib.auth import get_user_model from factory import CREATE_STRATEGY +from wagtail.core.models import Page from wagtail.core.rich_text import RichText from assignments.models import Assignment @@ -16,6 +17,24 @@ class BookFactory(BasePageFactory): class Meta: model = Book + @staticmethod + def create_default_structure(): + site = wagtail_factories.SiteFactory.create(is_default_site=True) + Page.objects.get(title='Root').delete() + + book = BookFactory.create(parent=site.root_page, title='A book') + topic = TopicFactory.create(parent=book, order=1, title='A topic') + module = ModuleFactory.create(parent=topic, + title="A module", + meta_title="Modul 1", + teaser="Whatever", + intro="

Hello

") + chapter = ChapterFactory.create(parent=module, title="A chapter") + content_block = ContentBlockFactory.create(parent=chapter, module=module, title="A content block", type="task", + contents=[]) + + return book, topic, module, chapter, content_block + class TopicFactory(BasePageFactory): class Meta: @@ -170,7 +189,8 @@ class ContentBlockFactory(BasePageFactory): 'url')] = 'https://picsum.photos/400/?random={}'.format( ''.join(random.choice('abcdefghiklmn') for _ in range(6))) elif block_type == 'solution': - kwargs['{}__{}__{}__{}'.format(stream_field_name, i, 'solution', 'text')] = RichText(fake_paragraph()) + kwargs['{}__{}__{}__{}'.format(stream_field_name, i, 'solution', 'text')] = RichText( + fake_paragraph()) @classmethod def create(cls, module, **kwargs): diff --git a/server/books/migrations/0007_auto_20190205_1529.py b/server/books/migrations/0007_auto_20190205_1529.py new file mode 100644 index 00000000..fbb30e88 --- /dev/null +++ b/server/books/migrations/0007_auto_20190205_1529.py @@ -0,0 +1,30 @@ +# Generated by Django 2.0.6 on 2019-02-05 15:29 + +import assignments.models +from django.conf import settings +from django.db import migrations, models +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.images.blocks +import wagtail.snippets.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('books', '0006_auto_20181204_1629'), + ] + + operations = [ + migrations.AddField( + model_name='module', + name='solutions_enabled_by', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='contentblock', + name='contents', + field=wagtail.core.fields.StreamField([('text_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock())])), ('basic_knowledge', wagtail.core.blocks.StructBlock([('description', wagtail.core.blocks.RichTextBlock()), ('basic_knowledge', wagtail.core.blocks.PageChooserBlock(required=True, target_model=['basicknowledge.BasicKnowledge']))])), ('assignment', wagtail.core.blocks.StructBlock([('assignment_id', wagtail.snippets.blocks.SnippetChooserBlock(assignments.models.Assignment))])), ('image_block', wagtail.images.blocks.ImageChooserBlock()), ('image_url_block', wagtail.core.blocks.StructBlock([('title', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('link_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('solution', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock())], icon='tick')), ('video_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('document_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())]))], blank=True, null=True), + ), + ] diff --git a/server/books/models/contentblock.py b/server/books/models/contentblock.py index 88c7e96d..8258f5d7 100644 --- a/server/books/models/contentblock.py +++ b/server/books/models/contentblock.py @@ -72,4 +72,6 @@ class ContentBlock(StrictHierarchyPage): parent_page_types = ['books.Chapter'] subpage_types = [] - + @property + def module(self): + return self.get_parent().get_parent().specific diff --git a/server/books/models/module.py b/server/books/models/module.py index 32d7131b..a33d762f 100644 --- a/server/books/models/module.py +++ b/server/books/models/module.py @@ -7,6 +7,7 @@ from wagtail.images.edit_handlers import ImageChooserPanel from books.blocks import DEFAULT_RICH_TEXT_FEATURES from core.wagtail_utils import StrictHierarchyPage +from users.models import User logger = logging.getLogger(__name__) @@ -31,6 +32,8 @@ class Module(StrictHierarchyPage): teaser = models.TextField() intro = RichTextField(features=DEFAULT_RICH_TEXT_FEATURES) + solutions_enabled_by = models.ManyToManyField(User) + content_panels = [ FieldPanel('title', classname="full title"), FieldPanel('meta_title', classname="full title"), @@ -55,3 +58,4 @@ class Module(StrictHierarchyPage): def get_child_ids(self): return self.get_children().values_list('id', flat=True) + diff --git a/server/books/schema/mutations/main.py b/server/books/schema/mutations/main.py index d1e55c0b..db56272e 100644 --- a/server/books/schema/mutations/main.py +++ b/server/books/schema/mutations/main.py @@ -1,7 +1,9 @@ from books.schema.mutations.contentblock import MutateContentBlock, AddContentBlock, DeleteContentBlock +from books.schema.mutations.module import UpdateSolutionVisibility class BookMutations(object): mutate_content_block = MutateContentBlock.Field() add_content_block = AddContentBlock.Field() delete_content_block = DeleteContentBlock.Field() + update_solution_visibility = UpdateSolutionVisibility.Field() diff --git a/server/books/schema/mutations/module.py b/server/books/schema/mutations/module.py new file mode 100644 index 00000000..4d1bc815 --- /dev/null +++ b/server/books/schema/mutations/module.py @@ -0,0 +1,39 @@ +import graphene +from graphene import relay + +from api.utils import get_errors, get_object +from books.models import Module + + +class UpdateSolutionVisibility(relay.ClientIDMutation): + class Input: + id = graphene.ID() + enabled = graphene.Boolean() + + success = graphene.Boolean() + errors = graphene.List(graphene.String) + + @classmethod + def mutate_and_get_payload(cls, root, info, **args): + try: + id = args.get('id') + enabled = args.get('enabled') + user = info.context.user + if 'users.can_manage_school_class_content' not in user.get_role_permissions(): + raise PermissionError() + + module = get_object(Module, id) + if enabled: + module.solutions_enabled_by.add(user) + else: + module.solutions_enabled_by.remove(user) + module.save() + + return cls(success=True) + + except PermissionError: + errors = ["You don't have the permission to do that."] + except Exception as e: + errors = ['Error: {}'.format(e)] + + return cls(success=False, errors=errors) diff --git a/server/books/schema/queries.py b/server/books/schema/queries.py index 20c23934..f77a8787 100644 --- a/server/books/schema/queries.py +++ b/server/books/schema/queries.py @@ -4,9 +4,15 @@ from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField from api.utils import get_object +from users.models import User, Role from ..models import Book, Topic, Module, Chapter, ContentBlock +def are_solutions_enabled_for(user: User, module: Module): + teacher = user.users_in_same_school_class().filter(user_roles__role=Role.objects.get_default_teacher_role()).first() + return teacher is not None and module.solutions_enabled_by.filter(pk=teacher.pk).exists() + + class ContentBlockNode(DjangoObjectType): mine = graphene.Boolean() @@ -23,6 +29,13 @@ class ContentBlockNode(DjangoObjectType): 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): + if 'users.can_manage_school_class_content' not in info.context.user.get_role_permissions() \ + and not are_solutions_enabled_for(info.context.user, self.module): + self.contents.stream_data = [content for content in self.contents.stream_data if + content['type'] != 'solution'] + return self.contents + class ChapterNode(DjangoObjectType): content_blocks = DjangoFilterConnectionField(ContentBlockNode) diff --git a/server/books/tests/test_module_mutations.py b/server/books/tests/test_module_mutations.py index 372f1c18..dfd5b07d 100644 --- a/server/books/tests/test_module_mutations.py +++ b/server/books/tests/test_module_mutations.py @@ -22,7 +22,7 @@ class NewContentBlockMutationTest(TestCase): request.user = user self.client = Client(schema=schema, context_value=request) - self.sibling_id = to_global_id('ContentBlock', content_block.pk) + self.sibling_id = to_global_id('ContentBlockNode', content_block.pk) def test_add_new_content_block(self): self.assertEqual(ContentBlock.objects.count(), 1) diff --git a/server/books/tests/test_module_solution_visibility.py b/server/books/tests/test_module_solution_visibility.py new file mode 100644 index 00000000..e2f0c0b9 --- /dev/null +++ b/server/books/tests/test_module_solution_visibility.py @@ -0,0 +1,118 @@ +from django.test import TestCase, RequestFactory +from graphene.test import Client +from graphql_relay import to_global_id + +from api.schema import schema + +from books.factories import BookFactory, TopicFactory, ModuleFactory, ChapterFactory, ContentBlockFactory +from books.models import ContentBlock +from users.models import User +from users.services import create_users + + +class ModuleSolutionVisibilityTest(TestCase): + def setUp(self): + create_users() + _, _, self.module, chapter, _ = BookFactory.create_default_structure() + content = { + 'type': 'solution', + 'value': { + 'text': '

Das ist eine Lösung.

' + } + } + + content_block = ContentBlockFactory.create( + parent=chapter, + module=self.module, + title="Another content block", + type="task", + contents=[content] + ) + + self.teacher = User.objects.get(username="teacher") + self.student = User.objects.get(username="student1") + student_request = RequestFactory().get('/') + student_request.user = self.student + self.student_client = Client(schema=schema, context_value=student_request) + + teacher_request = RequestFactory().get('/') + teacher_request.user = self.teacher + self.teacher_client = Client(schema=schema, context_value=teacher_request) + + self.content_block_id = to_global_id('ContentBlockNode', content_block.pk) + + self.update_mutation = mutation = """ + mutation UpdateSolutionVisibility($input: UpdateSolutionVisibilityInput!) { + updateSolutionVisibility(input: $input) { + success + } + } + """ + + self.query = """ + query ContentBlockQuery($id: ID!) { + contentBlock(id: $id) { + contents + title + } + } + """ + + def test_hide_solutions_for_students_and_then_show_them(self): + result = self.student_client.execute(self.query, variables={ + 'id': self.content_block_id + }) + + self.assertIsNone(result.get('errors')) + self.assertEqual(len(result.get('data').get('contentBlock').get('contents')), 0) + + result = self.teacher_client.execute(self.query, variables={ + 'id': self.content_block_id + }) + self.assertIsNone(result.get('errors')) + self.assertEqual(len(result.get('data').get('contentBlock').get('contents')), 1) + + module_id = to_global_id('ModuleNode', self.module.pk) + + result = self.teacher_client.execute(self.update_mutation, variables={ + 'input': { + 'id': module_id, + 'enabled': True + } + }) + + self.assertEqual(result.get('data').get('updateSolutionVisibility').get('success'), True) + + self.assertEqual(self.module.solutions_enabled_by.filter(pk=self.teacher.pk).count(), 1) + + result = self.student_client.execute(self.query, variables={ + 'id': self.content_block_id + }) + + self.assertIsNone(result.get('errors')) + self.assertEqual(len(result.get('data').get('contentBlock').get('contents')), 1) + + def test_try_to_show_solutions_as_student_and_fail(self): + result = self.student_client.execute(self.query, variables={ + 'id': self.content_block_id + }) + + self.assertEqual(len(result.get('data').get('contentBlock').get('contents')), 0) + + module_id = to_global_id('ModuleNode', self.module.pk) + + result = self.student_client.execute(self.update_mutation, variables={ + 'input': { + 'id': module_id, + 'enabled': True + } + }) + + self.assertIsNone(result.get('errors')) + self.assertFalse(result.get('data').get('success')) + + result = self.student_client.execute(self.query, variables={ + 'id': self.content_block_id + }) + + self.assertEqual(len(result.get('data').get('contentBlock').get('contents')), 0)