Add module visibility sync mutation

This commit is contained in:
Ramon Wenger 2021-03-05 17:21:53 +01:00
parent 209838dadb
commit 9490ffd443
16 changed files with 262 additions and 159 deletions

View File

@ -0,0 +1,5 @@
describe('Survey', () => {
it('needs to be implemented', () => {
expect(true).to.equal(false);
});
});

View File

@ -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 : '';
}
}
};
</script>

View File

@ -0,0 +1,5 @@
mutation SyncModuleVisibility($input: SyncModuleVisibilityInput!) {
syncModuleVisibility(input: $input) {
success
}
}

View File

@ -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',
},
},
};

View File

@ -1,6 +1,6 @@
<template>
<div class="module-page">
<module-navigation/>
<module-navigation v-if="showNavigation" />
<router-view/>
</div>
</template>
@ -11,6 +11,12 @@
export default {
components: {
ModuleNavigation
},
computed: {
showNavigation() {
return !this.$route.meta.hideNavigation;
}
}
};
</script>

View File

@ -12,16 +12,25 @@
<div class="module-visibility__form module-visibility__section">
Von
<select
:value="selectedClassId"
class="skillbox-input skillbox-dropdown module-visibility__dropdown"
placeholder="Hallo">
@change="select($event.target.value)">
<option
value
selected>-</option>
value=""
selected>-
</option>
<option
:key="schoolClass.id"
:value="schoolClass.id"
v-for="schoolClass in schoolClasses">{{ schoolClass.name }}
</option>
</select>
für INF2019i übernehmen.
für {{ currentClassName }} übernehmen.
</div>
<div class="module-visibility__section">
<a class="button button--primary">Anpassungen übernehmen</a>
<a
class="button button--primary"
@click="sync">Anpassungen übernehmen</a>
</div>
</div>
</template>
@ -29,11 +38,48 @@
<script>
import EyeIcon from '@/components/icons/EyeIcon';
import me from '@/mixins/me';
import SYNC_VISIBILITY_MUTATION from '@/graphql/gql/mutations/syncModuleVisibility.gql';
export default {
mixins: [me],
components: {
EyeIcon
EyeIcon,
},
data() {
return {
selectedClassId: '',
};
},
computed: {
schoolClasses() {
return this.me.schoolClasses.filter(schoolClass => schoolClass.id !== this.me.selectedClass.id);
},
},
methods: {
select(selectedClassId) {
this.selectedClassId = selectedClassId;
},
sync() {
if (this.selectedClassId) {
this.$apollo.mutate({
mutation: SYNC_VISIBILITY_MUTATION,
variables: {
input: {
module: this.$route.params.slug,
templateSchoolClass: this.selectedClassId,
schoolClass: this.me.selectedClass.id,
},
},
});
}
},
},
};
</script>

View File

@ -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
}
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}
}
]
},
...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) => {

View File

@ -0,0 +1,3 @@
export const SUBMISSIONS_PAGE = 'submissions';
export const MODULE_PAGE = 'module';
export const VISIBILITY_PAGE = 'visibility';

View File

@ -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
},
},
],
}
];

View File

@ -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')

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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"
}
}
}
]
}