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,
"__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');
describe('New student', () => {
it('shows "Enter Code" page and adds the user to a class', () => {
before(() => {
cy.server();
cy.mockGraphql({
schema: schema,
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
});
});
});
it('shows "Enter Code" page and adds the user to a class', () => {
cy.apolloLogin('hansli', 'test');
const __typename = 'SchoolClassNode';
@ -26,9 +29,9 @@ describe('New student', () => {
schoolClass: {
id,
name,
__typename
}
}
__typename,
},
},
},
MySchoolClassQuery: {
me: {
@ -37,21 +40,21 @@ describe('New student', () => {
__typename,
name,
id,
members: []
}
}
members: [],
},
},
},
...mockUpdateOnboardingProgress()
}
...mockUpdateOnboardingProgress(),
},
});
cy.visit('/');
cy.get('[data-cy=join-class-title]').should('contain', 'Einer Klasse beitreten');
cy.get('[data-cy=input-class-code]').type('XXXX');
cy.get('[data-cy=join-class]').click();
cy.get('[data-cy=join-form-title]').should('contain', 'Einer Klasse beitreten');
cy.get('[data-cy=input-form-code]').type('XXXX');
cy.get('[data-cy=join-form-confirm]').click();
cy.skipOnboarding();
cy.get('[data-cy=user-widget-avatar]').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';
const schema = require('../../fixtures/schema.json');
const me = require('../../fixtures/me.join-class.json');
describe('Onboarding', () => {
beforeEach(() => {
cy.server();
cy.mockGraphql({
schema: schema,
cy.task('getSchema').then(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', () => {
beforeEach(() => {
@ -7,100 +98,11 @@ describe('Project Entry', () => {
cy.fakeLogin('rahel.cueni', 'test');
cy.server();
cy.mockGraphql({
schema: schema,
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'
}
}
}
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
operations,
});
});
});

View File

@ -1,4 +1,3 @@
const schema = require('../../fixtures/schema.json');
const me = require('../../fixtures/me.join-class.json');
const selectedClass = require('../../fixtures/selected-school-class.json');
@ -6,8 +5,10 @@ describe('Class Management', () => {
beforeEach(() => {
cy.server();
cy.mockGraphql({
schema: schema,
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
});
});
cy.viewport('macbook-15');
@ -104,7 +105,7 @@ describe('Class Management', () => {
});
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=add-to-class]').should('have.length', 0);
});
@ -187,8 +188,10 @@ describe('Teacher Class Management', () => {
beforeEach(() => {
cy.server();
cy.mockGraphql({
schema: schema,
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
});
});
cy.viewport('macbook-15');
@ -229,10 +232,10 @@ describe('Teacher Class Management', () => {
cy.visit('/me/my-class');
cy.get('[data-cy=edit-class-name-link]').click();
cy.get('[data-cy=edit-class-name-input] input').type('{selectall}{backspace}').type(className);
cy.get('[data-cy=edit-group-name-link]').click();
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=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

View File

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

View File

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

View File

@ -11,7 +11,6 @@
:is="showModal"
v-if="showModal"/>
<component :is="layout"/>
</div>
</template>
@ -32,6 +31,7 @@
import NewNoteWizard from '@/components/notes/NewNoteWizard';
import EditNoteWizard from '@/components/notes/EditNoteWizard';
import EditClassNameWizard from '@/components/school-class/EditClassNameWizard';
import EditTeamNameWizard from '@/components/profile/EditTeamNameWizard';
import FullscreenImage from '@/components/FullscreenImage';
import FullscreenInfographic from '@/components/FullscreenInfographic';
import FullscreenVideo from '@/components/FullscreenVideo';
@ -60,6 +60,7 @@
NewNoteWizard,
EditNoteWizard,
EditClassNameWizard,
EditTeamNameWizard,
FullscreenImage,
FullscreenInfographic,
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>
<a
class="edit-class-name"
data-cy="edit-class-name-link"
class="edit-group-name"
data-cy="edit-group-name-link"
@click="$emit('edit')">
<pen-icon class="edit-class-name__icon"/>
<pen-icon class="edit-group-name__icon"/>
</a>
</template>
@ -18,9 +18,9 @@
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "~styles/_variables.scss";
.edit-class-name {
.edit-group-name {
&__icon {
width: 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
</router-link>
</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 class="profile-sidebar__section">
<div class="profile-sidebar__item">
@ -56,31 +65,40 @@
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 {MY_TEAM} from '@/router/me.names';
export default {
mixins: [sidebarMixin],
mixins: [sidebar, me],
components: {
LogoutWidget,
ClassSelectionWidget,
ProfileWidget,
Cross
Cross,
},
computed: {
myTeamPage() {
return {
name: MY_TEAM,
};
},
},
methods: {
close() {
this.closeSidebar('profile');
}
},
},
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
@import "~styles/helpers";
$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>
<modal
:hide-header="false"
:small="true"
title="Hello">
<h4 slot="header">Klasse bearbeiten</h4>
<modal-input
: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>
<edit-name-wizard
:name="name"
type="Klasse"
@input="name = $event"
@cancel="hideModal"
@save="save" />
</template>
<script>
import Modal from '@/components/Modal';
import ModalInput from '@/components/ModalInput';
import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql';
import UPDATE_SCHOOL_CLASS_MUTATION from '@/graphql/gql/mutations/updateSchoolClass.gql';
import EditNameWizard from '@/components/profile/EditNameWizard';
export default {
components: {
Modal,
ModalInput
EditNameWizard,
},
data() {
@ -63,9 +44,9 @@
store.writeQuery({query, data});
}
});
this.hide();
this.hideModal();
},
hide() {
hideModal() {
this.$store.dispatch('hideModal');
}
},

View File

@ -2,6 +2,16 @@
query MeQuery {
me {
...UserParts
team {
name
code
id
members {
firstName
lastName
id
}
}
isTeacher
permissions
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: [],
schoolClasses: [],
isTeacher: false,
team: null
},
showPopover: false,
};

View File

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

View File

@ -1,40 +1,13 @@
<template>
<div>
<h1 data-cy="join-class-title">Einer Klasse beitreten</h1>
<div>
<div class="skillboxform-input">
<label
for="join-code"
class="skillboxform-input__label">Zugangscode eingeben</label>
<input
:class="{'skillboxform-input__input--error': error}"
: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>
<join-form
:value="code"
:error="error"
title="Einer Klasse beitreten"
ok-text="Klasse beitreten"
cancel-text="Abmelden"
@input="updateCode"
@cancel="logout"
@confirm="joinClass"/>
</template>
<script>
@ -44,12 +17,15 @@
import addSchoolClass from '@/mixins/add-school-class';
import logout from '@/mixins/logout';
import JoinForm from '@/components/profile/JoinForm';
export default {
mixins: [addSchoolClass, logout],
components: {JoinForm},
data: () => ({
code: '',
error: ''
error: '',
}),
methods: {
@ -60,18 +36,18 @@
joinClass(code) {
let self = this;
this.$apollo.mutate({
mutation: JOIN_CLASS_MUTATION,
variables: {
input: {
code
}
},
update(store, {data: {joinClass: {schoolClass}}}) {
self.addSchoolClass(store, schoolClass);
self.$router.push({name: 'my-class'});
},
refetchQueries: [{query: MY_SCHOOL_CLASS_QUERY}]
})
mutation: JOIN_CLASS_MUTATION,
variables: {
input: {
code,
},
},
update(store, {data: {joinClass: {schoolClass}}}) {
self.addSchoolClass(store, schoolClass);
self.$router.push({name: 'my-class'});
},
refetchQueries: [{query: MY_SCHOOL_CLASS_QUERY}],
})
.then(() => {
})
@ -83,7 +59,7 @@
this.error = 'Dieser Zugangscode ist nicht gültig.';
}
});
}
}
},
},
};
</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>
<div class="my-class">
<h1
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
<group-list
:name="me.selectedClass.name"
:members="me.selectedClass.members"
:teacher="me.isTeacher"
:id="me.selectedClass.id"
:active-members="me.selectedClass.members.filter(member => member.active)"
:inactive-members="me.selectedClass.members.filter(member => !member.active)"
:show-code="me.isTeacher"
:show-code-route="showCodeRoute"
:can-edit="me.isTeacher"
title="Klassenliste"
class="my-class__class"
@remove="remove"
@add="add"
@edit="editClassName"
/>
</div>
</template>
<script>
import ClassList from '@/components/profile/ClassList';
import GroupList from '@/components/profile/GroupList';
import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql';
import ADD_REMOVE_MEMBER_MUTATION from '@/graphql/gql/mutations/addRemoveMember.gql';
import selectedClassMixin from '@/mixins/selected-class';
import {SHOW_SCHOOL_CLASS_CODE} from '@/router/me.names';
export default {
mixins: [selectedClassMixin],
components: {
ClassList
GroupList,
},
data() {
return {
showCodeRoute: {
name: SHOW_SCHOOL_CLASS_CODE,
},
};
},
methods: {
@ -42,8 +46,8 @@
input: {
member: member.id,
schoolClass: this.me.selectedClass.id,
active
}
active,
},
},
update(store, {data: {addRemoveMember: {success}}}) {
if (success) {
@ -57,7 +61,7 @@
];
store.writeQuery({query, data});
}
}
},
});
},
add(member) {
@ -65,22 +69,25 @@
},
remove(member) {
this.$modal.open('deactivate-person', {
myself: member.id === this.me.id,
name: `${member.firstName} ${member.lastName}`,
className: this.me.selectedClass.name,
})
.then(() => {
this.changeMember(member, false);
})
.catch(() => {
});
myself: member.id === this.me.id,
name: `${member.firstName} ${member.lastName}`,
className: this.me.selectedClass.name,
})
.then(() => {
this.changeMember(member, false);
})
.catch(() => {
});
},
editClassName() {
this.$store.dispatch('editClassName');
}
},
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "~styles/helpers";
.my-class {
display: grid;

View File

@ -5,9 +5,7 @@
</template>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_functions.scss";
@import "@/styles/_mixins.scss";
@import "~styles/helpers";
.profile {
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 start from '@/pages/start';
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 surveyPage from '@/pages/survey';
import styleGuidePage from '@/pages/styleguide';
@ -18,14 +14,12 @@ import emailVerification from '@/pages/email-verification';
import licenseActivation from '@/pages/license-activation';
import forgotPassword from '@/pages/forgot-password';
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 moduleRoutes from './module.routes';
import portfolioRoutes from './portfolio.routes';
import onboardingRoutes from './onboarding.routes';
import meRoutes from './me.routes';
import authRoutes from './auth.routes';
import roomRoutes from './room.routes';
@ -50,25 +44,8 @@ const routes = [
{path: '/submission/:id', name: 'submission', component: submission, meta: {layout: 'simple'}},
...portfolioRoutes,
{path: '/topic/:topicSlug', name: 'topic', component: topic, alias: '/book/topic/:topicSlug'},
{
path: '/me',
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'}},
...meRoutes,
{path: '/join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}},
{
path: '/survey/:id',
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) {
commit('setEditModule', payload);
},
editClassName({dispatch}, payload) {
editClassName({dispatch}) {
dispatch('showModal', 'edit-class-name-wizard');
},
editTeamName({dispatch}) {
dispatch('showModal', 'edit-team-name-wizard');
},
deactivateUser({commit, dispatch}, payload) {
commit('setModulePayload', payload);
return dispatch('showModal', 'deactivate-person');

View File

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

View File

@ -10,6 +10,8 @@ from faker import Faker
from wagtail.documents.models import get_document_model
from wagtail.images import get_image_model
from users.models import Role, UserRole
fake = Faker('de_CH')
@ -48,7 +50,7 @@ class DummyImageFactory(factory.DjangoModelFactory):
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = get_user_model()
django_get_or_create = ('username', )
django_get_or_create = ('username',)
first_name = factory.LazyAttribute(lambda x: fake.first_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):
self.set_password('test')
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:
o.write(str(schema))
with open(public_schema_path, 'w') as o:
o.write(str(schema_public))
# with open(public_schema_path, 'w') as o:
# o.write(str(schema_public))

View File

@ -1,7 +1,8 @@
import random
import factory
from users.models import SchoolClass, SchoolClassMember, License
from users.models import SchoolClass, SchoolClassMember, License, Team
class_types = ['DA', 'KV', 'INF', 'EE']
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)
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 Meta:
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 re
import string
from datetime import datetime
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Permission
@ -25,6 +25,7 @@ class User(AbstractUser):
hep_group_id = models.PositiveIntegerField(null=True, blank=False)
license_expiry_date = models.DateField(blank=False, null=True, default=None)
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
autocomplete_search_field = 'username'
@ -110,14 +111,43 @@ class User(AbstractUser):
return self.get_full_name()
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)
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,
through='users.SchoolClassMember')
code = models.CharField('Code zum Beitreten', blank=True, null=True, max_length=10, unique=True, default=None)
class Meta:
verbose_name = 'Schulklasse'
@ -162,17 +192,6 @@ class SchoolClass(models.Model):
def get_teacher(self):
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):
if self.code == '': # '' can't be unique, so we null it
self.code = None

View File

@ -6,11 +6,13 @@ from graphene import relay
from graphql_relay import from_global_id
from api.utils import get_object
from core.logger import get_logger
from users.inputs import PasswordUpdateInput
from users.models import SchoolClass, UserSetting, User, SchoolClassMember
from users.schema import SchoolClassNode
from users.models import SchoolClass, SchoolClassMember, Team
from users.schema import SchoolClassNode, TeamNode
from users.serializers import PasswordSerialzer, AvatarUrlSerializer
logger = get_logger(__name__)
class CodeNotFoundException(Exception):
pass
@ -25,6 +27,18 @@ class UpdateError(graphene.ObjectType):
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 Input:
password_input = graphene.Argument(PasswordUpdateInput)
@ -132,7 +146,7 @@ class JoinClass(relay.ClientIDMutation):
return cls(success=True, school_class=school_class)
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):
@ -154,7 +168,7 @@ class AddRemoveMember(relay.ClientIDMutation):
school_class = get_object(SchoolClass, school_class_id)
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.active = active
@ -163,7 +177,7 @@ class AddRemoveMember(relay.ClientIDMutation):
return cls(success=True)
class UpdateSchoolClass(relay.ClientIDMutation):
class UpdateSchoolClass(TeacherOnlyMutation):
class Input:
id = graphene.ID(required=True)
name = graphene.String()
@ -177,9 +191,7 @@ class UpdateSchoolClass(relay.ClientIDMutation):
name = kwargs.get('name')
user = info.context.user
if 'users.can_manage_school_class_content' not in user.get_role_permissions():
raise PermissionError()
# todo: only allow to edit your own school class
school_class = get_object(SchoolClass, id)
school_class.name = name
school_class.save()
@ -187,7 +199,7 @@ class UpdateSchoolClass(relay.ClientIDMutation):
return cls(success=True, school_class=school_class)
class CreateSchoolClass(relay.ClientIDMutation):
class CreateSchoolClass(TeacherOnlyMutation):
class Input:
name = graphene.String()
@ -197,18 +209,78 @@ class CreateSchoolClass(relay.ClientIDMutation):
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
name = kwargs.get('name')
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)
SchoolClassMember.objects.create(school_class=school_class, user=user)
user.set_selected_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):
success = graphene.Boolean()
@ -231,3 +303,6 @@ class ProfileMutations:
update_school_class = UpdateSchoolClass.Field()
create_school_class = CreateSchoolClass.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
from django.db.models import Q
from django.utils.dateformat import format
from django_filters import FilterSet, OrderingFilter
from graphene import relay, ObjectType
from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField
from django.utils.dateformat import format
from graphql_relay import to_global_id
from basicknowledge.models import BasicKnowledge
from basicknowledge.queries import InstrumentNode
from books.models import Module, RecentModule
from books.models import Module
from books.schema.queries import ModuleNode
from users.models import User, SchoolClass, SchoolClassMember
from users.models import User, SchoolClass, SchoolClassMember, Team
class SchoolClassNode(DjangoObjectType):
@ -40,6 +40,22 @@ class SchoolClassNode(DjangoObjectType):
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 Meta:
model = Module
@ -60,13 +76,14 @@ class UserNode(DjangoObjectType):
is_teacher = graphene.Boolean()
old_classes = DjangoFilterConnectionField(SchoolClassNode)
recent_modules = DjangoFilterConnectionField(ModuleNode, filterset_class=RecentModuleFilter)
team = graphene.Field(TeamNode)
class Meta:
model = User
filter_fields = ['username', 'email']
only_fields = ['username', 'email', 'first_name', 'last_name', 'school_classes', 'last_module',
'last_topic', 'avatar_url',
'selected_class', 'expiry_date', 'onboarding_visited']
'selected_class', 'expiry_date', 'onboarding_visited', 'team']
interfaces = (relay.Node,)
def resolve_pk(self, info, **kwargs):
@ -100,6 +117,9 @@ class UserNode(DjangoObjectType):
# see https://docs.graphene-python.org/projects/django/en/latest/filtering/
return RecentModuleFilter(kwargs).qs.filter(recent_modules__user=self)
def resolve_team(self, info, **kwargs):
return self.team
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 graphene import Context
from graphene.test import Client
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.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
UPDATE_SCHOOL_CLASS_MUTATION = get_graphql_mutation('updateSchoolClass.gql')
class SchoolClassesTest(TestCase):
@ -53,6 +37,7 @@ class SchoolClassesTest(TestCase):
class_name = SchoolClass.generate_default_group_name(user=user)
self.assertEqual(f'{self.prefix} {user.pk}', class_name)
class ModifySchoolClassTest(TestCase):
def setUp(self):
create_users()
@ -72,9 +57,8 @@ class ModifySchoolClassTest(TestCase):
school_class = SchoolClass.objects.get(name='skillbox')
self.assertEqual(school_class.name, 'skillbox')
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': {
'id': id,
'name': class_name
@ -85,15 +69,30 @@ class ModifySchoolClassTest(TestCase):
school_class = get_object(SchoolClass, id)
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):
class_name = 'Nanana'
school_class = SchoolClass.objects.get(name='skillbox')
self.assertEqual(school_class.name, 'skillbox')
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': {
'id': id,
'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)