Merged in feature/teams (pull request #81)

Feature/teams

Approved-by: Christian Cueni
This commit is contained in:
Ramon Wenger 2021-04-12 14:53:13 +00:00
commit da2253a73d
47 changed files with 1507 additions and 556 deletions

View File

@ -15,6 +15,7 @@
}, },
"onboardingVisited": false, "onboardingVisited": false,
"__typename": "UserNode", "__typename": "UserNode",
"permissions": [] "permissions": [],
"team": null
} }
} }

View File

@ -4,13 +4,16 @@ const schema = require('../../fixtures/schema.json');
const me = require('../../fixtures/me.new-student.json'); const me = require('../../fixtures/me.new-student.json');
describe('New student', () => { describe('New student', () => {
it('shows "Enter Code" page and adds the user to a class', () => { before(() => {
cy.server(); cy.server();
cy.task('getSchema').then(schema => {
cy.mockGraphql({ cy.mockGraphql({
schema: schema, schema,
});
}); });
});
it('shows "Enter Code" page and adds the user to a class', () => {
cy.apolloLogin('hansli', 'test'); cy.apolloLogin('hansli', 'test');
const __typename = 'SchoolClassNode'; const __typename = 'SchoolClassNode';
@ -26,9 +29,9 @@ describe('New student', () => {
schoolClass: { schoolClass: {
id, id,
name, name,
__typename __typename,
} },
} },
}, },
MySchoolClassQuery: { MySchoolClassQuery: {
me: { me: {
@ -37,21 +40,21 @@ describe('New student', () => {
__typename, __typename,
name, name,
id, id,
members: [] members: [],
} },
} },
}, },
...mockUpdateOnboardingProgress() ...mockUpdateOnboardingProgress(),
} },
}); });
cy.visit('/'); cy.visit('/');
cy.get('[data-cy=join-class-title]').should('contain', 'Einer Klasse beitreten'); cy.get('[data-cy=join-form-title]').should('contain', 'Einer Klasse beitreten');
cy.get('[data-cy=input-class-code]').type('XXXX'); cy.get('[data-cy=input-form-code]').type('XXXX');
cy.get('[data-cy=join-class]').click(); cy.get('[data-cy=join-form-confirm]').click();
cy.skipOnboarding(); cy.skipOnboarding();
cy.get('[data-cy=user-widget-avatar]').click(); cy.get('[data-cy=user-widget-avatar]').click();
cy.get('[data-cy=class-list-link]').click(); cy.get('[data-cy=class-list-link]').click();
cy.get('[data-cy=class-list-title]').should('contain', 'Klassenliste'); cy.get('[data-cy=group-list-title]').should('contain', 'Klassenliste');
}); });
}); });

View File

