Merged in feature/active-and-inactive-users-in-class (pull request #49)

Feature/active and inactive users in class

Approved-by: Christian Cueni
This commit is contained in:
Ramon Wenger 2020-03-10 15:01:53 +00:00
commit 1da6a00d40
35 changed files with 16950 additions and 15829 deletions

View File

@ -7,6 +7,7 @@
"firstName": "Rahel", "firstName": "Rahel",
"lastName": "Cueni", "lastName": "Cueni",
"avatarUrl": "", "avatarUrl": "",
"isTeacher": false,
"lastModule": { "lastModule": {
"id": "TW9kdWxlTm9kZToxNw==", "id": "TW9kdWxlTm9kZToxNw==",
"slug": "lohn-und-budget", "slug": "lohn-und-budget",

View File

@ -408,6 +408,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "node",
"description": "The ID of the object",
"args": [
{
"name": "id",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "INTERFACE",
"name": "Node",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "book", "name": "book",
"description": "The ID of the object", "description": "The ID of the object",
@ -701,71 +728,11 @@
"ofType": null "ofType": null
}, },
"defaultValue": null "defaultValue": null
},
{
"name": "slug",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "slug_Icontains",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "slug_In",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "title",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "title_Icontains",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "title_In",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
} }
], ],
"type": { "type": {
"kind": "OBJECT", "kind": "OBJECT",
"name": "TopicNodeConnection", "name": "TopicConnection",
"ofType": null "ofType": null
}, },
"isDeprecated": false, "isDeprecated": false,
@ -1573,33 +1540,6 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "node",
"description": "The ID of the object",
"args": [
{
"name": "id",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "INTERFACE",
"name": "Node",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "_debug", "name": "_debug",
"description": null, "description": null,
@ -3125,6 +3065,81 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "isTeacher",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "oldClasses",
"description": null,
"args": [
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "name",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "SchoolClassNodeConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"inputFields": null, "inputFields": null,
@ -3362,6 +3377,119 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "moduleSet",
"description": null,
"args": [
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "slug",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "slug_Icontains",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "slug_In",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "title",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "title_Icontains",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "title_In",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ModuleNodeConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "hiddenContentBlocks", "name": "hiddenContentBlocks",
"description": null, "description": null,
@ -3864,6 +3992,22 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "members",
"description": null,
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ClassMemberNode",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"inputFields": null, "inputFields": null,
@ -3973,6 +4117,92 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "ModuleNodeConnection",
"description": null,
"fields": [
{
"name": "pageInfo",
"description": "Pagination data for this connection.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "edges",
"description": "Contains the nodes in this connection.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ModuleNodeEdge",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ModuleNodeEdge",
"description": "A Relay edge containing a `ModuleNode` and its cursor.",
"fields": [
{
"name": "node",
"description": "The item at the end of the edge",
"args": [],
"type": {
"kind": "OBJECT",
"name": "ModuleNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "cursor",
"description": "A cursor for use in pagination",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "ContentBlockNodeConnection", "name": "ContentBlockNodeConnection",
@ -6617,6 +6847,89 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "ClassMemberNode",
"description": "We need to build this ourselves, because we want the active property on the node, because providing it on the\nConnection or Edge for a UserNodeConnection is difficult.",
"fields": [
{
"name": "user",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "UserNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "active",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "firstName",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastName",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isTeacher",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "StudentSubmissionNode", "name": "StudentSubmissionNode",
@ -7239,92 +7552,6 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "ModuleNodeConnection",
"description": null,
"fields": [
{
"name": "pageInfo",
"description": "Pagination data for this connection.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "edges",
"description": "Contains the nodes in this connection.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ModuleNodeEdge",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ModuleNodeEdge",
"description": "A Relay edge containing a `ModuleNode` and its cursor.",
"fields": [
{
"name": "node",
"description": "The item at the end of the edge",
"args": [],
"type": {
"kind": "OBJECT",
"name": "ModuleNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "cursor",
"description": "A cursor for use in pagination",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "StudentSubmissionNodeConnection", "name": "StudentSubmissionNodeConnection",
@ -8825,6 +9052,92 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "TopicConnection",
"description": null,
"fields": [
{
"name": "pageInfo",
"description": "Pagination data for this connection.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "edges",
"description": "Contains the nodes in this connection.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TopicEdge",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TopicEdge",
"description": "A Relay edge containing a `Topic` and its cursor.",
"fields": [
{
"name": "node",
"description": "The item at the end of the edge",
"args": [],
"type": {
"kind": "OBJECT",
"name": "TopicNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "cursor",
"description": "A cursor for use in pagination",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "DjangoDebug", "name": "DjangoDebug",
@ -9397,6 +9710,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "addRemoveMember",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AddRemoveMemberInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "AddRemoveMemberPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "addProject", "name": "addProject",
"description": null, "description": null,
@ -11628,6 +11968,104 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "AddRemoveMemberPayload",
"description": null,
"fields": [
{
"name": "success",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clientMutationId",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AddRemoveMemberInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "member",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "schoolClass",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "active",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "AddProjectPayload", "name": "AddProjectPayload",

View File

@ -0,0 +1,30 @@
{
"me": {
"id": "VXNlck5vZGU6Mg==",
"isTeacher": false,
"selectedClass": {
"id": "U2Nob29sQ2xhc3NOb2RlOjE=",
"name": "Moordale",
"members": [
{
"id": "VXNlck5vZGU6Mw==",
"firstName": "Otis",
"lastName": "Milburn",
"isTeacher": false,
"active": true,
"__typename": "ClassMemberNode"
},
{
"id": "VXNlck5vZGU6NA==",
"firstName": "Maeve",
"lastName": "Wiley",
"isTeacher": false,
"active": true,
"__typename": "ClassMemberNode"
}
],
"__typename": "SchoolClassNode"
},
"__typename": "UserNode"
}
}

View File

@ -1,7 +1,8 @@
const schema = require('../fixtures/schema.json'); 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');
describe('Join Class', () => { describe('Class Management', () => {
beforeEach(() => { beforeEach(() => {
cy.server(); cy.server();
@ -31,14 +32,15 @@ describe('Join Class', () => {
} }
}); });
cy.visit('/me/profile'); cy.visit('/me/profile');
cy.get('[data-cy=header-user-widget]').within(() => { cy.get('[data-cy=header-user-widget]').within(() => {
cy.get('[data-cy=user-widget-avatar]').click(); cy.get('[data-cy=user-widget-avatar]').click();
}); });
cy.get('[data-cy=class-selection]').click(); cy.get('[data-cy=class-selection]').click();
cy.get('[data-cy=class-selection-entry]').should('have.length', 1); cy.get('[data-cy=class-selection-entry]').should('have.length', 1);
cy.get('[data-cy=class-selection]').click();
cy.get('[data-cy=join-class-link]').click(); cy.get('[data-cy=join-class-link]').click();
@ -51,5 +53,94 @@ describe('Join Class', () => {
cy.get('[data-cy=class-selection]').click(); cy.get('[data-cy=class-selection]').click();
cy.get('[data-cy=class-selection-entry]').should('have.length', 2); cy.get('[data-cy=class-selection-entry]').should('have.length', 2);
}) });
})
it('should not be able to leave class', () => {
cy.mockGraphqlOps({
operations: {
MeQuery: me,
MySchoolClassQuery: selectedClass
}
});
cy.visit('/me/my-class');
cy.get('[data-cy=school-class-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);
});
it('should leave and re-join class', () => {
const teacher = {
me: {
...me.me,
isTeacher: true
}
};
const teacherSelectedClass = {
me: {
...selectedClass.me,
isTeacher: true
}
};
cy.mockGraphqlOps({
operations: {
MeQuery: teacher,
AddRemoveMember: {
addRemoveMember: {
success: true
}
},
MySchoolClassQuery: teacherSelectedClass
}
});
cy.visit('/me/my-class');
cy.get('[data-cy=active-class-members-list]').within(() => {
cy.get('[data-cy=school-class-member]').should('have.length', 2)
});
cy.get('[data-cy=inactive-class-members-list]').within(() => {
cy.get('[data-cy=school-class-member]').should('have.length', 0)
});
cy.get('[data-cy=remove-from-class]').first().click();
cy.get('[data-cy=active-class-members-list]').within(() => {
cy.get('[data-cy=school-class-member]').should('have.length', 1)
});
cy.get('[data-cy=inactive-class-members-list]').within(() => {
cy.get('[data-cy=school-class-member]').should('have.length', 1)
});
cy.get('[data-cy=add-to-class]').first().click();
cy.get('[data-cy=active-class-members-list]').within(() => {
cy.get('[data-cy=school-class-member]').should('have.length', 2)
});
cy.get('[data-cy=inactive-class-members-list]').within(() => {
cy.get('[data-cy=school-class-member]').should('have.length', 0)
});
});
it.only('should display old classes', () => {
let oldClasses = me.me.schoolClasses;
let OldClassesQuery = {
me: {
...me.me,
oldClasses
},
};
debugger;
cy.mockGraphqlOps({
operations: {
MeQuery: me,
OldClassesQuery
}
});
cy.visit('/me/old-classes');
cy.get('[data-cy=old-class-item]').should('have.length', 1);
});
});

View File

@ -27,7 +27,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: $color-white; background-color: $color-white;
padding: 20px; padding: 0;
z-index: 100; z-index: 100;
@include widget-shadow; @include widget-shadow;
@ -43,6 +43,9 @@
display: grid; display: grid;
&__link { &__link {
cursor: pointer;
padding: 0 $medium-spacing;
& > a { & > a {
display: inline-block; display: inline-block;
color: $color-silver-dark; color: $color-silver-dark;
@ -55,6 +58,7 @@
&--large { &--large {
line-height: 40px; line-height: 40px;
padding: $small-spacing $medium-spacing;
& > a, & { & > a, & {
@include small-text; @include small-text;
} }
@ -64,5 +68,9 @@
font-weight: 600; font-weight: 600;
} }
} }
&__divider {
border-top: 1px solid $color-silver-dark;
}
} }
</style> </style>

View File

@ -0,0 +1,90 @@
<template>
<div class="school-class">
<h2 class="school-class__name">{{name}}</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
class="simple-list__item member-item"
data-cy="school-class-member"
v-for="member in activeMembers"
: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="remove-from-class"
v-if="teacher"
@click="$emit('remove', member)">Deaktivieren</a>
</li>
</ul>
<h3 class="school-class__inactive-heading">Deaktivierte Benutzer</h3>
<ul data-cy="inactive-class-members-list" class="simple-list simple-list--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>
</div>
</div>
</template>
<script>
export default {
props: ['members', 'name', 'teacher'],
methods: {
fullName(member) {
return `${member.firstName} ${member.lastName}`;
},
role({isTeacher}) {
return isTeacher ? 'Lehrperson' : 'Schüler';
}
},
computed: {
activeMembers() {
return this.members.filter(member => member.active)
},
inactiveMembers() {
return this.members.filter(member => !member.active)
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.school-class {
&__inactive-heading {
@include heading-4;
margin-bottom: $small-spacing;
}
}
.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,59 +0,0 @@
<template>
<div class="schoolclass">
<h2 class="schoolclass__name">{{name}}</h2>
<div class="schoolclass__members schoolclass-members">
<ul class="schoolclass-members__list members-list">
<li v-for="user in users" :key="user.id" class="members-list__item">
<p class="member-item"><span class="member-item__name">{{fullName(user)}}</span> <span class="member-item__role">{{role(user)}}</span></p>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: ['users', 'name'],
methods: {
fullName (user) {
return `${user.firstName} ${user.lastName}`;
},
role ({permissions}) {
return permissions.indexOf('users.can_manage_school_class_content') > -1 ? 'Lehrperson' : '';
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
$height: 52px;
.members-list {
&__item {
line-height: $height;
height: $height;
border-bottom: 1px solid $color-silver-dark;
}
}
.member-item {
line-height: $height;
height: $height;
display: flex;
flex-direction: row;
justify-content: space-between;
&__name {
font-family: $sans-serif-font-family;
font-weight: $font-weight-bold;
}
&__role {
padding-right: $medium-spacing;
}
}
</style>

View File

@ -2,7 +2,8 @@
<div class="class-selection" v-if="currentClassSelection"> <div class="class-selection" v-if="currentClassSelection">
<div data-cy="class-selection" class="class-selection__selected-class selected-class" <div data-cy="class-selection" class="class-selection__selected-class selected-class"
@click="showPopover = !showPopover"> @click="showPopover = !showPopover">
<current-class class="selected-class__text"></current-class> <chevron-down class="selected-class__dropdown-icon"></chevron-down> <current-class class="selected-class__text"></current-class>
<chevron-down class="selected-class__dropdown-icon"></chevron-down>
</div> </div>
<widget-popover v-if="showPopover" <widget-popover v-if="showPopover"
@hide-me="showPopover = false" @hide-me="showPopover = false"
@ -13,9 +14,13 @@
:key="schoolClass.id" :key="schoolClass.id"
:label="schoolClass.name" :label="schoolClass.name"
:item="schoolClass" :item="schoolClass"
@click="updateFilter(schoolClass)"> @click="updateSelectedClass(schoolClass)">
{{schoolClass.name}} {{schoolClass.name}}
</li> </li>
<li class="popover-links__link popover-links__link--large popover-links__divider">Klasse erfassen</li>
<li class="popover-links__link popover-links__link--large popover-links__divider">
<router-link :to="{name: 'old-classes'}">Alte Klassen anzeigen</router-link>
</li>
</widget-popover> </widget-popover>
</div> </div>
</template> </template>
@ -24,8 +29,10 @@
import WidgetPopover from '@/components/WidgetPopover'; import WidgetPopover from '@/components/WidgetPopover';
import ChevronDown from '@/components/icons/ChevronDown'; import ChevronDown from '@/components/icons/ChevronDown';
import CurrentClass from '@/components/school-class/CurrentClass'; import CurrentClass from '@/components/school-class/CurrentClass';
import ME_QUERY from '@/graphql/gql/meQuery.gql'; import ME_QUERY from '@/graphql/gql/meQuery.gql';
import UPDATE_USER_SETTING from '@/graphql/gql/mutations/updateUserSetting.gql';
import updateSelectedClassMixin from '@/mixins/updateSelectedClass';
export default { export default {
components: { components: {
@ -41,6 +48,8 @@
} }
}, },
mixins: [updateSelectedClassMixin],
apollo: { apollo: {
me: { me: {
query: ME_QUERY, query: ME_QUERY,
@ -67,22 +76,8 @@
}, },
methods: { methods: {
updateFilter(selectedClass) { updateSelectedClassAndHidePopover(selectedClass) {
this.$apollo.mutate({ this.updateSelectedClass(selectedClass);
mutation: UPDATE_USER_SETTING,
variables: {
input: {
id: selectedClass.id
}
},
update(store, data) {
let meData = store.readQuery({query: ME_QUERY});
meData.me.selectedClass = selectedClass;
store.writeQuery({query: ME_QUERY, data: meData});
}
}).catch((error) => {
console.log('fail', error)
});
this.showPopover = false; this.showPopover = false;
} }
}, },
@ -139,9 +134,4 @@
fill: $color-brand; fill: $color-brand;
} }
} }
.popover-links__link {
cursor: pointer;
}
</style> </style>

View File

@ -0,0 +1,5 @@
mutation AddRemoveMember($input: AddRemoveMemberInput!) {
addRemoveMember(input: $input) {
success
}
}

View File

@ -1,18 +1,16 @@
query { query MySchoolClassQuery {
me { me {
id id
isTeacher
selectedClass { selectedClass {
id id
name name
users { members {
edges {
node {
id id
firstName firstName
lastName lastName
permissions isTeacher
} active
}
} }
} }
} }

View File

@ -0,0 +1,13 @@
query OldClassesQuery {
me {
id
oldClasses {
edges {
node {
id
name
}
}
}
}
}

View File

@ -0,0 +1,24 @@
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import UPDATE_USER_SETTING from '@/graphql/gql/mutations/updateUserSetting.gql';
export default {
methods: {
updateSelectedClass(selectedClass) {
return this.$apollo.mutate({
mutation: UPDATE_USER_SETTING,
variables: {
input: {
id: selectedClass.id
}
},
update(store, data) {
let meData = store.readQuery({query: ME_QUERY});
meData.me.selectedClass = selectedClass;
store.writeQuery({query: ME_QUERY, data: meData});
}
}).catch((error) => {
console.warn('failed to update selected class', error)
});
}
},
}

View File

@ -1,33 +1,79 @@
<template> <template>
<div class="my-class"> <div class="my-class">
<h1 class="my-class__header" data-cy="class-list-title">Klassenliste</h1> <h1 class="my-class__header" data-cy="class-list-title">Klassenliste</h1>
<classlist v-bind="selectedClass" class="my-class__class"></classlist> <class-list
class="my-class__class"
:name="me.selectedClass.name"
:members="me.selectedClass.members"
:teacher="me.isTeacher"
@remove="remove"
@add="add"
></class-list>
</div> </div>
</template> </template>
<script> <script>
import ClassList from '@/components/profile/ClassList';
import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql'; import MY_SCHOOL_CLASS_QUERY from '@/graphql/gql/mySchoolClass.gql';
import Classlist from '@/components/profile/Classlist'; import ADD_REMOVE_MEMBER_MUTATION from '@/graphql/gql/mutations/addRemoveMember.gql';
export default { export default {
components: { components: {
Classlist ClassList
},
methods: {
changeMember(member, active) {
this.$apollo.mutate({
mutation: ADD_REMOVE_MEMBER_MUTATION,
variables: {
input: {
member: member.id,
schoolClass: this.me.selectedClass.id,
active
}
},
update(store, {data: {addRemoveMember: {success}}}) {
if (success) {
const query = MY_SCHOOL_CLASS_QUERY;
const data = store.readQuery({query});
let memberIndex = data.me.selectedClass.members.findIndex(m => m.id === member.id);
data.me.selectedClass.members = [
...data.me.selectedClass.members.slice(0, memberIndex),
{...member, active},
...data.me.selectedClass.members.slice(memberIndex),
];
store.writeQuery({query, data});
}
}
});
},
add(member) {
this.changeMember(member, true);
},
remove(member) {
this.changeMember(member, false);
}
}, },
apollo: { apollo: {
selectedClass: { me: {
query: MY_SCHOOL_CLASS_QUERY, query: MY_SCHOOL_CLASS_QUERY,
update(data) { update(data) {
return this.$getRidOfEdges(data).me.selectedClass return this.$getRidOfEdges(data).me
} }
} }
}, },
data() { data() {
return { return {
me: {
isTeacher: false,
selectedClass: { selectedClass: {
name: '' name: '',
members: []
}
} }
} }
} }

View File

@ -0,0 +1,55 @@
<template>
<div class="old-classes">
<h1 class="old-classes__title">Alte Klassen</h1>
<ul class="old-classes__list simple-list">
<li class="simple-list__item" v-for="schoolClass in me.oldClasses" :key="schoolClass.id" data-cy="old-class-item"><span
class="old-classes__class-name">{{schoolClass.name}}</span> <a
class="simple-list__action" @click="updateSelectedClassAndGoToClassList(schoolClass)">Anzeigen</a>
</li>
</ul>
</div>
</template>
<script>
import OLD_CLASSES_QUERY from '@/graphql/gql/oldClasses.gql';
import updateSelectedClassMixin from '@/mixins/updateSelectedClass';
export default {
mixins: [updateSelectedClassMixin],
methods: {
updateSelectedClassAndGoToClassList(selectedClass) {
this.updateSelectedClass(selectedClass).then(() => {
this.$router.push({name: 'my-class'});
});
}
},
// todo: test this in front- and backend
apollo: {
me: {
query: OLD_CLASSES_QUERY,
update(data) {
return this.$getRidOfEdges(data).me;
}
}
},
data: () => ({
me: {
oldClasses: []
}
})
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
.old-classes {
&__class-name {
font-family: $sans-serif-font-family;
}
}
</style>

View File

@ -31,6 +31,7 @@ import login from '@/pages/login'
import registration from '@/pages/registration' import registration from '@/pages/registration'
import waitForClass from '@/pages/waitForClass' import waitForClass from '@/pages/waitForClass'
import joinClass from '@/pages/joinClass' import joinClass from '@/pages/joinClass'
import oldClasses from '@/pages/oldClasses';
import store from '@/store/index'; import store from '@/store/index';
@ -111,6 +112,13 @@ const routes = [
{path: 'my-class', name: 'my-class', component: myClass, meta: {isProfile: true}}, {path: 'my-class', name: 'my-class', component: myClass, meta: {isProfile: true}},
{path: 'activity', name: 'activity', component: activity, meta: {isProfile: true}}, {path: 'activity', name: 'activity', component: activity, meta: {isProfile: true}},
{path: '', name: 'profile-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: 'join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}}, {path: 'join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}},

View File

@ -146,6 +146,7 @@
font-family: $sans-serif-font-family; font-family: $sans-serif-font-family;
font-weight: $font-weight-regular; font-weight: $font-weight-regular;
color: $color-silver-dark; color: $color-silver-dark;
cursor: pointer;
&--active { &--active {
color: $color-brand; color: $color-brand;

View File

@ -0,0 +1,24 @@
@import "variables";
.simple-list {
border-top: 1px solid $color-silver-dark;
&--active {
margin-bottom: 2*$large-spacing;
}
&__item {
line-height: $list-height;
height: $list-height;
border-bottom: 1px solid $color-silver-dark;
display: flex;
flex-direction: row;
justify-content: space-between;
}
&__action {
@include default-link;
color: $color-brand;
}
}

View File

@ -56,6 +56,9 @@ $red: #FA5F5F;
$green: #6DD79A; $green: #6DD79A;
$brown: #EB9E77; $brown: #EB9E77;
$list-height: 52px;
$default-border-radius: 13px; $default-border-radius: 13px;
$input-border-radius: 3px; $input-border-radius: 3px;

View File

@ -23,3 +23,4 @@
@import "student-submission"; @import "student-submission";
@import "module-activity"; @import "module-activity";
@import "book-subnavigation"; @import "book-subnavigation";
@import "simple-list";

View File

@ -13,6 +13,7 @@ from graphql_relay import to_global_id
from api.test_utils import create_client, DefaultUserTestCase from api.test_utils import create_client, DefaultUserTestCase
from assignments.models import Assignment, StudentSubmission from assignments.models import Assignment, StudentSubmission
from users.factories import SchoolClassFactory from users.factories import SchoolClassFactory
from users.models import SchoolClassMember
from ..factories import AssignmentFactory, StudentSubmissionFactory, SubmissionFeedbackFactory from ..factories import AssignmentFactory, StudentSubmissionFactory, SubmissionFeedbackFactory
@ -25,13 +26,16 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
) )
self.assignment_id = to_global_id('AssignmentNode', self.assignment.pk) self.assignment_id = to_global_id('AssignmentNode', self.assignment.pk)
self.student_submission = StudentSubmissionFactory(assignment=self.assignment, student=self.student1, final=False) self.student_submission = StudentSubmissionFactory(assignment=self.assignment, student=self.student1,
final=False)
self.student_submission_id = to_global_id('StudentSubmissionNode', self.student_submission.pk) self.student_submission_id = to_global_id('StudentSubmissionNode', self.student_submission.pk)
school_class = SchoolClassFactory() school_class = SchoolClassFactory()
school_class.users.add(self.student1) for user in [self.student1, self.teacher, self.teacher2]:
school_class.users.add(self.teacher) SchoolClassMember.objects.create(
school_class.users.add(self.teacher2) user=user,
school_class=school_class
)
def _create_submission_feedback(self, user, final, text, student_submission_id): def _create_submission_feedback(self, user, final, text, student_submission_id):
mutation = ''' mutation = '''
@ -122,19 +126,17 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
}) })
def test_teacher_can_create_feedback(self): def test_teacher_can_create_feedback(self):
result = self._create_submission_feedback(self.teacher, False, 'Balalal', self.student_submission_id) result = self._create_submission_feedback(self.teacher, False, 'Balalal', self.student_submission_id)
self.assertIsNone(result.get('errors')) self.assertIsNone(result.get('errors'))
self.assertIsNotNone(result.get('data').get('updateSubmissionFeedback').get('updatedSubmissionFeedback').get('id')) self.assertIsNotNone(
result.get('data').get('updateSubmissionFeedback').get('updatedSubmissionFeedback').get('id'))
def test_student_cannot_create_feedback(self): def test_student_cannot_create_feedback(self):
result = self._create_submission_feedback(self.student1, False, 'Balalal', self.student_submission_id) result = self._create_submission_feedback(self.student1, False, 'Balalal', self.student_submission_id)
self.assertIsNotNone(result.get('errors')) self.assertIsNotNone(result.get('errors'))
def test_teacher_can_update_feedback(self): def test_teacher_can_update_feedback(self):
assignment = AssignmentFactory( assignment = AssignmentFactory(
owner=self.teacher owner=self.teacher
) )
@ -148,13 +150,13 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
self.assertIsNone(result.get('errors')) self.assertIsNone(result.get('errors'))
submission_feedback_response = result.get('data').get('updateSubmissionFeedback').get('updatedSubmissionFeedback') submission_feedback_response = result.get('data').get('updateSubmissionFeedback').get(
'updatedSubmissionFeedback')
self.assertTrue(submission_feedback_response.get('final')) self.assertTrue(submission_feedback_response.get('final'))
self.assertEqual(submission_feedback_response.get('text'), 'Some') self.assertEqual(submission_feedback_response.get('text'), 'Some')
def test_rogue_teacher_cannot_update_feedback(self): def test_rogue_teacher_cannot_update_feedback(self):
assignment = AssignmentFactory( assignment = AssignmentFactory(
owner=self.teacher owner=self.teacher
) )
@ -169,14 +171,12 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
self.assertIsNotNone(result.get('errors')) self.assertIsNotNone(result.get('errors'))
def test_student_does_not_see_non_final_feedback(self): def test_student_does_not_see_non_final_feedback(self):
SubmissionFeedbackFactory(teacher=self.teacher, final=False, student_submission=self.student_submission) SubmissionFeedbackFactory(teacher=self.teacher, final=False, student_submission=self.student_submission)
result = self._fetch_assignment_student(self.student1) result = self._fetch_assignment_student(self.student1)
self.assertIsNone(result.get('data').get('submissionFeedback')) self.assertIsNone(result.get('data').get('submissionFeedback'))
def test_student_does_see_final_feedback(self): def test_student_does_see_final_feedback(self):
submission_feedback = SubmissionFeedbackFactory(teacher=self.teacher, final=True, submission_feedback = SubmissionFeedbackFactory(teacher=self.teacher, final=True,
student_submission=self.student_submission) student_submission=self.student_submission)
result = self._fetch_assignment_student(self.student1) result = self._fetch_assignment_student(self.student1)

View File

@ -5,7 +5,7 @@ import os
import requests import requests
from django.conf import settings from django.conf import settings
from users.models import User, SchoolClass, Role, UserRole from users.models import User, SchoolClass, Role, UserRole, SchoolClassMember
class Command(BaseCommand): class Command(BaseCommand):
@ -40,7 +40,10 @@ class Command(BaseCommand):
self.stdout.write("Adding to class(es) {}".format(', '.join(school_class_names))) self.stdout.write("Adding to class(es) {}".format(', '.join(school_class_names)))
for school_class_name in school_class_names: for school_class_name in school_class_names:
school, _ = SchoolClass.objects.get_or_create(name=school_class_name) school, _ = SchoolClass.objects.get_or_create(name=school_class_name)
user.school_classes.add(school) SchoolClassMember.objects.create(
school_class=school,
user=user
)
self.stdout.write("") self.stdout.write("")

View File

@ -13,7 +13,7 @@ from graphene import relay
from core.views import SetPasswordView from core.views import SetPasswordView
from registration.models import License from registration.models import License
from registration.serializers import RegistrationSerializer from registration.serializers import RegistrationSerializer
from users.models import User, Role, UserRole, SchoolClass from users.models import User, Role, UserRole, SchoolClass, SchoolClassMember
class PublicFieldError(graphene.ObjectType): class PublicFieldError(graphene.ObjectType):
@ -61,7 +61,10 @@ class Registration(relay.ClientIDMutation):
UserRole.objects.get_or_create(user=user, role=teacher_role) UserRole.objects.get_or_create(user=user, role=teacher_role)
default_class_name = SchoolClass.generate_default_group_name() default_class_name = SchoolClass.generate_default_group_name()
default_class = SchoolClass.objects.create(name=default_class_name) default_class = SchoolClass.objects.create(name=default_class_name)
user.school_classes.add(default_class) SchoolClassMember.objects.create(
user=user,
school_class=default_class
)
else: else:
student_role = Role.objects.get(key=Role.objects.STUDENT_KEY) student_role = Role.objects.get(key=Role.objects.STUDENT_KEY)
UserRole.objects.get_or_create(user=user, role=student_role) UserRole.objects.get_or_create(user=user, role=student_role)

View File

@ -7,15 +7,24 @@ from .models import User, SchoolClass, Role, UserRole, UserSetting
class SchoolClassInline(admin.TabularInline): class SchoolClassInline(admin.TabularInline):
model = SchoolClass.users.through model = SchoolClass.users.through
extra = 1
class RoleInline(admin.TabularInline): class RoleInline(admin.TabularInline):
model = UserRole model = UserRole
extra = 1
@admin.register(SchoolClass) @admin.register(SchoolClass)
class SchoolClassAdmin(admin.ModelAdmin): class SchoolClassAdmin(admin.ModelAdmin):
list_display = ('name', 'code', 'is_deleted') list_display = ('name', 'code', 'user_list', 'is_deleted')
inlines = [
SchoolClassInline
]
def user_list(self, obj):
return ', '.join([s.username for s in obj.users.all()])
@admin.register(Role) @admin.register(Role)

View File

@ -2,7 +2,7 @@ import random
import factory import factory
from users.models import SchoolClass from users.models import SchoolClass, SchoolClassMember
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']
@ -16,7 +16,8 @@ class SchoolClassFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = SchoolClass model = SchoolClass
name = factory.Sequence(lambda n: '{}{}{}'.format(random.choice(class_types), '18', class_suffix[n % len(class_suffix)])) name = factory.Sequence(
lambda n: '{}{}{}'.format(random.choice(class_types), '18', class_suffix[n % len(class_suffix)]))
is_deleted = False is_deleted = False
@factory.post_generation @factory.post_generation
@ -28,4 +29,4 @@ class SchoolClassFactory(factory.django.DjangoModelFactory):
if extracted: if extracted:
# A list of groups were passed in, use them # A list of groups were passed in, use them
for user in extracted: for user in extracted:
self.users.add(user) SchoolClassMember.objects.create(user=user, school_class=self, active=True)

View File

@ -0,0 +1,29 @@
# Generated by Django 2.1.15 on 2020-03-03 12:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0011_auto_20200302_1613'),
]
operations = [
migrations.CreateModel(
name='UserSchoolClassConnection',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('active', models.BooleanField(default=True)),
('school_class', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.SchoolClass')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='schoolclass',
name='users_with_active',
field=models.ManyToManyField(blank=True, related_name='school_classes_with_active', through='users.UserSchoolClassConnection', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 2.1.15 on 2020-03-03 12:59
from django.db import migrations
def forwards(apps, schema_editor):
SchoolClass = apps.get_model('users', 'SchoolClass')
UserSchoolClassConnection = apps.get_model('users', 'UserSchoolClassConnection')
for school_class in SchoolClass.objects.all():
for user in school_class.users.all():
UserSchoolClassConnection.objects.create(user=user, school_class=school_class, active=True)
class Migration(migrations.Migration):
dependencies = [
('users', '0012_auto_20200303_1258'),
]
operations = [
migrations.RunPython(forwards)
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.1.15 on 2020-03-03 13:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0013_auto_20200303_1259'),
]
operations = [
migrations.RemoveField(
model_name='schoolclass',
name='users',
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.1.15 on 2020-03-03 13:06
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0014_remove_schoolclass_users'),
]
operations = [
migrations.RemoveField(
model_name='schoolclass',
name='users_with_active',
),
migrations.AddField(
model_name='schoolclass',
name='users',
field=models.ManyToManyField(blank=True, related_name='school_classes', through='users.UserSchoolClassConnection', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.1.15 on 2020-03-04 12:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0015_auto_20200303_1306'),
]
operations = [
migrations.RenameModel(
old_name='UserSchoolClassConnection',
new_name='SchoolClassMember',
),
]

View File

@ -42,13 +42,16 @@ class User(AbstractUser):
return User.objects.filter(school_classes__users=self.pk) return User.objects.filter(school_classes__users=self.pk)
def get_teacher(self): def get_teacher(self):
if self.user_roles.filter(role__key='teacher').exists(): if self.is_teacher():
return self return self
elif self.school_classes.count() > 0: elif self.school_classes.count() > 0:
return self.school_classes.first().get_teacher() return self.school_classes.first().get_teacher()
else: else:
return None return None
def is_teacher(self):
return self.user_roles.filter(role__key='teacher').exists()
def selected_class(self): def selected_class(self):
try: try:
settings = UserSetting.objects.get(user=self) settings = UserSetting.objects.get(user=self)
@ -69,7 +72,8 @@ class User(AbstractUser):
class SchoolClass(models.Model): 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)
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')
code = models.CharField('Code zum Beitreten', blank=True, null=True, max_length=10, unique=True, default=None) code = models.CharField('Code zum Beitreten', blank=True, null=True, max_length=10, unique=True, default=None)
class Meta: class Meta:
@ -175,3 +179,9 @@ class UserRole(models.Model):
class UserSetting(models.Model): class UserSetting(models.Model):
user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE, related_name='user_setting') user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE, related_name='user_setting')
selected_class = models.ForeignKey(SchoolClass, blank=True, null=True, on_delete=models.CASCADE) selected_class = models.ForeignKey(SchoolClass, blank=True, null=True, on_delete=models.CASCADE)
class SchoolClassMember(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
school_class = models.ForeignKey(SchoolClass, on_delete=models.CASCADE)
active = models.BooleanField(default=True)

View File

@ -3,10 +3,11 @@ from django.contrib.auth import update_session_auth_hash
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Q from django.db.models import Q
from graphene import relay from graphene import relay
from graphql_relay import from_global_id
from api.utils import get_object from api.utils import get_object
from users.inputs import PasswordUpdateInput from users.inputs import PasswordUpdateInput
from users.models import SchoolClass, UserSetting from users.models import SchoolClass, UserSetting, User, SchoolClassMember
from users.schema import SchoolClassNode from users.schema import SchoolClassNode
from users.serializers import PasswordSerialzer, AvatarUrlSerializer from users.serializers import PasswordSerialzer, AvatarUrlSerializer
@ -122,9 +123,11 @@ class JoinClass(relay.ClientIDMutation):
try: try:
school_class = SchoolClass.objects.get(Q(code__iexact=code)) school_class = SchoolClass.objects.get(Q(code__iexact=code))
if user not in list(school_class.users.all()): if user not in list(school_class.users.all()):
school_class.users.add(user) SchoolClassMember.objects.create(
user=user,
school_class=school_class
)
else: else:
raise CodeNotFoundException('[CAJ] Schüler ist bereits in Klasse') # CAJ = Class Already Joined raise CodeNotFoundException('[CAJ] Schüler ist bereits in Klasse') # CAJ = Class Already Joined
@ -133,8 +136,37 @@ class JoinClass(relay.ClientIDMutation):
raise CodeNotFoundException('[CNV] Code ist nicht gültig') # CAV = Code Not Valid raise CodeNotFoundException('[CNV] Code ist nicht gültig') # CAV = Code Not Valid
class AddRemoveMember(relay.ClientIDMutation):
class Input:
member = graphene.ID(required=True)
school_class = graphene.ID(required=True)
active = graphene.Boolean(required=True)
success = graphene.Boolean()
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
member_id = kwargs.get('member')
school_class_id = kwargs.get('school_class')
active = kwargs.get('active')
user = info.context.user
member_pk = from_global_id(member_id)[1]
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')
school_class_member = SchoolClassMember.objects.get(user__pk=member_pk, school_class=school_class)
school_class_member.active = active
school_class_member.save()
return cls(success=True)
class ProfileMutations: class ProfileMutations:
update_password = UpdatePassword.Field() update_password = UpdatePassword.Field()
update_avatar = UpdateAvatar.Field() update_avatar = UpdateAvatar.Field()
update_setting = UpdateSetting.Field() update_setting = UpdateSetting.Field()
join_class = JoinClass.Field() join_class = JoinClass.Field()
add_remove_member = AddRemoveMember.Field()

View File

@ -1,19 +1,21 @@
import graphene import graphene
from graphene import relay from django.db.models import Prefetch, Value, CharField, Q
from django.db.models.functions import Concat
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 graphql_relay import to_global_id
from assignments.models import StudentSubmission
from assignments.schema.types import StudentSubmissionNode
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 from books.models import Module
from books.schema.queries import ModuleNode from books.schema.queries import ModuleNode
from users.models import User, SchoolClass from users.models import User, SchoolClass, SchoolClassMember
class SchoolClassNode(DjangoObjectType): class SchoolClassNode(DjangoObjectType):
pk = graphene.Int() pk = graphene.Int()
members = graphene.List('users.schema.ClassMemberNode')
class Meta: class Meta:
model = SchoolClass model = SchoolClass
@ -23,11 +25,16 @@ class SchoolClassNode(DjangoObjectType):
def resolve_pk(self, *args, **kwargs): def resolve_pk(self, *args, **kwargs):
return self.id return self.id
def resolve_members(self, info, **kwargs):
return SchoolClassMember.objects.filter(school_class=self)
class UserNode(DjangoObjectType): class UserNode(DjangoObjectType):
pk = graphene.Int() pk = graphene.Int()
permissions = graphene.List(graphene.String) permissions = graphene.List(graphene.String)
selected_class = graphene.Field(SchoolClassNode) selected_class = graphene.Field(SchoolClassNode)
is_teacher = graphene.Boolean()
old_classes = DjangoFilterConnectionField(SchoolClassNode)
class Meta: class Meta:
model = User model = User
@ -45,6 +52,49 @@ class UserNode(DjangoObjectType):
def resolve_selected_class(self, info): def resolve_selected_class(self, info):
return self.selected_class() return self.selected_class()
def resolve_is_teacher(self, info):
return self.is_teacher()
def resolve_school_classes(self, info):
if self.selected_class() is None: # then we don't have any class to return
return []
return SchoolClass.objects.filter(
Q(schoolclassmember__active=True, schoolclassmember__user=self) | Q(pk=self.selected_class().pk)).distinct()
def resolve_old_classes(self, info):
return SchoolClass.objects.filter(schoolclassmember__active=False, schoolclassmember__user=self)
class ClassMemberNode(ObjectType):
"""
We need to build this ourselves, because we want the active property on the node, because providing it on the
Connection or Edge for a UserNodeConnection is difficult.
"""
user = graphene.Field(UserNode)
active = graphene.Boolean()
first_name = graphene.String()
last_name = graphene.String()
is_teacher = graphene.Boolean()
id = graphene.ID()
def resolve_id(self, *args):
return to_global_id('UserNode', self.user.pk)
def resolve_active(self, *args):
return self.active
def resolve_first_name(self, *args):
return self.user.first_name
def resolve_last_name(self, *args):
return self.user.last_name
def resolve_user(self, info, **kwargs):
return self.user
def resolve_is_teacher(self, *args):
return self.user.is_teacher()
class UsersQuery(object): class UsersQuery(object):
me = graphene.Field(UserNode) me = graphene.Field(UserNode)

View File

@ -4,7 +4,7 @@ from graphene.test import Client
from core.factories import UserFactory from core.factories import UserFactory
from users.factories import SchoolClassFactory from users.factories import SchoolClassFactory
from users.models import SchoolClass from users.models import SchoolClass, SchoolClassMember
from api.schema import schema from api.schema import schema
@ -45,7 +45,10 @@ class JoinSchoolClassTest(TestCase):
def test_class_already_joined(self): def test_class_already_joined(self):
code = 'YYYY' code = 'YYYY'
school_class = SchoolClass.objects.get(code=code) school_class = SchoolClass.objects.get(code=code)
school_class.users.add(self.user) SchoolClassMember.objects.create(
user=self.user,
school_class=school_class
)
self.assertEqual(self.user.school_classes.count(), 2) self.assertEqual(self.user.school_classes.count(), 2)

View File

@ -0,0 +1,126 @@
from django.test import TestCase
from graphene import Context
from graphene.test import Client
from graphql_relay import to_global_id
from api.utils import get_graphql_mutation
from core.factories import UserFactory
from users.factories import SchoolClassFactory
from users.models import SchoolClass, User, SchoolClassMember
from api.schema import schema
from users.services import create_users
class JoinSchoolClassTest(TestCase):
def setUp(self):
self.client = Client(schema=schema)
self.school_class_name = 'Moordale'
user_data = [
{
'teacher': ('Emily', 'Sands',),
'class': self.school_class_name,
'code': 'SEXED',
'students': [
('Otis', 'Milburn'),
('Maeve', 'Wiley'),
('Adam', 'Groff'),
('Eric', 'Effiong'),
('Jackson', 'Marchetti'),
]
},
{
'teacher': ('Colin', 'Hendricks'),
'class': 'Swing Band',
'students': [
('Ola', 'Nyman'),
]
}
]
create_users(user_data)
teacher = User.objects.get(username='emily.sands')
self.teacher_id = to_global_id('UserNode', teacher.pk)
student = User.objects.get(username='adam.groff')
self.student_id = to_global_id('UserNode', student.pk)
other_student = User.objects.get(username='eric.effiong')
self.other_student_id = to_global_id('UserNode', other_student.pk)
school_class = SchoolClass.objects.get(name=self.school_class_name)
self.school_class_id = to_global_id('SchoolClassNode', school_class.pk)
self.teacher_context = Context(user=teacher)
self.student_context = Context(user=student)
self.mutation = get_graphql_mutation('addRemoveMember.gql')
def test_leave_and_join_class(self):
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 6)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(),
0)
result = self.client.execute(self.mutation, variables={
'input': {
'schoolClass': self.school_class_id,
'member': self.student_id,
'active': False
}
}, context=self.teacher_context)
self.assertIsNone(result.get('errors'))
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 5)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(),
1)
result = self.client.execute(self.mutation, variables={
'input': {
'schoolClass': self.school_class_id,
'member': self.student_id,
'active': True
}
}, context=self.teacher_context)
self.assertIsNone(result.get('errors'))
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 6)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(),
0)
def test_leave_class_student_raises_error(self):
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 6)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(), 0)
result = self.client.execute(self.mutation, variables={
'input': {
'schoolClass': self.school_class_id,
'member': self.other_student_id,
'active': False
}
}, context=self.student_context)
self.assertIsNotNone(result['errors'])
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 6)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(), 0)
def test_leave_class_other_school_class_raises_error(self):
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 6)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(), 0)
student = User.objects.get(username='ola.nyman')
school_class = SchoolClass.objects.get(name='Swing Band')
result = self.client.execute(self.mutation, variables={
'input': {
'schoolClass': to_global_id('SchoolClassNode', school_class.id),
'member': to_global_id('UserNode', student.id),
'active': False
}
}, context=self.teacher_context)
self.assertIsNotNone(result['errors'])
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=True).count(), 6)
self.assertEqual(
SchoolClassMember.objects.filter(school_class__name=self.school_class_name, active=False).count(), 0)

View File

@ -11,10 +11,12 @@ from django.conf import settings
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from graphene.test import Client from graphene.test import Client
from api.schema import schema from api.schema import schema
from api.utils import get_graphql_query
from core.factories import UserFactory from core.factories import UserFactory
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from users.factories import SchoolClassFactory from users.factories import SchoolClassFactory
from users.models import SchoolClassMember
class MySchoolClasses(TestCase): class MySchoolClasses(TestCase):
@ -24,13 +26,17 @@ class MySchoolClasses(TestCase):
self.class1 = SchoolClassFactory(users=[self.user, self.another_user]) self.class1 = SchoolClassFactory(users=[self.user, self.another_user])
self.class2 = SchoolClassFactory(users=[self.user]) self.class2 = SchoolClassFactory(users=[self.user])
self.class3 = SchoolClassFactory(users=[self.another_user]) self.class3 = SchoolClassFactory(users=[self.another_user])
self.inactive_class = SchoolClassFactory(users=[self.user])
inactive_member = SchoolClassMember.objects.get(school_class=self.inactive_class)
inactive_member.active = False
inactive_member.save()
request = RequestFactory().get('/') request = RequestFactory().get('/')
request.user = self.user request.user = self.user
self.client = Client(schema=schema, context_value=request) self.client = Client(schema=schema, context_value=request)
def make_query(self): def test_user_sees_her_classes(self):
query = ''' query = '''
query { query {
me { me {
@ -55,11 +61,8 @@ class MySchoolClasses(TestCase):
} }
} }
''' '''
return self.client.execute(query)
def test_user_sees_her_classes(self): result = self.client.execute(query)
result = self.make_query()
classes = result.get('data').get('me').get('schoolClasses').get('edges') classes = result.get('data').get('me').get('schoolClasses').get('edges')
self.assertEqual(len(classes), 2) self.assertEqual(len(classes), 2)
@ -72,5 +75,12 @@ class MySchoolClasses(TestCase):
elif school_class.get('name') == self.class2: elif school_class.get('name') == self.class2:
self.fail('MySchoolClassTest:test_user_sees_her_classes: Class should not be in response') self.fail('MySchoolClassTest:test_user_sees_her_classes: Class should not be in response')
def test_old_classes(self):
query = get_graphql_query('oldClasses.gql')
result = self.client.execute(query)
old_classes = result.get('data').get('me').get('oldClasses').get('edges')
self.assertEqual(len(old_classes), 1)