diff --git a/client/cypress/integration/sync-module-visibility.spec.js b/client/cypress/integration/sync-module-visibility.spec.js new file mode 100644 index 00000000..6f778a03 --- /dev/null +++ b/client/cypress/integration/sync-module-visibility.spec.js @@ -0,0 +1,5 @@ +describe('Survey', () => { + it('needs to be implemented', () => { + expect(true).to.equal(false); + }); +}); diff --git a/client/src/components/school-class/CurrentClass.vue b/client/src/components/school-class/CurrentClass.vue index 3b8109cd..a385418b 100644 --- a/client/src/components/school-class/CurrentClass.vue +++ b/client/src/components/school-class/CurrentClass.vue @@ -9,15 +9,6 @@ export default { mixins: [me], - - computed: { - currentClassName() { - let currentClass = this.me.schoolClasses.find(schoolClass => { - return schoolClass.id === this.me.selectedClass.id; - }); - return currentClass ? currentClass.name : this.me.schoolClasses.length ? this.me.schoolClasses[0].name : ''; - } - } }; diff --git a/client/src/graphql/gql/mutations/syncModuleVisibility.gql b/client/src/graphql/gql/mutations/syncModuleVisibility.gql new file mode 100644 index 00000000..00cd6040 --- /dev/null +++ b/client/src/graphql/gql/mutations/syncModuleVisibility.gql @@ -0,0 +1,5 @@ +mutation SyncModuleVisibility($input: SyncModuleVisibilityInput!) { + syncModuleVisibility(input: $input) { + success + } +} diff --git a/client/src/mixins/me.js b/client/src/mixins/me.js index aaa043d3..e2e592c7 100644 --- a/client/src/mixins/me.js +++ b/client/src/mixins/me.js @@ -5,13 +5,13 @@ export default { return { me: { selectedClass: { - id: '' + id: '', }, permissions: [], schoolClasses: [], - isTeacher: false + isTeacher: false, }, - showPopover: false + showPopover: false, }; }, @@ -21,8 +21,8 @@ export default { return { name: 'topic', params: { - topicSlug: this.me.lastTopic.slug - } + topicSlug: this.me.lastTopic.slug, + }, }; } return '/book/topic/berufliche-grundbildung'; @@ -33,15 +33,21 @@ export default { canManageContent() { return this.me.permissions.includes('users.can_manage_school_class_content'); }, + currentClassName() { + let currentClass = this.me.schoolClasses.find(schoolClass => { + return schoolClass.id === this.me.selectedClass.id; + }); + return currentClass ? currentClass.name : this.me.schoolClasses.length ? this.me.schoolClasses[0].name : ''; + }, }, apollo: { me: { query: ME_QUERY, - update(data) { - return this.$getRidOfEdges(data).me; + update({me}) { + return this.$getRidOfEdges(me); }, - fetchPolicy: 'cache-first' + fetchPolicy: 'cache-first', }, }, }; diff --git a/client/src/pages/module-base.vue b/client/src/pages/module-base.vue index f2b39298..b6c529c6 100644 --- a/client/src/pages/module-base.vue +++ b/client/src/pages/module-base.vue @@ -1,6 +1,6 @@ @@ -11,6 +11,12 @@ export default { components: { ModuleNavigation + }, + + computed: { + showNavigation() { + return !this.$route.meta.hideNavigation; + } } }; diff --git a/client/src/pages/moduleVisibility.vue b/client/src/pages/moduleVisibility.vue index ab61135b..b0474497 100644 --- a/client/src/pages/moduleVisibility.vue +++ b/client/src/pages/moduleVisibility.vue @@ -12,16 +12,25 @@
Von - für INF2019i übernehmen. + für {{ currentClassName }} übernehmen.
- Anpassungen übernehmen + Anpassungen übernehmen
@@ -29,11 +38,48 @@ diff --git a/client/src/router/index.js b/client/src/router/index.js index 70a678c0..57e36b6d 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -1,8 +1,5 @@ import Vue from 'vue'; -// import index from '@/pages/index' import topic from '@/pages/topic'; -import moduleBase from '@/pages/module-base'; -import module from '@/pages/module'; import rooms from '@/pages/rooms'; import room from '@/pages/room'; import newRoom from '@/pages/newRoom'; @@ -10,7 +7,6 @@ import editRoom from '@/pages/editRoom'; import article from '@/pages/article'; import instrument from '@/pages/instrument'; import instrumentOverview from '@/pages/instrumentOverview'; -import submissions from '@/pages/submissions'; import p404 from '@/pages/p404'; import start from '@/pages/start'; import submission from '@/pages/studentSubmission'; @@ -41,10 +37,11 @@ import onboardingStep1 from '@/pages/onboarding/step1'; import onboardingStep2 from '@/pages/onboarding/step2'; import onboardingStep3 from '@/pages/onboarding/step3'; import settingsPage from '@/pages/moduleSettings'; + +import moduleRoutes from './module.routes'; import portfolioRoutes from './portfolio.routes'; import store from '@/store/index'; -import moduleVisibility from '@/pages/moduleVisibility'; const ONBOARDING_STEP_1 = 'onboarding-step-1'; const ONBOARDING_STEP_2 = 'onboarding-step-2'; @@ -54,7 +51,7 @@ const routes = [ { path: '/', name: 'home', - component: start + component: start, }, { path: '/login', @@ -62,8 +59,8 @@ const routes = [ component: login, meta: { layout: 'public', - public: true - } + public: true, + }, }, { path: '/hello', @@ -71,8 +68,8 @@ const routes = [ component: hello, meta: { layout: 'public', - public: true - } + public: true, + }, }, { path: '/beta-login', @@ -80,27 +77,10 @@ const routes = [ component: betaLogin, meta: { layout: 'public', - public: true - } - }, - { - path: '/module/:slug', - component: moduleBase, - children: [ - { - path: '', - name: 'module', - component: module, - meta: {filter: true} - }, - { - path: 'submissions/:id', - name: 'submissions', - component: submissions, - meta: {filter: true} - } - ] + public: true, + }, }, + ...moduleRoutes, {path: '/rooms', name: 'rooms', component: rooms, meta: {filter: true}}, {path: '/new-room/', name: 'new-room', component: newRoom}, {path: '/edit-room/:id', name: 'edit-room', component: editRoom, props: true}, @@ -110,13 +90,13 @@ const routes = [ name: 'moduleRoom', component: moduleRoom, props: true, - meta: {layout: 'fullScreen'} + meta: {layout: 'fullScreen'}, }, {path: '/article/:slug', name: 'article', component: article, meta: {layout: 'simple'}}, { path: '/instruments/', name: 'instrument-overview', - component: instrumentOverview + component: instrumentOverview, }, {path: '/instrument/:slug', name: 'instrument', component: instrument, meta: {layout: 'simple'}}, {path: '/submission/:id', name: 'submission', component: submission, meta: {layout: 'simple'}}, @@ -134,11 +114,11 @@ const routes = [ path: 'old-classes', name: 'old-classes', component: oldClasses, - meta: {isProfile: true} + meta: {isProfile: true}, }, {path: 'create-class', name: 'create-class', component: createClass, meta: {layout: 'simple'}}, {path: 'show-code', name: 'show-code', component: showCode, meta: {layout: 'simple'}}, - ] + ], }, {path: 'join-class', name: 'join-class', component: joinClass, meta: {layout: 'public'}}, { @@ -146,7 +126,7 @@ const routes = [ component: surveyPage, name: 'survey', props: true, - meta: {layout: 'simple'} + meta: {layout: 'simple'}, }, { path: '/register', @@ -155,7 +135,7 @@ const routes = [ meta: { public: true, layout: 'public', - } + }, }, { path: '/check-email', @@ -163,8 +143,8 @@ const routes = [ name: 'checkEmail', meta: { public: true, - layout: 'public' - } + layout: 'public', + }, }, { path: '/verify-email', @@ -172,16 +152,16 @@ const routes = [ name: 'emailVerification', meta: { public: true, - layout: 'public' - } + layout: 'public', + }, }, { path: '/license-activation', component: licenseActivation, name: 'licenseActivation', meta: { - layout: 'public' - } + layout: 'public', + }, }, { path: '/forgot-password', @@ -189,13 +169,13 @@ const routes = [ name: 'forgotPassword', meta: { layout: 'public', - public: true - } + public: true, + }, }, { path: '/news', component: news, - name: 'news' + name: 'news', }, { path: '/onboarding', @@ -207,7 +187,7 @@ const routes = [ name: 'onboarding-start', meta: { layout: 'blank', - next: ONBOARDING_STEP_1 + next: ONBOARDING_STEP_1, }, }, { @@ -217,7 +197,7 @@ const routes = [ meta: { layout: 'blank', next: ONBOARDING_STEP_2, - illustration: 'contents' + illustration: 'contents', }, }, { @@ -227,7 +207,7 @@ const routes = [ meta: { layout: 'blank', next: ONBOARDING_STEP_3, - illustration: 'rooms' + illustration: 'rooms', }, }, { @@ -237,30 +217,23 @@ const routes = [ meta: { layout: 'blank', next: 'home', - illustration: 'portfolio' + illustration: 'portfolio', }, }, - ] + ], }, { path: '/settings', - component: settingsPage - }, - { - path: '/visibility', - component: moduleVisibility, - meta: { - layout: 'simple' - } + component: settingsPage, }, {path: '/styleguide', component: styleGuidePage}, { path: '*', component: p404, meta: { - layout: 'blank' - } - } + layout: 'blank', + }, + }, ]; Vue.use(Router); @@ -273,7 +246,7 @@ const router = new Router({ return savedPosition; } return {x: 0, y: 0}; - } + }, }); router.afterEach((to, from) => { diff --git a/client/src/router/module.names.js b/client/src/router/module.names.js new file mode 100644 index 00000000..ec35f516 --- /dev/null +++ b/client/src/router/module.names.js @@ -0,0 +1,3 @@ +export const SUBMISSIONS_PAGE = 'submissions'; +export const MODULE_PAGE = 'module'; +export const VISIBILITY_PAGE = 'visibility'; diff --git a/client/src/router/module.routes.js b/client/src/router/module.routes.js new file mode 100644 index 00000000..4eef3e0b --- /dev/null +++ b/client/src/router/module.routes.js @@ -0,0 +1,35 @@ +import moduleBase from '@/pages/module-base'; +import module from '@/pages/module'; +import submissions from '@/pages/submissions'; +import moduleVisibility from '@/pages/moduleVisibility'; +import {MODULE_PAGE, SUBMISSIONS_PAGE, VISIBILITY_PAGE} from '@/router/module.names'; + +export default [ + { + path: '/module/:slug', + component: moduleBase, + children: [ + { + path: '', + name: MODULE_PAGE, + component: module, + meta: {filter: true}, + }, + { + path: 'submissions/:id', + name: SUBMISSIONS_PAGE, + component: submissions, + meta: {filter: true}, + }, + { + path: 'visibility', + name: VISIBILITY_PAGE, + component: moduleVisibility, + meta: { + layout: 'simple', + hideNavigation: true + }, + }, + ], +} +]; diff --git a/server/api/schema.py b/server/api/schema.py index f5e87a87..7421800f 100644 --- a/server/api/schema.py +++ b/server/api/schema.py @@ -24,7 +24,6 @@ from rooms.mutations import RoomMutations from rooms.schema import RoomsQuery, ModuleRoomsQuery from users.schema import AllUsersQuery, UsersQuery from users.mutations import ProfileMutations -from registration.mutations_public import RegistrationMutations class CustomQuery(UsersQuery, AllUsersQuery, ModuleRoomsQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, @@ -36,7 +35,7 @@ class CustomQuery(UsersQuery, AllUsersQuery, ModuleRoomsQuery, RoomsQuery, Objec class CustomMutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, CoreMutations, PortfolioMutations, - ProfileMutations, SurveyMutations, NoteMutations, RegistrationMutations, SpellCheckMutations, + ProfileMutations, SurveyMutations, NoteMutations, SpellCheckMutations, CouponMutations, graphene.ObjectType): if settings.DEBUG: debug = graphene.Field(DjangoDebug, name='_debug') diff --git a/server/books/models/module.py b/server/books/models/module.py index 2785d3a1..093a082b 100644 --- a/server/books/models/module.py +++ b/server/books/models/module.py @@ -58,7 +58,7 @@ class Module(StrictHierarchyPage): def get_child_ids(self): return self.get_children().values_list('id', flat=True) - def sync_from_school_class(self, school_class_pattern, school_class_to_sync): + def sync_from_school_class(self, school_class_template, school_class_to_sync): # import here so we don't get a circular import error from books.models import Chapter, ContentBlock @@ -77,12 +77,12 @@ class Module(StrictHierarchyPage): content_block.visible_for.remove(school_class_to_sync) # get all content blocks with `hidden for` for class `school_class_pattern` - for content_block in school_class_pattern.hidden_content_blocks.intersection(content_block_query): + for content_block in school_class_template.hidden_content_blocks.intersection(content_block_query): # add `school_class_to_sync` to these blocks' `hidden for` content_block.hidden_for.add(school_class_to_sync) # get all content blocks with `visible for` for class `school_class_pattern` - for content_block in school_class_pattern.visible_content_blocks.intersection(content_block_query): + for content_block in school_class_template.visible_content_blocks.intersection(content_block_query): # add `school_class_to_sync` to these blocks' `visible for` content_block.visible_for.add(school_class_to_sync) diff --git a/server/books/schema/mutations/__init__.py b/server/books/schema/mutations/__init__.py index fc85ed51..999faf9b 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, UpdateLastTopic +from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility +from books.schema.mutations.topic import UpdateLastTopic class BookMutations(object): @@ -11,3 +12,4 @@ class BookMutations(object): update_last_module = UpdateLastModule.Field() update_last_topic = UpdateLastTopic.Field() update_chapter_visibility = UpdateChapterVisibility.Field() + sync_module_visibility = SyncModuleVisibility.Field() diff --git a/server/books/schema/mutations/module.py b/server/books/schema/mutations/module.py index 77b00244..d50e8b20 100644 --- a/server/books/schema/mutations/module.py +++ b/server/books/schema/mutations/module.py @@ -3,9 +3,10 @@ from datetime import datetime import graphene from graphene import relay -from api.utils import get_errors, get_object -from books.models import Module, Topic, RecentModule -from books.schema.queries import ModuleNode, TopicNode +from api.utils import get_object +from books.models import Module, RecentModule +from books.schema.queries import ModuleNode +from users.models import SchoolClass class UpdateSolutionVisibility(relay.ClientIDMutation): @@ -75,23 +76,30 @@ class UpdateLastModule(relay.ClientIDMutation): return cls(last_module=last_module.module) -class UpdateLastTopic(relay.ClientIDMutation): +class SyncModuleVisibility(relay.ClientIDMutation): class Input: - # todo: use slug here too - id = graphene.ID() + module = graphene.String(required=True) + template_school_class = graphene.ID(required=True) + school_class = graphene.ID(required=True) - topic = graphene.Field(TopicNode) + success = graphene.Boolean() @classmethod def mutate_and_get_payload(cls, root, info, **args): user = info.context.user - id = args.get('id') + if not user.is_teacher(): + raise Exception('Permission denied') - topic = get_object(Topic, id) - if not topic: - raise Topic.DoesNotExist + module_slug = args.get('module') + template_id = args.get('template_school_class') + school_class_id = args.get('school_class') - user.last_topic = topic - user.save() + module = Module.objects.get(slug=module_slug) + template = get_object(SchoolClass, template_id) + school_class = get_object(SchoolClass, school_class_id) + if not template.is_user_in_schoolclass(user) or not school_class.is_user_in_schoolclass(user): + raise Exception('Permission denied') - return cls(topic=topic) + module.sync_from_school_class(template, school_class) + + return cls(success=True) diff --git a/server/books/schema/mutations/topic.py b/server/books/schema/mutations/topic.py new file mode 100644 index 00000000..fafadae1 --- /dev/null +++ b/server/books/schema/mutations/topic.py @@ -0,0 +1,28 @@ +import graphene +from graphene import relay + +from api.utils import get_object +from books.models import Topic +from books.schema.queries import TopicNode + + +class UpdateLastTopic(relay.ClientIDMutation): + class Input: + # todo: use slug here too + id = graphene.ID() + + topic = graphene.Field(TopicNode) + + @classmethod + def mutate_and_get_payload(cls, root, info, **args): + user = info.context.user + id = args.get('id') + + topic = get_object(Topic, id) + if not topic: + raise Topic.DoesNotExist + + user.last_topic = topic + user.save() + + return cls(topic=topic) diff --git a/server/books/tests/test_copy_visibility_for_other_class.py b/server/books/tests/test_copy_visibility_for_other_class.py index 60cff107..6fd27a22 100644 --- a/server/books/tests/test_copy_visibility_for_other_class.py +++ b/server/books/tests/test_copy_visibility_for_other_class.py @@ -34,6 +34,14 @@ CONTENT_BLOCK_QUERY = """ } """ +SYNC_MUTATION = """ +mutation SyncMutationVisibility($input: SyncModuleVisibilityInput!) { + syncModuleVisibility(input: $input) { + success + } +} +""" + class CopyVisibilityForClassesTestCase(TestCase): """ @@ -52,7 +60,7 @@ class CopyVisibilityForClassesTestCase(TestCase): """ def setUp(self): - module = ModuleFactory() + module = ModuleFactory(slug='some-module') chapter = Chapter(title='Some Chapter') module.add_child(instance=chapter) create_users() @@ -110,6 +118,36 @@ class CopyVisibilityForClassesTestCase(TestCase): }) return result + def _test_in_sync(self): + # the hidden block is hidden for both now + hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.hidden_content_block) + hidden_for = hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') + self.assertTrue('template-class' in map(lambda x: x['node']['name'], hidden_for)) + self.assertTrue('class-to-be-synced' in map(lambda x: x['node']['name'], hidden_for)) + + # the other hidden block is hidden for no one now + other_hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, + self.other_hidden_content_block) + hidden_for = other_hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') + self.assertEqual(len(hidden_for), 0) + + # the default block is still hidden for no one + default_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.default_content_block) + hidden_for = default_result.get('data').get('contentBlock').get('hiddenFor').get('edges') + self.assertEqual(len(hidden_for), 0) + + # the custom block is visible for both + custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.custom_content_block) + visible_for = custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') + self.assertTrue('template-class' in map(lambda x: x['node']['name'], visible_for)) + self.assertTrue('class-to-be-synced' in map(lambda x: x['node']['name'], visible_for)) + + # the other custom block is visible for no one + other_custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, + self.other_custom_content_block) + visible_for = other_custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') + self.assertEqual(len(visible_for), 0) + def test_hidden_for_and_visible_for_set_correctly(self): self.assertEqual(ContentBlock.objects.count(), 5) @@ -142,31 +180,14 @@ class CopyVisibilityForClassesTestCase(TestCase): def test_syncs_correctly(self): self.module.sync_from_school_class(self.template_school_class, self.school_class_to_be_synced) - # the hidden block is hidden for both now - hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.hidden_content_block) - hidden_for = hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') - self.assertTrue('template-class' in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue('class-to-be-synced' in map(lambda x: x['node']['name'], hidden_for)) + self._test_in_sync() - # the other hidden block is hidden for no one now - other_hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, - self.other_hidden_content_block) - hidden_for = other_hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') - self.assertEqual(len(hidden_for), 0) - - # the default block is still hidden for no one - default_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.default_content_block) - hidden_for = default_result.get('data').get('contentBlock').get('hiddenFor').get('edges') - self.assertEqual(len(hidden_for), 0) - - # the custom block is visible for both - custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.custom_content_block) - visible_for = custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') - self.assertTrue('template-class' in map(lambda x: x['node']['name'], visible_for)) - self.assertTrue('class-to-be-synced' in map(lambda x: x['node']['name'], visible_for)) - - # the other custom block is visible for no one - other_custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, - self.other_custom_content_block) - visible_for = other_custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') - self.assertEqual(len(visible_for), 0) + def test_mutation(self): + self.teacher_client.execute(SYNC_MUTATION, variables={ + 'input': { + 'module': self.module.slug, + 'templateSchoolClass': to_global_id('SchoolClassNode', self.template_school_class.pk), + 'schoolClass': to_global_id('SchoolClassNode', self.school_class_to_be_synced.pk) + } + }) + self._test_in_sync() diff --git a/server/graphql.config.json b/server/graphql.config.json deleted file mode 100644 index 8208188c..00000000 --- a/server/graphql.config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schema": { - "request": { - "url": "http://localhost:8000/graphql", - "method": "POST", - "postIntrospectionQuery": true, - "options": { - "headers": { - "user-agent": "JS GraphQL" - } - } - } - }, - "endpoints": [ - { - "name": "Default (http://localhost:8000/graphql", - "url": "http://localhost:8000/graphql", - "options": { - "headers": { - "user-agent": "JS GraphQL" - } - } - } - ] -} \ No newline at end of file