@ -1,14 +1,15 @@
import {mockUpdateOnboardingProgress} from '../../support/helpers'; import {mockUpdateOnboardingProgress} from '../../support/helpers';
const schema = require('../../fixtures/schema.json');
const me = require('../../fixtures/me.join-class.json'); const me = require('../../fixtures/me.join-class.json');
describe('Onboarding', () => { describe('Onboarding', () => {
beforeEach(() => { beforeEach(() => {
cy.server(); cy.server();
cy.mockGraphql({ cy.task('getSchema').then(schema => {
schema: schema, cy.mockGraphql({
schema,
});
}); });
}); });

View File

@ -1,5 +1,96 @@
const schema = require('../../fixtures/schema.json');
const me = require('../../fixtures/me.new-student.json'); const operations = {
MeQuery: {
me: {
id: 'VXNlck5vZGU6NQ==',
onboardingVisited: true,
permissions: [],
},
},
ProjectsQuery: {
projects: {
edges: [{
node: {
id: 'UHJvamVjdE5vZGU6MzM=',
title: 'Groot',
appearance: 'red',
'description': 'I am Groot',
'slug': 'groot',
'objectives': 'Be Groot\nBe awesome',
'final': false,
'student': {
'firstName': 'Rahel',
'lastName': 'Cueni',
'id': 'VXNlck5vZGU6NQ==',
'avatarUrl': '',
'__typename': 'UserNode',
},
'entriesCount': 2,
'__typename': 'ProjectNode',
},
'__typename': 'ProjectNodeEdge',
}],
'__typename': 'ProjectNodeConnection',
},
},
ProjectQuery: {
'project': {
'id': 'UHJvamVjdE5vZGU6MzY=',
'title': 'Groot',
'appearance': 'yellow',
'description': 'I am Groot',
'slug': 'groot',
'objectives': 'Be Groot\nBe awesome',
'final': false,
'student': {
'firstName': 'Rahel',
'lastName': 'Cueni',
'id': 'VXNlck5vZGU6NQ==',
'avatarUrl': '',
'__typename': 'UserNode',
},
'entriesCount': 1,
'__typename': 'ProjectNode',
'entries': {
'edges': [{
'node': {
'id': 'UHJvamVjdEVudHJ5Tm9kZTo2NQ==',
'activity': 'Kill Thanos',
'reflection': 'He sucks',
'nextSteps': 'Go for the head',
'documentUrl': '',
'__typename': 'ProjectEntryNode',
'created': '2020-01-20T15:20:31.262510+00:00',
},
'__typename': 'ProjectEntryNodeEdge',
}],
'__typename': 'ProjectEntryNodeConnection',
},
},
},
AddProjectEntry: variables => ({
addProjectEntry: {
projectEntry: Object.assign({}, variables.input.projectEntry, {
created: '2020-01-20T15:26:58.722773+00:00',
}),
errors: null,
__typename: 'AddProjectEntryPayload',
},
}),
UpdateProjectEntry: variables => ({
updateProjectEntry: {
projectEntry: variables.input.projectEntry,
errors: null,
__typename: 'UpdateProjectEntryPayload',
},
}),
DeleteProjectEntry: {
deleteProjectEntry: {
success: true,
__typename: 'DeleteProjectEntryPayload',
},
},
};
describe('Project Entry', () => { describe('Project Entry', () => {
beforeEach(() => { beforeEach(() => {
@ -7,100 +98,11 @@ describe('Project Entry', () => {
cy.fakeLogin('rahel.cueni', 'test'); cy.fakeLogin('rahel.cueni', 'test');
cy.server(); cy.server();
cy.mockGraphql({ cy.task('getSchema').then(schema => {
schema: schema, cy.mockGraphql({
operations: { schema,
MeQuery: { operations,
me: { });
id: 'VXNlck5vZGU6NQ==',
onboardingVisited: true,
permissions: []
}
},
ProjectsQuery: {
projects: {
edges: [{
node: {
id: 'UHJvamVjdE5vZGU6MzM=',
title: 'Groot',
appearance: 'red',
'description': 'I am Groot',
'slug': 'groot',
'objectives': 'Be Groot\nBe awesome',
'final': false,
'student': {
'firstName': 'Rahel',
'lastName': 'Cueni',
'id': 'VXNlck5vZGU6NQ==',
'avatarUrl': '',
'__typename': 'UserNode'
},
'entriesCount': 2,
'__typename': 'ProjectNode'
},
'__typename': 'ProjectNodeEdge'
}],
'__typename': 'ProjectNodeConnection'
}
},
ProjectQuery: {
'project': {
'id': 'UHJvamVjdE5vZGU6MzY=',
'title': 'Groot',
'appearance': 'yellow',
'description': 'I am Groot',
'slug': 'groot',
'objectives': 'Be Groot\nBe awesome',
'final': false,
'student': {
'firstName': 'Rahel',
'lastName': 'Cueni',
'id': 'VXNlck5vZGU6NQ==',
'avatarUrl': '',
'__typename': 'UserNode'
},
'entriesCount': 1,
'__typename': 'ProjectNode',
'entries': {
'edges': [{
'node': {
'id': 'UHJvamVjdEVudHJ5Tm9kZTo2NQ==',
'activity': 'Kill Thanos',
'reflection': 'He sucks',
'nextSteps': 'Go for the head',
'documentUrl': '',
'__typename': 'ProjectEntryNode',
'created': '2020-01-20T15:20:31.262510+00:00'
},
'__typename': 'ProjectEntryNodeEdge'
}],
'__typename': 'ProjectEntryNodeConnection'
}
}
},
AddProjectEntry: variables => ({
addProjectEntry: {
projectEntry: Object.assign({}, variables.input.projectEntry, {
created: '2020-01-20T15:26:58.722773+00:00'
}),
errors: null,
__typename: 'AddProjectEntryPayload'
}
}),
UpdateProjectEntry: variables => ({
updateProjectEntry: {
projectEntry: variables.input.projectEntry,
errors: null,
__typename: 'UpdateProjectEntryPayload'
}
}),
DeleteProjectEntry: {
deleteProjectEntry: {
success: true,
__typename: 'DeleteProjectEntryPayload'
}
}
}
}); });
}); });

View File

@ -1,4 +1,3 @@
const schema = require('../../fixtures/schema.json');
const me = require('../../fixtures/me.join-class.json'); const me = require('../../fixtures/me.join-class.json');
const selectedClass = require('../../fixtures/selected-school-class.json'); const selectedClass = require('../../fixtures/selected-school-class.json');
@ -6,8 +5,10 @@ describe('Class Management', () => {
beforeEach(() => { beforeEach(() => {
cy.server(); cy.server();
cy.mockGraphql({ cy.task('getSchema').then(schema => {
schema: schema, cy.mockGraphql({
schema,
});
}); });
cy.viewport('macbook-15'); cy.viewport('macbook-15');
@ -104,7 +105,7 @@ describe('Class Management', () => {
}); });
cy.visit('/me/my-class'); cy.visit('/me/my-class');
cy.get('[data-cy=school-class-member]').should('have.length', 2); cy.get('[data-cy=group-list-member]').should('have.length', 2);
cy.get('[data-cy=remove-from-class]').should('have.length', 0); cy.get('[data-cy=remove-from-class]').should('have.length', 0);
cy.get('[data-cy=add-to-class]').should('have.length', 0); cy.get('[data-cy=add-to-class]').should('have.length', 0);
}); });
@ -187,8 +188,10 @@ describe('Teacher Class Management', () => {
beforeEach(() => { beforeEach(() => {
cy.server(); cy.server();
cy.mockGraphql({ cy.task('getSchema').then(schema => {
schema: schema, cy.mockGraphql({
schema,
});
}); });
cy.viewport('macbook-15'); cy.viewport('macbook-15');
@ -229,10 +232,10 @@ describe('Teacher Class Management', () => {
cy.visit('/me/my-class'); cy.visit('/me/my-class');
cy.get('[data-cy=edit-class-name-link]').click(); cy.get('[data-cy=edit-group-name-link]').click();
cy.get('[data-cy=edit-class-name-input] input').type('{selectall}{backspace}').type(className); cy.get('[data-cy=edit-name-input] input').type('{selectall}{backspace}').type(className);
cy.get('[data-cy=modal-save-button]').click(); cy.get('[data-cy=modal-save-button]').click();
cy.get('[data-cy=school-class-name]').should('contain', className); cy.get('[data-cy=group-list-name]').should('contain', className);
}); });
// // fixme: cache misbehaves with mequery, but only for test // // fixme: cache misbehaves with mequery, but only for test

View File

@ -1,33 +1,37 @@
import {mockUpdateLastModule} from '../../support/helpers'; import {mockUpdateLastModule} from '../../support/helpers';
const schema = require('../../fixtures/schema.json');
const assignments = require('../../fixtures/assignments.json'); const assignments = require('../../fixtures/assignments.json');
const module = require('../../fixtures/module.json'); const module = require('../../fixtures/module.json');
const spellCheck = require('../../fixtures/spell-check.json'); const spellCheck = require('../../fixtures/spell-check.json');
const operations = {
MeQuery: {
me: {
permissions: [],
onboardingVisited: true,
},
},
AssignmentsQuery: {
assignments,
},
ModulesQuery: {
module,
},
SpellCheck: {
spellCheck,
},
...mockUpdateLastModule(),
};
describe('Spellcheck', () => { describe('Spellcheck', () => {
before(() => { before(() => {
cy.server(); cy.server();
cy.mockGraphql({
schema: schema, cy.task('getSchema').then(schema => {
operations: { cy.mockGraphql({
MeQuery: { schema,
me: { operations,
permissions: [], });
onboardingVisited: true
}
},
AssignmentsQuery: {
assignments
},
ModulesQuery: {
module
},
SpellCheck: {
spellCheck
},
...mockUpdateLastModule()
}
}); });
}); });

View File

@ -1,12 +1,13 @@
const schema = require('../../fixtures/schema.json');
const module = require('../../fixtures/module.json'); const module = require('../../fixtures/module.json');
describe('Survey', () => { describe('Survey', () => {
beforeEach(() => { beforeEach(() => {
cy.server(); cy.server();
cy.mockGraphql({ cy.task('getSchema').then(schema => {
schema: schema, cy.mockGraphql({
schema,
});
}); });
cy.viewport('macbook-15'); cy.viewport('macbook-15');

View File

@ -11,7 +11,6 @@
:is="showModal" :is="showModal"
v-if="showModal"/> v-if="showModal"/>
<component :is="layout"/> <component :is="layout"/>
</div> </div>
</template> </template>
@ -32,6 +31,7 @@
import NewNoteWizard from '@/components/notes/NewNoteWizard'; import NewNoteWizard from '@/components/notes/NewNoteWizard';
import EditNoteWizard from '@/components/notes/EditNoteWizard'; import EditNoteWizard from '@/components/notes/EditNoteWizard';
import EditClassNameWizard from '@/components/school-class/EditClassNameWizard'; import EditClassNameWizard from '@/components/school-class/EditClassNameWizard';
import EditTeamNameWizard from '@/components/profile/EditTeamNameWizard';
import FullscreenImage from '@/components/FullscreenImage'; import FullscreenImage from '@/components/FullscreenImage';
import FullscreenInfographic from '@/components/FullscreenInfographic'; import FullscreenInfographic from '@/components/FullscreenInfographic';
import FullscreenVideo from '@/components/FullscreenVideo'; import FullscreenVideo from '@/components/FullscreenVideo';
@ -60,6 +60,7 @@
NewNoteWizard, NewNoteWizard,
EditNoteWizard, EditNoteWizard,
EditClassNameWizard, EditClassNameWizard,
EditTeamNameWizard,
FullscreenImage, FullscreenImage,
FullscreenInfographic, FullscreenInfographic,
FullscreenVideo, FullscreenVideo,

View File

@ -1,115 +0,0 @@
<template>
<div class="school-class">
<h2 class="school-class__heading"><span
class="school-class__name"
data-cy="school-class-name">{{ name }}</span>
<edit-class-name
v-if="teacher"
@edit="editClassName"/>
</h2>
<div class="school-class__members school-class-members">
<ul
class="school-class-members__list simple-list simple-list--active"
data-cy="active-class-members-list">
<li
:key="member.id"
class="simple-list__item member-item"
data-cy="school-class-member"
v-for="member in activeMembers">
<span class="member-item__name">{{ fullName(member) }}</span>
<span class="member-item__role">{{ role(member) }}</span>
<!-- <a-->
<!-- class="member-item__action simple-list__action"-->
<!-- data-cy="remove-from-class"-->
<!-- v-if="teacher"-->
<!-- @click="$emit('remove', member)">Deaktivieren</a>-->
</li>
</ul>
<!-- <template v-if="inactiveMembers.length">-->
<!-- <h3 class="school-class__inactive-heading">Deaktivierte Personen</h3>-->
<!-- <ul data-cy="inactive-class-members-list" class="simple-list simple-list&#45;&#45;inactive">-->
<!-- <li-->
<!-- class="simple-list__item member-item"-->
<!-- data-cy="school-class-member"-->
<!-- v-for="member in inactiveMembers"-->
<!-- :key="member.id">-->
<!-- <span class="member-item__name">{{fullName(member)}}</span>-->
<!-- <span class="member-item__role">{{role(member)}}</span>-->
<!-- <a-->
<!-- class="member-item__action simple-list__action"-->
<!-- data-cy="add-to-class"-->
<!-- v-if="teacher"-->
<!-- @click="$emit('add', member)">Aktivieren</a>-->
<!-- </li>-->
<!-- </ul>-->
<!-- </template>-->
</div>
</div>
</template>
<script>
import EditClassName from '@/components/school-class/EditClassName';
export default {
props: ['members', 'name', 'teacher', 'id'],
components: {
EditClassName
},
computed: {
activeMembers() {
return this.members.filter(member => member.active);
},
inactiveMembers() {
return this.members.filter(member => !member.active);
}
},
methods: {
fullName(member) {
return `${member.firstName} ${member.lastName}`;
},
role({isTeacher}) {
return isTeacher ? 'Lehrperson' : 'Schüler';
},
editClassName() {
this.$store.dispatch('editClassName');
}
},
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.school-class {
&__inactive-heading {
@include heading-4;
margin-bottom: $small-spacing;
}
&__name {
@include heading-2;
}
}
.member-item {
&__name {
font-family: $sans-serif-font-family;
font-weight: $font-weight-bold;
flex: 2 1 auto;
}
&__role {
flex: 0 1 110px;
text-align: right;
}
&__action {
flex: 0 1 110px;
padding-left: $large-spacing;
}
}
</style>

View File

@ -1,9 +1,9 @@
<template> <template>
<a <a
class="edit-class-name" class="edit-group-name"
data-cy="edit-class-name-link" data-cy="edit-group-name-link"
@click="$emit('edit')"> @click="$emit('edit')">
<pen-icon class="edit-class-name__icon"/> <pen-icon class="edit-group-name__icon"/>
</a> </a>
</template> </template>
@ -18,9 +18,9 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_variables.scss"; @import "~styles/_variables.scss";
.edit-class-name { .edit-group-name {
&__icon { &__icon {
width: 20px; width: 20px;
height: 20px; height: 20px;

View File

@ -0,0 +1,46 @@
<template>
<modal
:hide-header="false"
:small="true">
<h4 slot="header">{{ type }} bearbeiten</h4>
<modal-input
:value="name"
placeholder="Klassenname"
data-cy="edit-name-input"
@input="$emit('input', $event)"
/>
<div slot="footer">
<a
class="button button--primary"
data-cy="modal-save-button"
@click="$emit('save')">Speichern</a>
<a
class="button"
@click="$emit('cancel')">Abbrechen</a>
</div>
</modal>
</template>
<script>
import Modal from '@/components/Modal';
import ModalInput from '@/components/ModalInput';
export default {
props: {
name: {
type: String,
default: ''
},
type: {
type: String,
default: ''
}
},
components: {
Modal,
ModalInput
},
};
</script>

View File

@ -0,0 +1,69 @@
<template>
<edit-name-wizard
:name="name"
type="Team"
@input="name = $event"
@cancel="hideModal"
@save="save"
/>
</template>
<script>
import me from '@/mixins/me';
import EditNameWizard from '@/components/profile/EditNameWizard';
import UPDATE_TEAM_MUTATION from '@/graphql/gql/mutations/updateTeam.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
export default {
mixins: [me],
components: {
EditNameWizard,
},
data() {
return {
name: ''
};
},
computed: {
team() {
return this.me.team;
}
},
mounted() {
this.name = this.team ? this.team.name : '';
},
methods: {
save() {
this.$apollo.mutate({
mutation: UPDATE_TEAM_MUTATION,
variables: {
input: {
name: this.name,
id: this.team.id
}
},
update(store, {data: {updateTeam: {team: {name}}}}) {
const query = ME_QUERY;
const data = store.readQuery({query});
data.me.team.name = name;
store.writeQuery({query, data});
}
});
this.hideModal();
},
hideModal() {
this.$store.dispatch('hideModal');
}
},
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
</style>

View File

@ -0,0 +1,170 @@
<template>
<div class="group-list">
<h1
class="group-list__header"
data-cy="group-list-title">{{ title }}</h1>
<router-link
:to="showCodeRoute"
class="group-list__code-link button button--primary"
v-if="showCode">Zugangscode anzeigen
</router-link>
<div class="group-list__content">
<h2 class="group-list__heading"><span
class="group-list__name"
data-cy="group-list-name">{{ name }}</span>
<edit-group-name
v-if="canEdit"
@edit="$emit('edit')"/>
</h2>
<div class="group-list__members group-list-members">
<ul
class="group-list-members__list simple-list simple-list--active"
data-cy="active-class-members-list">
<li
:key="member.id"
class="simple-list__item member-item"
data-cy="group-list-member"
v-for="member in activeMembers">
<span class="member-item__name">{{ fullName(member) }}</span>
<span class="member-item__role">{{ role(member) }}</span>
<!-- <a-->
<!-- class="member-item__action simple-list__action"-->
<!-- data-cy="remove-from-class"-->
<!-- v-if="teacher"-->
<!-- @click="$emit('remove', member)">Deaktivieren</a>-->
</li>
</ul>
<!-- <template v-if="inactiveMembers.length">-->
<!-- <h3 class="group-list__inactive-heading">Deaktivierte Personen</h3>-->
<!-- <ul data-cy="inactive-class-members-list" class="simple-list simple-list&#45;&#45;inactive">-->
<!-- <li-->
<!-- class="simple-list__item member-item"-->
<!-- data-cy="group-list-member"-->
<!-- v-for="member in inactiveMembers"-->
<!-- :key="member.id">-->
<!-- <span class="member-item__name">{{fullName(member)}}</span>-->
<!-- <span class="member-item__role">{{role(member)}}</span>-->
<!-- <a-->
<!-- class="member-item__action simple-list__action"-->
<!-- data-cy="add-to-class"-->
<!-- v-if="teacher"-->
<!-- @click="$emit('add', member)">Aktivieren</a>-->
<!-- </li>-->
<!-- </ul>-->
<!-- </template>-->
</div>
</div>
</div>
</template>
<script>
import EditGroupName from '@/components/profile/EditGroupName';
export default {
// props: ['active-members', 'inactive-members', 'name', 'canEdit'],
props: {
title: {
type: String,
default: '',
},
showCode: {
type: Boolean,
default: false,
},
showCodeRoute: {
type: Object,
default: () => ({}),
},
activeMembers: {
type: Array,
default: () => [],
},
inactiveMembers: {
type: Array,
default: () => [],
},
name: {
type: String,
default: '',
},
canEdit: {
type: Boolean,
default: false,
},
},
components: {
EditGroupName,
},
methods: {
fullName(member) {
return `${member.firstName} ${member.lastName}`;
},
role({isTeacher}) {
if (isTeacher === undefined) {
return '';
}
return isTeacher ? 'Lehrperson' : 'Schüler';
},
},
};
</script>
<style scoped lang="scss">
@import "~styles/helpers";
.group-list {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
grid-template-areas: "h b" "c c";
&__header {
grid-area: h;
}
&__code-link {
grid-area: b;
justify-self: end;
align-self: center;
}
&__content {
grid-area: c;
width: 100%;
margin-bottom: $large-spacing;
}
&__inactive-heading {
@include heading-4;
margin-bottom: $small-spacing;
}
&__name {
@include heading-2;
}
}
.member-item {
&__name {
font-family: $sans-serif-font-family;
font-weight: $font-weight-bold;
flex: 2 1 auto;
}
&__role {
flex: 0 1 110px;
text-align: right;
}
&__action {
flex: 0 1 110px;
padding-left: $large-spacing;
}
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<div>
<h1 data-cy="join-form-title">{{ title }}</h1>
<div>
<div class="skillboxform-input">
<label
for="join-code"
class="skillboxform-input__label">{{ labelText }}</label>
<input
:class="{'skillboxform-input__input--error': error}"
:value="value"
class="skillbox-input skillboxform-input__input"
data-cy="input-form-code"
id="join-code"
@input="$emit('input', $event)">
<small
class="skillboxform-input__error"
v-if="error"
>{{ error }}
</small>
</div>
<div>
<a
class="button button--primary button--big"
data-cy="join-form-confirm"
@click="$emit('confirm', value)">{{ okText }}</a>
<button
class="button button--big"
data-cy="join-form-cancel"
@click="$emit('cancel')">{{ cancelText }}
</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
default: ''
},
value: {
type: String,
default: ''
},
error: {
type: String,
default: ''
},
okText: {
type: String,
default: 'Klasse beitreten'
},
labelText: {
type: String,
default: 'Zugangscode eingeben'
},
cancelText: {
type: String,
default: 'Abmelden'
}
}
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
</style>

View File

@ -19,6 +19,15 @@
class="profile-sidebar__link">Meine Aktivitäten class="profile-sidebar__link">Meine Aktivitäten
</router-link> </router-link>
</div> </div>
<div
class="profile-sidebar__item"
v-if="me.isTeacher"
@click="close">
<router-link
:to="myTeamPage"
class="profile-sidebar__link">Mein Team
</router-link>
</div>
</div> </div>
<div class="profile-sidebar__section"> <div class="profile-sidebar__section">
<div class="profile-sidebar__item"> <div class="profile-sidebar__item">
@ -56,31 +65,40 @@
import ClassSelectionWidget from '@/components/school-class/ClassSelectionWidget'; import ClassSelectionWidget from '@/components/school-class/ClassSelectionWidget';
import sidebarMixin from '@/mixins/sidebar'; import sidebar from '@/mixins/sidebar';
import me from '@/mixins/me';
import LogoutWidget from '@/components/LogoutWidget'; import LogoutWidget from '@/components/LogoutWidget';
import {MY_TEAM} from '@/router/me.names';
export default { export default {
mixins: [sidebarMixin], mixins: [sidebar, me],
components: { components: {
LogoutWidget, LogoutWidget,
ClassSelectionWidget, ClassSelectionWidget,
ProfileWidget, ProfileWidget,
Cross Cross,
},
computed: {
myTeamPage() {
return {
name: MY_TEAM,
};
},
}, },
methods: { methods: {
close() { close() {
this.closeSidebar('profile'); this.closeSidebar('profile');
} },
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_variables.scss"; @import "~styles/helpers";
@import "@/styles/_mixins.scss";
$desktop-width: 333px; $desktop-width: 333px;

View File

@ -0,0 +1,42 @@
<template>
<div class="show-code">
<h2 class="show-code__title">Zugangscode {{ type }} {{ name }}</h2>
<h1 class="show-code__code">{{ code }}</h1>
</div>
</template>
<script>
export default {
props: {
code: {
type: String,
default: '',
},
type: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
},
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
.show-code {
&__title {
@include regular-text;
margin-bottom: 2*$large-spacing;
}
&__code {
font-size: toRem(120px);
letter-spacing: toRem(20px);
font-weight: 600;
}
}
</style>

View File

@ -1,39 +1,20 @@
<template> <template>
<modal <edit-name-wizard
:hide-header="false" :name="name"
:small="true" type="Klasse"
title="Hello"> @input="name = $event"
<h4 slot="header">Klasse bearbeiten</h4> @cancel="hideModal"
<modal-input @save="save" />
:value="name"
placeholder="Klassenname"
data-cy="edit-class-name-input"
@input="name = $event"
/>
<div slot="footer">
<a
class="button button--primary"
data-cy="modal-save-button"
@click="save">Speichern</a>
<a
class="button"
@click="hide">Abbrechen</a>
</div>
</modal>
</template> </template>
<script> <script>
import Modal from '@/components/Modal';
import ModalInput from '@/components/ModalInput';
import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql'; import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql';
import UPDATE_SCHOOL_CLASS_MUTATION from '@/graphql/gql/mutations/updateSchoolClass.gql'; import UPDATE_SCHOOL_CLASS_MUTATION from '@/graphql/gql/mutations/updateSchoolClass.gql';
import EditNameWizard from '@/components/profile/EditNameWizard';
export default { export default {
components: { components: {
Modal, EditNameWizard,
ModalInput
}, },
data() { data() {
@ -63,9 +44,9 @@
store.writeQuery({query, data}); store.writeQuery({query, data});
} }
}); });
this.hide(); this.hideModal();
}, },
hide() { hideModal() {
this.$store.dispatch('hideModal'); this.$store.dispatch('hideModal');
} }
}, },

View File

@ -2,6 +2,16 @@
query MeQuery { query MeQuery {
me { me {
...UserParts ...UserParts
team {
name
code
id
members {
firstName
lastName
id
}
}
isTeacher isTeacher
permissions permissions
onboardingVisited onboardingVisited

View File

@ -0,0 +1,9 @@
mutation CreateTeamMutation($input: CreateTeamInput!) {
createTeam(input: $input) {
success
team {
name
code
}
}
}

View File

@ -0,0 +1,9 @@
mutation JoinTeamMutation($input: JoinTeamInput!) {
joinTeam(input: $input) {
success
team {
name
code
}
}
}

View File

@ -0,0 +1,8 @@
mutation UpdateTeam($input: UpdateTeamInput!) {
updateTeam(input: $input) {
success
team {
name
}
}
}

View File

@ -0,0 +1,7 @@
export default {
methods: {
addTeam(store, team) {
console.log('add team');
}
}
};

View File

@ -10,6 +10,7 @@ export default {
permissions: [], permissions: [],
schoolClasses: [], schoolClasses: [],
isTeacher: false, isTeacher: false,
team: null
}, },
showPopover: false, showPopover: false,
}; };

View File

@ -1,41 +1,15 @@
<template> <template>
<div class="create-class"> <join-form
<h1 class="create-class__title">Klasse erfassen</h1> :value="name"
class="create-class"
<div> title="Klasse erfassen"
<div class="skillboxform-input"> ok-text="Klasse erfassen"
<label label-text="Name"
for="class-name" cancel-text="Abbrechen"
class="skillboxform-input__label">Name</label> @input="updateName"
<input @cancel="cancel"
:class="{'skillboxform-input__input--error': error}" @confirm="createClass"
:value="name" />
class="skillbox-input skillboxform-input__input"
data-cy="input-class-name"
id="class-name"
@input="updateName">
<small
class="skillboxform-input__error"
data-cy="email-local-errors"
v-if="error"
>{{ error }}
</small>
</div>
<div>
<a
class="button button--primary button--big"
data-cy="create-class"
@click="createClass(name)">Klasse
erfassen</a>
<a
class="button button--big"
data-cy="create-class-cancel"
@click="cancel">Abbrechen</a>
</div>
</div>
</div>
</template> </template>
<script> <script>
@ -43,12 +17,18 @@
import CREATE_CLASS_MUTATION from '@/graphql/gql/mutations/createClass.gql'; import CREATE_CLASS_MUTATION from '@/graphql/gql/mutations/createClass.gql';
import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass'; import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass';
import JoinForm from '@/components/profile/JoinForm';
export default { export default {
mixins: [addSchoolClassMixin], mixins: [addSchoolClassMixin],
components: {
JoinForm
},
data: () => ({ data: () => ({
name: '', name: '',
error: '' error: '',
}), }),
methods: { methods: {
@ -62,24 +42,24 @@
mutation: CREATE_CLASS_MUTATION, mutation: CREATE_CLASS_MUTATION,
variables: { variables: {
input: { input: {
name name,
} },
}, },
update(store, {data: {createSchoolClass: {schoolClass}}}) { update(store, {data: {createSchoolClass: {schoolClass}}}) {
self.addSchoolClass(store, schoolClass); self.addSchoolClass(store, schoolClass);
self.$router.push({ self.$router.push({
name: 'my-class' name: 'my-class',
}); });
}, },
refetchQueries: [{ refetchQueries: [{
query: MY_SCHOOL_CLASS_QUERY query: MY_SCHOOL_CLASS_QUERY,
}] }],
}); });
}, },
cancel() { cancel() {
this.$router.go(-1); this.$router.go(-1);
} },
} },
}; };
</script> </script>

View File

@ -1,40 +1,13 @@
<template> <template>
<div> <join-form
<h1 data-cy="join-class-title">Einer Klasse beitreten</h1> :value="code"
<div> :error="error"
<div class="skillboxform-input"> title="Einer Klasse beitreten"
<label ok-text="Klasse beitreten"
for="join-code" cancel-text="Abmelden"
class="skillboxform-input__label">Zugangscode eingeben</label> @input="updateCode"
<input @cancel="logout"
:class="{'skillboxform-input__input--error': error}" @confirm="joinClass"/>
:value="code"
class="skillbox-input skillboxform-input__input"
data-cy="input-class-code"
id="join-code"
@input="updateCode">
<small
class="skillboxform-input__error"
data-cy="email-local-errors"
v-if="error"
>{{ error }}
</small>
</div>
<div>
<a
class="button button--primary button--big"
data-cy="join-class"
@click="joinClass(code)">Klasse beitreten</a>
<button
class="button button--big"
data-cy="join-class-cancel"
@click="logout">Abmelden
</button>
</div>
</div>
</div>
</template> </template>
<script> <script>
@ -44,12 +17,15 @@
import addSchoolClass from '@/mixins/add-school-class'; import addSchoolClass from '@/mixins/add-school-class';
import logout from '@/mixins/logout'; import logout from '@/mixins/logout';
import JoinForm from '@/components/profile/JoinForm';
export default { export default {
mixins: [addSchoolClass, logout], mixins: [addSchoolClass, logout],
components: {JoinForm},
data: () => ({ data: () => ({
code: '', code: '',
error: '' error: '',
}), }),
methods: { methods: {
@ -60,18 +36,18 @@
joinClass(code) { joinClass(code) {
let self = this; let self = this;
this.$apollo.mutate({ this.$apollo.mutate({
mutation: JOIN_CLASS_MUTATION, mutation: JOIN_CLASS_MUTATION,
variables: { variables: {
input: { input: {
code code,
} },
}, },
update(store, {data: {joinClass: {schoolClass}}}) { update(store, {data: {joinClass: {schoolClass}}}) {
self.addSchoolClass(store, schoolClass); self.addSchoolClass(store, schoolClass);
self.$router.push({name: 'my-class'}); self.$router.push({name: 'my-class'});
}, },
refetchQueries: [{query: MY_SCHOOL_CLASS_QUERY}] refetchQueries: [{query: MY_SCHOOL_CLASS_QUERY}],
}) })
.then(() => { .then(() => {
}) })
@ -83,7 +59,7 @@
this.error = 'Dieser Zugangscode ist nicht gültig.'; this.error = 'Dieser Zugangscode ist nicht gültig.';
} }
}); });
} },
} },
}; };
</script> </script>

View File

@ -0,0 +1,61 @@
<template>
<join-form
:value="name"
class="create-team"
title="Team erfassen"
ok-text="Team erfassen"
label-text="Name"
cancel-text="Abbrechen"
@input="updateName"
@cancel="cancel"
@confirm="createTeam"
/>
</template>
<script>
import addTeamMixin from '@/mixins/add-team';
import JoinForm from '@/components/profile/JoinForm';
import CREATE_TEAM_MUTATION from '@/graphql/gql/mutations/createTeam.gql';
import {MY_TEAM} from '@/router/me.names';
export default {
mixins: [addTeamMixin],
components: {
JoinForm
},
data: () => ({
name: '',
error: '',
}),
methods: {
updateName(event) {
this.name = event.target.value;
this.error = '';
},
createTeam(name) {
this.$apollo.mutate({
mutation: CREATE_TEAM_MUTATION,
variables: {
input: {
name,
},
},
update: (store, {data: {createTeam: {team}}}) => {
this.addTeam(store, team);
this.$router.push({
name: MY_TEAM,
});
},
});
},
cancel() {
this.$router.go(-1);
},
},
};
</script>

View File

@ -0,0 +1,75 @@
<template>
<join-form
:value="code"
:error="error"
title="Einem Team beitreten"
ok-text="Team beitreten"
cancel-text="Abbrechen"
@input="updateCode"
@cancel="cancel"
@confirm="joinTeam"/>
</template>
<script>
import JOIN_TEAM_MUTATION from '@/graphql/gql/mutations/joinTeam.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import JoinForm from '@/components/profile/JoinForm';
import {MY_TEAM} from '@/router/me.names';
export default {
components: {
JoinForm,
},
data() {
return {
code: '',
error: '',
teamRoute: {
name: MY_TEAM,
},
};
},
methods: {
updateCode(event) {
this.code = event.target.value;
this.error = '';
},
cancel() {
this.$router.push(this.teamRoute);
},
joinTeam(code) {
this.$apollo.mutate({
mutation: JOIN_TEAM_MUTATION,
variables: {
'input': {
'code': code,
},
},
update: (store, {data: {joinTeam: {team}}}) => {
const query = ME_QUERY;
const data = store.readQuery({query});
store.writeQuery({
query,
data: {
...data,
me: {
...data.me,
team: team,
},
},
});
this.$router.push({name: MY_TEAM});
},
});
},
},
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
</style>

View File

@ -0,0 +1,83 @@
<template>
<div class="my-team">
<template v-if="me.team">
<group-list
:active-members="me.team.members"
:can-edit="true"
:show-code-route="showCodeRoute"
:show-code="true"
:name="me.team.name"
title="Mein Team"
@edit="editTeamName"
/>
</template>
<template v-else>
<h1 class="my-team__heading">Mein Team {{ me.team }}</h1>
<div class="my-team__section">
<h2 class="my-team__subheading">Willst du einem bestehenden Team beitreten?</h2>
<router-link
:to="joinTeamRoute"
class="button button--primary">Zugangscode eingeben
</router-link>
</div>
<div class="my-team__section">
<h2 class="my-team__subheading">Willst du ein neues Team erfassen?</h2>
<router-link
:to="createTeamRoute"
class="button button--primary">Team erfassen
</router-link>
</div>
</template>
</div>
</template>
<script>
import {CREATE_TEAM, JOIN_TEAM, SHOW_TEAM_CODE} from '@/router/me.names';
import me from '@/mixins/me';
import GroupList from '@/components/profile/GroupList';
export default {
mixins: [me],
components: {GroupList},
data() {
return {
joinTeamRoute: {
name: JOIN_TEAM,
},
createTeamRoute: {
name: CREATE_TEAM,
},
showCodeRoute: {
name: SHOW_TEAM_CODE
}
};
},
methods: {
editTeamName() {
this.$store.dispatch('editTeamName');
}
}
};
</script>
<style scoped lang="scss">
@import '~styles/helpers';
.my-team {
&__heading {
margin-bottom: $section-spacing;
}
&__section {
margin-bottom: $section-spacing;
}
&__subheading {
@include heading-3;
margin-bottom: $large-spacing;
}
}
</style>

View File

@ -0,0 +1,17 @@
<template>
<show-code
:name="me.selectedClass.name"
:code="me.selectedClass.code"
class="show-school-class-code"
type="Klasse"/>
</template>
<script>
import selectedClassMixin from '@/mixins/selected-class';
import ShowCode from '@/components/profile/ShowCode';
export default {
mixins: [selectedClassMixin],
components: {ShowCode},
};
</script>

View File

@ -0,0 +1,17 @@
<template>
<show-code
:name="me.team.name"
:code="me.team.code"
class="show-school-class-code"
type="Team"/>
</template>
<script>
import me from '@/mixins/me';
import ShowCode from '@/components/profile/ShowCode';
export default {
mixins: [me],
components: {ShowCode},
};
</script>

View File

@ -1,37 +1,41 @@
<template> <template>
<div class="my-class"> <div class="my-class">
<h1 <group-list
class="my-class__header"
data-cy="class-list-title">Klassenliste</h1>
<router-link
:to="{name: 'show-code'}"
class="my-class__code-link button button--primary"
v-if="me.isTeacher">Zugangscode anzeigen
</router-link>
<class-list
:name="me.selectedClass.name" :name="me.selectedClass.name"
:members="me.selectedClass.members" :active-members="me.selectedClass.members.filter(member => member.active)"
:teacher="me.isTeacher" :inactive-members="me.selectedClass.members.filter(member => !member.active)"
:id="me.selectedClass.id" :show-code="me.isTeacher"
:show-code-route="showCodeRoute"
:can-edit="me.isTeacher"
title="Klassenliste"
class="my-class__class" class="my-class__class"
@remove="remove"
@add="add" @add="add"
@edit="editClassName"
/> />
</div> </div>
</template> </template>
<script> <script>
import ClassList from '@/components/profile/ClassList'; import GroupList from '@/components/profile/GroupList';
import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql'; import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql';
import ADD_REMOVE_MEMBER_MUTATION from '@/graphql/gql/mutations/addRemoveMember.gql'; import ADD_REMOVE_MEMBER_MUTATION from '@/graphql/gql/mutations/addRemoveMember.gql';
import selectedClassMixin from '@/mixins/selected-class'; import selectedClassMixin from '@/mixins/selected-class';
import {SHOW_SCHOOL_CLASS_CODE} from '@/router/me.names';
export default { export default {
mixins: [selectedClassMixin], mixins: [selectedClassMixin],
components: { components: {
ClassList GroupList,
},
data() {
return {
showCodeRoute: {
name: SHOW_SCHOOL_CLASS_CODE,
},
};
}, },
methods: { methods: {
@ -42,8 +46,8 @@
input: { input: {
member: member.id, member: member.id,
schoolClass: this.me.selectedClass.id, schoolClass: this.me.selectedClass.id,
active active,
} },
}, },
update(store, {data: {addRemoveMember: {success}}}) { update(store, {data: {addRemoveMember: {success}}}) {
if (success) { if (success) {
@ -57,7 +61,7 @@
]; ];
store.writeQuery({query, data}); store.writeQuery({query, data});
} }
} },
}); });
}, },
add(member) { add(member) {
@ -65,22 +69,25 @@
}, },
remove(member) { remove(member) {
this.$modal.open('deactivate-person', { this.$modal.open('deactivate-person', {
myself: member.id === this.me.id, myself: member.id === this.me.id,
name: `${member.firstName} ${member.lastName}`, name: `${member.firstName} ${member.lastName}`,
className: this.me.selectedClass.name, className: this.me.selectedClass.name,
}) })
.then(() => { .then(() => {
this.changeMember(member, false); this.changeMember(member, false);
}) })
.catch(() => { .catch(() => {
}); });
},
editClassName() {
this.$store.dispatch('editClassName');
} }
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_variables.scss"; @import "~styles/helpers";
.my-class { .my-class {
display: grid; display: grid;

View File

@ -5,9 +5,7 @@
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_variables.scss"; @import "~styles/helpers";
@import "@/styles/_functions.scss";
@import "@/styles/_mixins.scss";
.profile { .profile {
padding: $large-spacing; padding: $large-spacing;

View File

@ -1,32 +0,0 @@
<template>
<div class="show-code">
<h2 class="show-code__title">Zugangscode Klasse {{ me.selectedClass.name }}</h2>
<h1 class="show-code__code">{{ me.selectedClass.code }}</h1>
</div>
</template>
<script>
import selectedClassMixin from '@/mixins/selected-class';
export default {
mixins: [selectedClassMixin],
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.show-code {
&__title {
@include regular-text;
margin-bottom: 2*$large-spacing;
}
&__code {
font-size: toRem(120px);
letter-spacing: toRem(20px);
font-weight: 600;
}
}
</style>

View File

@ -6,10 +6,6 @@ import instrumentOverview from '@/pages/instrumentOverview';
import p404 from '@/pages/p404'; import p404 from '@/pages/p404';
import start from '@/pages/start'; import start from '@/pages/start';
import submission from '@/pages/studentSubmission'; import submission from '@/pages/studentSubmission';
import profilePage from '@/pages/profile';
import profile from '@/components/profile/Profile';
import myClass from '@/pages/myClass';
import activity from '@/pages/activity';
import Router from 'vue-router'; import Router from 'vue-router';
import surveyPage from '@/pages/survey'; import surveyPage from '@/pages/survey';
import styleGuidePage from '@/pages/styleguide'; import styleGuidePage from '@/pages/styleguide';
@ -18,14 +14,12 @@ import emailVerification from '@/pages/email-verification';
import licenseActivation from '@/pages/license-activation'; import licenseActivation from '@/pages/license-activation';
import forgotPassword from '@/pages/forgot-password'; import forgotPassword from '@/pages/forgot-password';
import joinClass from '@/pages/joinClass'; import joinClass from '@/pages/joinClass';
import oldClasses from '@/pages/oldClasses';
import createClass from '@/pages/createClass';
import showCode from '@/pages/showCode';
import news from '@/pages/news'; import news from '@/pages/news';
import moduleRoutes from './module.routes'; import moduleRoutes from './module.routes';
import portfolioRoutes from './portfolio.routes'; import portfolioRoutes from './portfolio.routes';
import onboardingRoutes from './onboarding.routes'; import onboardingRoutes from './onboarding.routes';
import meRoutes from './me.routes';
import authRoutes from './auth.routes'; import authRoutes from './auth.routes';
import roomRoutes from './room.routes'; import roomRoutes from './room.routes';
@ -50,25 +44,8 @@ const routes = [
{path: '/submission/:id', name: 'submission', component: submission, meta: {layout: 'simple'}}, {path: '/submission/:id', name: 'submission', component: submission, meta: {layout: 'simple'}},
...portfolioRoutes, ...portfolioRoutes,
{path: '/topic/:topicSlug', name: 'topic', component: topic, alias: '/book/topic/:topicSlug'}, {path: '/topic/:topicSlug', name: 'topic', component: topic, alias: '/book/topic/:topicSlug'},
{ ...meRoutes,
path: '/me', {path: '/join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}},
component: profilePage,
children: [
{path: 'profile', name: 'profile', component: profile, meta: {isProfile: true}},
{path: 'my-class', name: 'my-class', component: myClass, meta: {isProfile: true}},
{path: 'activity', name: 'activity', component: activity, meta: {isProfile: true}},
{path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}},
{
path: 'old-classes',
name: 'old-classes',
component: oldClasses,
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'}},
{ {
path: '/survey/:id', path: '/survey/:id',
component: surveyPage, component: surveyPage,

View File

@ -0,0 +1,5 @@
export const MY_TEAM = 'my-team';
export const JOIN_TEAM = 'join-team';
export const CREATE_TEAM = 'create-team';
export const SHOW_SCHOOL_CLASS_CODE = 'show-school-class-code';
export const SHOW_TEAM_CODE = 'show-teams-code';

View File

@ -0,0 +1,55 @@
import profilePage from '@/pages/profile';
import profile from '@/components/profile/Profile';
import myClass from '@/pages/myClass';
import activity from '@/pages/activity';
import oldClasses from '@/pages/oldClasses';
import createClass from '@/pages/createClass';
import showSchoolClassCode from '@/pages/me/showSchoolClassCode';
import showTeamCode from '@/pages/me/showTeamCode';
import myTeam from '@/pages/me/myTeam';
import joinTeam from '@/pages/me/joinTeam';
import createTeam from '@/pages/me/createTeam';
import {CREATE_TEAM, JOIN_TEAM, MY_TEAM, SHOW_SCHOOL_CLASS_CODE, SHOW_TEAM_CODE} from './me.names';
export default [
{
path: '/me',
component: profilePage,
children: [
{path: 'profile', name: 'profile', component: profile, meta: {isProfile: true}},
{path: 'class', alias: 'my-class', name: 'my-class', component: myClass, meta: {isProfile: true}},
{path: 'activity', name: 'activity', component: activity, meta: {isProfile: true}},
{path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}},
{
path: 'old-classes',
name: 'old-classes',
component: oldClasses,
meta: {isProfile: true},
},
{
path: 'class/create',
alias: 'create-class',
name: 'create-class',
component: createClass,
meta: {layout: 'simple'},
},
{
path: 'class/code',
alias: 'show-code',
name: SHOW_SCHOOL_CLASS_CODE,
component: showSchoolClassCode,
meta: {layout: 'simple'},
},
{path: 'team', name: MY_TEAM, component: myTeam, meta: {isProfile: true}},
{path: 'team/join', name: JOIN_TEAM, component: joinTeam, meta: {isProfile: true, layout: 'simple'}},
{path: 'team/create', name: CREATE_TEAM, component: createTeam, meta: {isProfile: true, layout: 'simple'}},
{
path: 'team/code',
name: SHOW_TEAM_CODE,
component: showTeamCode,
meta: {layout: 'simple'},
},
],
},
];

View File

@ -185,9 +185,12 @@ export default new Vuex.Store({
editModule({commit}, payload) { editModule({commit}, payload) {
commit('setEditModule', payload); commit('setEditModule', payload);
}, },
editClassName({dispatch}, payload) { editClassName({dispatch}) {
dispatch('showModal', 'edit-class-name-wizard'); dispatch('showModal', 'edit-class-name-wizard');
}, },
editTeamName({dispatch}) {
dispatch('showModal', 'edit-team-name-wizard');
},
deactivateUser({commit, dispatch}, payload) { deactivateUser({commit, dispatch}, payload) {
commit('setModulePayload', payload); commit('setModulePayload', payload);
return dispatch('showModal', 'deactivate-person'); return dispatch('showModal', 'deactivate-person');

View File

@ -347,6 +347,17 @@ type CreateSchoolClassPayload {
clientMutationId: String clientMutationId: String
} }
input CreateTeamInput {
name: String!
clientMutationId: String
}
type CreateTeamPayload {
success: Boolean
team: TeamNode
clientMutationId: String
}
type CustomMutation { type CustomMutation {
redeemCoupon(input: CouponInput!): CouponPayload redeemCoupon(input: CouponInput!): CouponPayload
spellCheck(input: SpellCheckInput!): SpellCheckPayload spellCheck(input: SpellCheckInput!): SpellCheckPayload
@ -365,6 +376,9 @@ type CustomMutation {
updateSchoolClass(input: UpdateSchoolClassInput!): UpdateSchoolClassPayload updateSchoolClass(input: UpdateSchoolClassInput!): UpdateSchoolClassPayload
createSchoolClass(input: CreateSchoolClassInput!): CreateSchoolClassPayload createSchoolClass(input: CreateSchoolClassInput!): CreateSchoolClassPayload
updateOnboardingProgress: UpdateOnboardingProgress updateOnboardingProgress: UpdateOnboardingProgress
createTeam(input: CreateTeamInput!): CreateTeamPayload
joinTeam(input: JoinTeamInput!): JoinTeamPayload
updateTeam(input: UpdateTeamInput!): UpdateTeamPayload
addProject(input: AddProjectInput!): AddProjectPayload addProject(input: AddProjectInput!): AddProjectPayload
updateProject(input: UpdateProjectInput!): UpdateProjectPayload updateProject(input: UpdateProjectInput!): UpdateProjectPayload
deleteProject(input: DeleteProjectInput!): DeleteProjectPayload deleteProject(input: DeleteProjectInput!): DeleteProjectPayload
@ -578,6 +592,17 @@ type JoinClassPayload {
clientMutationId: String clientMutationId: String
} }
input JoinTeamInput {
code: String!
clientMutationId: String
}
type JoinTeamPayload {
success: Boolean
team: TeamNode
clientMutationId: String
}
type Logout { type Logout {
success: Boolean success: Boolean
} }
@ -803,8 +828,8 @@ type SchoolClassNode implements Node {
id: ID! id: ID!
name: String! name: String!
isDeleted: Boolean! isDeleted: Boolean!
users(offset: Int, before: String, after: String, first: Int, last: Int, username: String, email: String): UserNodeConnection!
code: String code: String
users(offset: Int, before: String, after: String, first: Int, last: Int, username: String, email: String): UserNodeConnection!
moduleSet(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! moduleSet(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!
hiddenChapterTitles(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection! hiddenChapterTitles(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection!
hiddenChapterDescriptions(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection! hiddenChapterDescriptions(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection!
@ -920,6 +945,16 @@ type SyncModuleVisibilityPayload {
clientMutationId: String clientMutationId: String
} }
type TeamNode implements Node {
name: String!
isDeleted: Boolean!
code: String
id: ID!
creator: UserNode
members: [UserNode]
pk: Int
}
type TopicConnection { type TopicConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
edges: [TopicEdge]! edges: [TopicEdge]!
@ -1266,6 +1301,18 @@ type UpdateSubmissionFeedbackPayload {
clientMutationId: String clientMutationId: String
} }
input UpdateTeamInput {
id: ID!
name: String
clientMutationId: String
}
type UpdateTeamPayload {
success: Boolean
team: TeamNode
clientMutationId: String
}
input UserGroupBlockVisibility { input UserGroupBlockVisibility {
schoolClassId: ID! schoolClassId: ID!
hidden: Boolean! hidden: Boolean!
@ -1280,6 +1327,7 @@ type UserNode implements Node {
avatarUrl: String! avatarUrl: String!
email: String! email: String!
onboardingVisited: Boolean! onboardingVisited: Boolean!
team: TeamNode
schoolClasses(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! schoolClasses(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection!
id: ID! id: ID!
pk: Int pk: Int

View File

@ -10,6 +10,8 @@ from faker import Faker
from wagtail.documents.models import get_document_model from wagtail.documents.models import get_document_model
from wagtail.images import get_image_model from wagtail.images import get_image_model
from users.models import Role, UserRole
fake = Faker('de_CH') fake = Faker('de_CH')
@ -48,7 +50,7 @@ class DummyImageFactory(factory.DjangoModelFactory):
class UserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = get_user_model() model = get_user_model()
django_get_or_create = ('username', ) django_get_or_create = ('username',)
first_name = factory.LazyAttribute(lambda x: fake.first_name()) first_name = factory.LazyAttribute(lambda x: fake.first_name())
last_name = factory.LazyAttribute(lambda x: fake.last_name()) last_name = factory.LazyAttribute(lambda x: fake.last_name())
@ -58,3 +60,11 @@ class UserFactory(factory.django.DjangoModelFactory):
def post(self, create, extracted, **kwargs): def post(self, create, extracted, **kwargs):
self.set_password('test') self.set_password('test')
self.save() self.save()
class TeacherFactory(UserFactory):
@factory.post_generation
def post(self, create, extracted, **kwargs):
Role.objects.create_default_roles()
teacher_role = Role.objects.get_default_teacher_role()
UserRole.objects.create(user=self, role=teacher_role)

View File

@ -16,6 +16,6 @@ class Command(BaseCommand):
with open(schema_path, 'w') as o: with open(schema_path, 'w') as o:
o.write(str(schema)) o.write(str(schema))
with open(public_schema_path, 'w') as o: # with open(public_schema_path, 'w') as o:
o.write(str(schema_public)) # o.write(str(schema_public))

View File

@ -1,7 +1,8 @@
import random import random
import factory import factory
from users.models import SchoolClass, SchoolClassMember, License
from users.models import SchoolClass, SchoolClassMember, License, Team
class_types = ['DA', 'KV', 'INF', 'EE'] class_types = ['DA', 'KV', 'INF', 'EE']
class_suffix = ['A', 'B', 'C', 'D', 'E'] class_suffix = ['A', 'B', 'C', 'D', 'E']
@ -31,6 +32,15 @@ class SchoolClassFactory(factory.django.DjangoModelFactory):
SchoolClassMember.objects.create(user=user, school_class=self, active=True) SchoolClassMember.objects.create(user=user, school_class=self, active=True)
class TeamFactory(factory.django.DjangoModelFactory):
class Meta:
model = Team
name = factory.Faker('name')
code = factory.Sequence(lambda n: "CODE{}".format(n))
is_deleted = False
class LicenseFactory(factory.django.DjangoModelFactory): class LicenseFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = License model = License

View File

@ -0,0 +1,33 @@
# Generated by Django 2.2.19 on 2021-03-24 21:26
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0025_auto_20210126_1343'),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('is_deleted', models.BooleanField(default=False)),
('code', models.CharField(blank=True, default=None, max_length=10, null=True, unique=True, verbose_name='Code zum Beitreten')),
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='user',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='users.Team'),
),
]

View File

@ -1,7 +1,7 @@
import re
from datetime import datetime
import string
import random import random
import re
import string
from datetime import datetime
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Permission from django.contrib.auth.models import AbstractUser, Permission
@ -25,6 +25,7 @@ class User(AbstractUser):
hep_group_id = models.PositiveIntegerField(null=True, blank=False) hep_group_id = models.PositiveIntegerField(null=True, blank=False)
license_expiry_date = models.DateField(blank=False, null=True, default=None) license_expiry_date = models.DateField(blank=False, null=True, default=None)
onboarding_visited = models.BooleanField(default=False) onboarding_visited = models.BooleanField(default=False)
team = models.ForeignKey('users.Team', on_delete=models.SET_NULL, blank=True, null=True, related_name='members')
# for wagtail autocomplete # for wagtail autocomplete
autocomplete_search_field = 'username' autocomplete_search_field = 'username'
@ -110,14 +111,43 @@ class User(AbstractUser):
return self.get_full_name() return self.get_full_name()
class Meta: class Meta:
ordering = ['pk',] ordering = ['pk', ]
class GroupWithCode(models.Model):
class Meta:
abstract = True
class SchoolClass(models.Model):
name = models.CharField(max_length=100, blank=False, null=False, unique=True) name = models.CharField(max_length=100, blank=False, null=False, unique=True)
is_deleted = models.BooleanField(blank=False, null=False, default=False) is_deleted = models.BooleanField(blank=False, null=False, default=False)
code = models.CharField('Code zum Beitreten', blank=True, null=True, max_length=10, unique=True, default=None)
def generate_code(self):
letters = string.ascii_lowercase
digits = string.digits
code = ''.join(random.choice(letters) for i in range(4)) + ''.join(random.choice(digits) for i in range(2))
try:
self.__class__.objects.get(code=code)
self.generate_code()
except self.__class__.DoesNotExist:
self.code = code.upper()
self.save()
class Team(GroupWithCode):
creator = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL, blank=True, related_name='+')
class Meta:
verbose_name = 'Team'
verbose_name_plural = 'Teams'
def __str__(self):
return self.name
class SchoolClass(GroupWithCode):
users = models.ManyToManyField(get_user_model(), related_name='school_classes', blank=True, users = models.ManyToManyField(get_user_model(), related_name='school_classes', blank=True,
through='users.SchoolClassMember') through='users.SchoolClassMember')
code = models.CharField('Code zum Beitreten', blank=True, null=True, max_length=10, unique=True, default=None)
class Meta: class Meta:
verbose_name = 'Schulklasse' verbose_name = 'Schulklasse'
@ -162,17 +192,6 @@ class SchoolClass(models.Model):
def get_teacher(self): def get_teacher(self):
return self.users.filter(user_roles__role__key='teacher').first() return self.users.filter(user_roles__role__key='teacher').first()
def generate_code(self):
letters = string.ascii_lowercase
digits = string.digits
code = ''.join(random.choice(letters) for i in range(4)) + ''.join(random.choice(digits) for i in range(2))
try:
SchoolClass.objects.get(code=code)
self.generate_code()
except SchoolClass.DoesNotExist:
self.code = code.upper()
self.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.code == '': # '' can't be unique, so we null it if self.code == '': # '' can't be unique, so we null it
self.code = None self.code = None

View File

@ -6,11 +6,13 @@ from graphene import relay
from graphql_relay import from_global_id from graphql_relay import from_global_id
from api.utils import get_object from api.utils import get_object
from core.logger import get_logger
from users.inputs import PasswordUpdateInput from users.inputs import PasswordUpdateInput
from users.models import SchoolClass, UserSetting, User, SchoolClassMember from users.models import SchoolClass, SchoolClassMember, Team
from users.schema import SchoolClassNode from users.schema import SchoolClassNode, TeamNode
from users.serializers import PasswordSerialzer, AvatarUrlSerializer from users.serializers import PasswordSerialzer, AvatarUrlSerializer
logger = get_logger(__name__)
class CodeNotFoundException(Exception): class CodeNotFoundException(Exception):
pass pass
@ -25,6 +27,18 @@ class UpdateError(graphene.ObjectType):
errors = graphene.List(FieldError) errors = graphene.List(FieldError)
class TeacherOnlyMutation(relay.ClientIDMutation):
class Meta:
abstract = True
@classmethod
def mutate(cls, root, info, input):
user = info.context.user
if 'users.can_manage_school_class_content' not in user.get_role_permissions():
raise PermissionError('Permission denied')
return super().mutate(root, info, input)
class UpdatePassword(relay.ClientIDMutation): class UpdatePassword(relay.ClientIDMutation):
class Input: class Input:
password_input = graphene.Argument(PasswordUpdateInput) password_input = graphene.Argument(PasswordUpdateInput)
@ -132,7 +146,7 @@ class JoinClass(relay.ClientIDMutation):
return cls(success=True, school_class=school_class) return cls(success=True, school_class=school_class)
except SchoolClass.DoesNotExist: except SchoolClass.DoesNotExist:
raise CodeNotFoundException('[CNV] Code ist nicht gültig') # CAV = Code Not Valid raise CodeNotFoundException('[CNV] Code ist nicht gültig') # CNV = Code Not Valid
class AddRemoveMember(relay.ClientIDMutation): class AddRemoveMember(relay.ClientIDMutation):
@ -154,7 +168,7 @@ class AddRemoveMember(relay.ClientIDMutation):
school_class = get_object(SchoolClass, school_class_id) school_class = get_object(SchoolClass, school_class_id)
if not user.is_teacher() or not school_class.users.filter(pk=user.pk).exists(): if not user.is_teacher() or not school_class.users.filter(pk=user.pk).exists():
raise PermissionError('Fehlende Berechtigung') raise PermissionError('Permission denied')
school_class_member = SchoolClassMember.objects.get(user__pk=member_pk, school_class=school_class) school_class_member = SchoolClassMember.objects.get(user__pk=member_pk, school_class=school_class)
school_class_member.active = active school_class_member.active = active
@ -163,7 +177,7 @@ class AddRemoveMember(relay.ClientIDMutation):
return cls(success=True) return cls(success=True)
class UpdateSchoolClass(relay.ClientIDMutation): class UpdateSchoolClass(TeacherOnlyMutation):
class Input: class Input:
id = graphene.ID(required=True) id = graphene.ID(required=True)
name = graphene.String() name = graphene.String()
@ -177,9 +191,7 @@ class UpdateSchoolClass(relay.ClientIDMutation):
name = kwargs.get('name') name = kwargs.get('name')
user = info.context.user user = info.context.user
if 'users.can_manage_school_class_content' not in user.get_role_permissions(): # todo: only allow to edit your own school class
raise PermissionError()
school_class = get_object(SchoolClass, id) school_class = get_object(SchoolClass, id)
school_class.name = name school_class.name = name
school_class.save() school_class.save()
@ -187,7 +199,7 @@ class UpdateSchoolClass(relay.ClientIDMutation):
return cls(success=True, school_class=school_class) return cls(success=True, school_class=school_class)
class CreateSchoolClass(relay.ClientIDMutation): class CreateSchoolClass(TeacherOnlyMutation):
class Input: class Input:
name = graphene.String() name = graphene.String()
@ -197,18 +209,78 @@ class CreateSchoolClass(relay.ClientIDMutation):
@classmethod @classmethod
def mutate_and_get_payload(cls, root, info, **kwargs): def mutate_and_get_payload(cls, root, info, **kwargs):
name = kwargs.get('name') name = kwargs.get('name')
user = info.context.user user = info.context.user
if 'users.can_manage_school_class_content' not in user.get_role_permissions():
raise PermissionError()
school_class = SchoolClass.objects.create(name=name) school_class = SchoolClass.objects.create(name=name)
SchoolClassMember.objects.create(school_class=school_class, user=user) SchoolClassMember.objects.create(school_class=school_class, user=user)
user.set_selected_class(school_class) user.set_selected_class(school_class)
return cls(success=True, school_class=school_class) return cls(success=True, school_class=school_class)
class CreateTeam(TeacherOnlyMutation):
class Input:
name = graphene.String(required=True)
success = graphene.Boolean()
team = graphene.Field(TeamNode)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
name = kwargs.get('name')
user = info.context.user
team = Team.objects.create(name=name, creator=user)
team.generate_code()
user.team = team
user.save()
return cls(success=True, team=team)
class UpdateTeam(TeacherOnlyMutation):
class Input:
id = graphene.ID(required=True)
name = graphene.String()
success = graphene.Boolean()
team = graphene.Field(TeamNode)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
user = info.context.user
team = get_object(Team, id)
if user not in team.members.all():
logger.info('User not part of this team')
raise PermissionError('Permission denied')
team.name = name
team.save()
return cls(success=True, team=team)
class JoinTeam(TeacherOnlyMutation):
class Input:
code = graphene.String(required=True)
success = graphene.Boolean()
team = graphene.Field(TeamNode)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
user = info.context.user
code = kwargs.get('code')
try:
team = Team.objects.get(Q(code__iexact=code))
user.team = team
user.save()
return cls(success=True, team=team)
except Team.DoesNotExist:
raise CodeNotFoundException('[CNV] Code ist nicht gültig') # CNV = Code Not Valid
class UpdateOnboardingProgress(graphene.Mutation): class UpdateOnboardingProgress(graphene.Mutation):
success = graphene.Boolean() success = graphene.Boolean()
@ -231,3 +303,6 @@ class ProfileMutations:
update_school_class = UpdateSchoolClass.Field() update_school_class = UpdateSchoolClass.Field()
create_school_class = CreateSchoolClass.Field() create_school_class = CreateSchoolClass.Field()
update_onboarding_progress = UpdateOnboardingProgress.Field() update_onboarding_progress = UpdateOnboardingProgress.Field()
create_team = CreateTeam.Field()
join_team = JoinTeam.Field()
update_team = UpdateTeam.Field()

View File

@ -2,18 +2,18 @@ from datetime import datetime
import graphene import graphene
from django.db.models import Q from django.db.models import Q
from django.utils.dateformat import format
from django_filters import FilterSet, OrderingFilter from django_filters import FilterSet, OrderingFilter
from graphene import relay, ObjectType from graphene import relay, ObjectType
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
from django.utils.dateformat import format
from graphql_relay import to_global_id from graphql_relay import to_global_id
from basicknowledge.models import BasicKnowledge from basicknowledge.models import BasicKnowledge
from basicknowledge.queries import InstrumentNode from basicknowledge.queries import InstrumentNode
from books.models import Module, RecentModule from books.models import Module
from books.schema.queries import ModuleNode from books.schema.queries import ModuleNode
from users.models import User, SchoolClass, SchoolClassMember from users.models import User, SchoolClass, SchoolClassMember, Team
class SchoolClassNode(DjangoObjectType): class SchoolClassNode(DjangoObjectType):
@ -40,6 +40,22 @@ class SchoolClassNode(DjangoObjectType):
return self.code return self.code
class TeamNode(DjangoObjectType):
class Meta:
model = Team
filter_fields = ['name']
interfaces = (relay.Node,)
pk = graphene.Int()
members = graphene.List('users.schema.UserNode')
def resolve_pk(self, *args, **kwargs):
return self.id
def resolve_members(self, *args, **kwargs):
return self.members.all()
class RecentModuleFilter(FilterSet): class RecentModuleFilter(FilterSet):
class Meta: class Meta:
model = Module model = Module
@ -60,13 +76,14 @@ class UserNode(DjangoObjectType):
is_teacher = graphene.Boolean() is_teacher = graphene.Boolean()
old_classes = DjangoFilterConnectionField(SchoolClassNode) old_classes = DjangoFilterConnectionField(SchoolClassNode)
recent_modules = DjangoFilterConnectionField(ModuleNode, filterset_class=RecentModuleFilter) recent_modules = DjangoFilterConnectionField(ModuleNode, filterset_class=RecentModuleFilter)
team = graphene.Field(TeamNode)
class Meta: class Meta:
model = User model = User
filter_fields = ['username', 'email'] filter_fields = ['username', 'email']
only_fields = ['username', 'email', 'first_name', 'last_name', 'school_classes', 'last_module', only_fields = ['username', 'email', 'first_name', 'last_name', 'school_classes', 'last_module',
'last_topic', 'avatar_url', 'last_topic', 'avatar_url',
'selected_class', 'expiry_date', 'onboarding_visited'] 'selected_class', 'expiry_date', 'onboarding_visited', 'team']
interfaces = (relay.Node,) interfaces = (relay.Node,)
def resolve_pk(self, info, **kwargs): def resolve_pk(self, info, **kwargs):
@ -100,6 +117,9 @@ class UserNode(DjangoObjectType):
# see https://docs.graphene-python.org/projects/django/en/latest/filtering/ # see https://docs.graphene-python.org/projects/django/en/latest/filtering/
return RecentModuleFilter(kwargs).qs.filter(recent_modules__user=self) return RecentModuleFilter(kwargs).qs.filter(recent_modules__user=self)
def resolve_team(self, info, **kwargs):
return self.team
class ClassMemberNode(ObjectType): class ClassMemberNode(ObjectType):
""" """

View File

@ -1,32 +1,16 @@
# -*- coding: utf-8 -*-
#
# ITerativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
#
# Created on 2019-10-10
# @author: chrigu <christian.cueni@iterativ.ch>
from django.conf import settings
# -*- coding: utf-8 -*-
#
# ITerativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
#
# Created on 2019-04-09
# @author: chrigu <christian.cueni@iterativ.ch>
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from graphene import Context
from graphene.test import Client from graphene.test import Client
from graphql_relay import to_global_id from graphql_relay import to_global_id
from api.utils import get_graphql_mutation, get_object
from core.factories import UserFactory
from users.models import SchoolClass, User
from api.schema import schema from api.schema import schema
from api.utils import get_graphql_mutation, get_object
from core.factories import UserFactory, TeacherFactory
from users.models import SchoolClass, User
from users.services import create_users from users.services import create_users
UPDATE_SCHOOL_CLASS_MUTATION = get_graphql_mutation('updateSchoolClass.gql')
class SchoolClassesTest(TestCase): class SchoolClassesTest(TestCase):
@ -53,6 +37,7 @@ class SchoolClassesTest(TestCase):
class_name = SchoolClass.generate_default_group_name(user=user) class_name = SchoolClass.generate_default_group_name(user=user)
self.assertEqual(f'{self.prefix} {user.pk}', class_name) self.assertEqual(f'{self.prefix} {user.pk}', class_name)
class ModifySchoolClassTest(TestCase): class ModifySchoolClassTest(TestCase):
def setUp(self): def setUp(self):
create_users() create_users()
@ -72,9 +57,8 @@ class ModifySchoolClassTest(TestCase):
school_class = SchoolClass.objects.get(name='skillbox') school_class = SchoolClass.objects.get(name='skillbox')
self.assertEqual(school_class.name, 'skillbox') self.assertEqual(school_class.name, 'skillbox')
id = to_global_id('SchoolClassNode', school_class.id) id = to_global_id('SchoolClassNode', school_class.id)
mutation = get_graphql_mutation('updateSchoolClass.gql')
result = self.client.execute(mutation, variables={ result = self.client.execute(UPDATE_SCHOOL_CLASS_MUTATION, variables={
'input': { 'input': {
'id': id, 'id': id,
'name': class_name 'name': class_name
@ -85,15 +69,30 @@ class ModifySchoolClassTest(TestCase):
school_class = get_object(SchoolClass, id) school_class = get_object(SchoolClass, id)
self.assertEqual(school_class.name, class_name) self.assertEqual(school_class.name, class_name)
def test_update_school_class_not_in_class_fails(self):
client = Client(schema=schema)
teacher = TeacherFactory(username='conan')
context = Context(user=teacher)
school_class = SchoolClass.objects.get(name='skillbox')
self.assertEqual(school_class.name, 'skillbox')
id = to_global_id('SchoolClassNode', school_class.id)
variables = {
'input': {
'id': id,
'name': 'Nein'
}
}
result = client.execute(UPDATE_SCHOOL_CLASS_MUTATION, variables=variables, context=context)
self.assertIsNone(result.get('errors'))
def test_update_school_class_fail(self): def test_update_school_class_fail(self):
class_name = 'Nanana' class_name = 'Nanana'
school_class = SchoolClass.objects.get(name='skillbox') school_class = SchoolClass.objects.get(name='skillbox')
self.assertEqual(school_class.name, 'skillbox') self.assertEqual(school_class.name, 'skillbox')
id = to_global_id('SchoolClassNode', school_class.id) id = to_global_id('SchoolClassNode', school_class.id)
mutation = get_graphql_mutation('updateSchoolClass.gql')
result = self.student_client.execute(mutation, variables={ result = self.student_client.execute(UPDATE_SCHOOL_CLASS_MUTATION, variables={
'input': { 'input': {
'id': id, 'id': id,
'name': class_name 'name': class_name

View File

@ -0,0 +1,171 @@
from django.test import TestCase
from graphene import Context
from graphene.test import Client
from graphql_relay import to_global_id
from api.schema import schema
from api.utils import get_graphql_mutation
from core.factories import TeacherFactory, UserFactory
from users.factories import TeamFactory
from users.models import Team
ME_QUERY = """
query MeQuery {
me {
team {
name
code
}
}
}
"""
CREATE_TEAM_MUTATION = get_graphql_mutation('createTeam.gql')
JOIN_TEAM_MUTATION = get_graphql_mutation('joinTeam.gql')
UPDATE_TEAM_MUTATION = get_graphql_mutation('updateTeam.gql')
class TeamTest(TestCase):
def setUp(self):
self.client = Client(schema=schema)
self.team_name = 'Fiterativ'
self.code = 'AAAA'
self.team = TeamFactory(name=self.team_name, code=self.code)
self.team_id = to_global_id('TeamNode', self.team.id)
self.user = TeacherFactory(username='ueli', team=self.team)
self.student = UserFactory(username='fritzli')
self.context = Context(user=self.user)
@staticmethod
def get_team(result):
return result.get('data').get('me').get('team')
def no_error(self, result):
self.assertIsNone(result.get('errors'))
def permission_error(self, result):
errors = result.get('errors')
self.assertIsNotNone(errors)
self.assertIn('Permission denied', errors[0]['message'])
def check_me(self, context, name, code):
result = self.client.execute(ME_QUERY, context=context)
self.no_error(result)
team = self.get_team(result)
self.assertEqual(team.get('name'), name)
self.assertEqual(team.get('code'), code)
def join_team(self, code, context):
variables = {
"input": {
"code": code
}
}
result = self.client.execute(JOIN_TEAM_MUTATION, variables=variables, context=context)
self.no_error(result)
success = result['data']['joinTeam']['success']
self.assertTrue(success)
def test_team_query(self):
self.check_me(self.context, self.team_name, self.code)
def test_join_team_mutation(self):
teacher = TeacherFactory(username='hansli')
context = Context(user=teacher)
self.join_team(self.code, context)
self.check_me(context, self.team_name, self.code)
def test_join_second_team_mutation(self):
teacher = TeacherFactory(username='peterli')
context = Context(user=teacher)
second_team = TeamFactory()
self.join_team(self.code, context)
self.check_me(context, self.team_name, self.code)
self.join_team(second_team.code, context)
self.check_me(context, second_team.name, second_team.code)
def test_create_team_mutation(self):
team_name = "Dunder Mifflin"
variables = {
"input": {
"name": team_name
}
}
result = self.client.execute(CREATE_TEAM_MUTATION, context=self.context, variables=variables)
self.no_error(result)
create_team = result.get('data').get('createTeam')
team = create_team.get('team')
success = create_team.get('success')
self.assertTrue(success)
self.assertEqual(team.get('name'), team_name)
self.assertIsNotNone(team.get('code'))
def test_update_team_name(self):
new_name = 'Team Böhmermann'
variables = {
"input": {
"name": new_name,
"id": self.team_id
}
}
result = self.client.execute(UPDATE_TEAM_MUTATION, context=self.context, variables=variables)
self.no_error(result)
update_team = result.get('data').get('updateTeam')
team = update_team.get('team')
success = update_team.get('success')
self.assertTrue(success)
self.assertEqual(team.get('name'), new_name)
def test_update_team_name_as_student_fails(self):
context = Context(user=self.student)
variables = {
"input": {
"name": 'Not gonna happen',
"id": self.team_id
}
}
result = self.client.execute(UPDATE_TEAM_MUTATION, context=context, variables=variables)
self.permission_error(result)
def test_update_team_name_not_in_team_fails(self):
schelm = TeacherFactory(username='schelm')
context = Context(user=schelm)
team = Team.objects.get(pk=self.team.pk)
old_name = team.name
self.assertFalse(self.team.members.filter(pk=schelm.pk).exists())
variables = {
"input": {
"name": 'Not gonna happen',
"id": self.team_id
}
}
result = self.client.execute(UPDATE_TEAM_MUTATION, context=context, variables=variables)
self.permission_error(result)
team = Team.objects.get(pk=self.team.pk)
self.assertEqual(team.name, old_name)
def test_create_team_mutation_as_student_fails(self):
self.assertEqual(Team.objects.count(), 1)
variables = {
"input": {
"name": 'Nope'
}
}
context = Context(user=self.student)
result = self.client.execute(CREATE_TEAM_MUTATION, context=context, variables=variables)
self.permission_error(result)
self.assertEqual(Team.objects.count(), 1)
def test_join_create_team_mutation_as_student_fails(self):
self.assertIsNone(self.student.team)
variables = {
"input": {
"code": 'ZZZZ'
}
}
context = Context(user=self.student)
result = self.client.execute(JOIN_TEAM_MUTATION, context=context, variables=variables)
self.permission_error(result)
self.assertIsNone(self.student.team)