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",
"lastName": "Cueni",
"avatarUrl": "",
"isTeacher": false,
"lastModule": {
"id": "TW9kdWxlTm9kZToxNw==",
"slug": "lohn-und-budget",

File diff suppressed because it is too large Load Diff

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 me = require('../fixtures/me.join-class.json');
const selectedClass = require('../fixtures/selected-school-class.json');
describe('Join Class', () => {
describe('Class Management', () => {
beforeEach(() => {
cy.server();
@ -31,14 +32,15 @@ describe('Join Class', () => {
}
});
cy.visit('/me/profile');
cy.get('[data-cy=header-user-widget]').within(() => {
cy.get('[data-cy=user-widget-avatar]').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]').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-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;
flex-direction: column;
background-color: $color-white;
padding: 20px;
padding: 0;
z-index: 100;
@include widget-shadow;
@ -43,6 +43,9 @@
display: grid;
&__link {
cursor: pointer;
padding: 0 $medium-spacing;
& > a {
display: inline-block;
color: $color-silver-dark;
@ -55,6 +58,7 @@
&--large {
line-height: 40px;
padding: $small-spacing $medium-spacing;
& > a, & {
@include small-text;
}
@ -64,5 +68,9 @@
font-weight: 600;
}
}
&__divider {
border-top: 1px solid $color-silver-dark;
}
}
</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 data-cy="class-selection" class="class-selection__selected-class selected-class"
@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>
<widget-popover v-if="showPopover"
@hide-me="showPopover = false"
@ -13,9 +14,13 @@
:key="schoolClass.id"
:label="schoolClass.name"
:item="schoolClass"
@click="updateFilter(schoolClass)">
@click="updateSelectedClass(schoolClass)">
{{schoolClass.name}}
</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>
</div>
</template>
@ -24,8 +29,10 @@
import WidgetPopover from '@/components/WidgetPopover';
import ChevronDown from '@/components/icons/ChevronDown';
import CurrentClass from '@/components/school-class/CurrentClass';
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 {
components: {
@ -41,6 +48,8 @@
}
},
mixins: [updateSelectedClassMixin],
apollo: {
me: {
query: ME_QUERY,
@ -67,22 +76,8 @@
},
methods: {
updateFilter(selectedClass) {
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.log('fail', error)
});
updateSelectedClassAndHidePopover(selectedClass) {
this.updateSelectedClass(selectedClass);
this.showPopover = false;
}
},
@ -139,9 +134,4 @@
fill: $color-brand;
}
}
.popover-links__link {
cursor: pointer;
}
</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 {
id
isTeacher
selectedClass {
id
name
users {
edges {
node {
id
firstName
lastName
permissions
}
}
members {
id
firstName
lastName
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>
<div class="my-class">
<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>
</template>
<script>
import ClassList from '@/components/profile/ClassList';
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 {
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: {
selectedClass: {
me: {
query: MY_SCHOOL_CLASS_QUERY,
update(data) {
return this.$getRidOfEdges(data).me.selectedClass
return this.$getRidOfEdges(data).me
}
}
},
data() {
return {
selectedClass: {
name: ''
me: {
isTeacher: false,
selectedClass: {
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 waitForClass from '@/pages/waitForClass'
import joinClass from '@/pages/joinClass'
import oldClasses from '@/pages/oldClasses';
import store from '@/store/index';
@ -111,6 +112,13 @@ const routes = [
{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: 'join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}},

View File

@ -146,6 +146,7 @@
font-family: $sans-serif-font-family;
font-weight: $font-weight-regular;
color: $color-silver-dark;
cursor: pointer;
&--active {
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;
$brown: #EB9E77;
$list-height: 52px;
$default-border-radius: 13px;
$input-border-radius: 3px;

View File

@ -23,3 +23,4 @@
@import "student-submission";
@import "module-activity";
@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 assignments.models import Assignment, StudentSubmission
from users.factories import SchoolClassFactory
from users.models import SchoolClassMember
from ..factories import AssignmentFactory, StudentSubmissionFactory, SubmissionFeedbackFactory
@ -25,13 +26,16 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
)
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)
school_class = SchoolClassFactory()
school_class.users.add(self.student1)
school_class.users.add(self.teacher)
school_class.users.add(self.teacher2)
for user in [self.student1, self.teacher, self.teacher2]:
SchoolClassMember.objects.create(
user=user,
school_class=school_class
)
def _create_submission_feedback(self, user, final, text, student_submission_id):
mutation = '''
@ -122,19 +126,17 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
})
def test_teacher_can_create_feedback(self):
result = self._create_submission_feedback(self.teacher, False, 'Balalal', self.student_submission_id)
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):
result = self._create_submission_feedback(self.student1, False, 'Balalal', self.student_submission_id)
self.assertIsNotNone(result.get('errors'))
def test_teacher_can_update_feedback(self):
assignment = AssignmentFactory(
owner=self.teacher
)
@ -148,13 +150,13 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
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.assertEqual(submission_feedback_response.get('text'), 'Some')
def test_rogue_teacher_cannot_update_feedback(self):
assignment = AssignmentFactory(
owner=self.teacher
)
@ -169,14 +171,12 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
self.assertIsNotNone(result.get('errors'))
def test_student_does_not_see_non_final_feedback(self):
SubmissionFeedbackFactory(teacher=self.teacher, final=False, student_submission=self.student_submission)
result = self._fetch_assignment_student(self.student1)
self.assertIsNone(result.get('data').get('submissionFeedback'))
def test_student_does_see_final_feedback(self):
submission_feedback = SubmissionFeedbackFactory(teacher=self.teacher, final=True,
student_submission=self.student_submission)
result = self._fetch_assignment_student(self.student1)
@ -195,7 +195,7 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
def test_rogue_teacher_cannot_see_feedback(self):
SubmissionFeedbackFactory(teacher=self.teacher, final=False,
student_submission=self.student_submission)
student_submission=self.student_submission)
self.student_submission.final = True
self.student_submission.save()

View File

@ -5,7 +5,7 @@ import os
import requests
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):
@ -40,7 +40,10 @@ class Command(BaseCommand):
self.stdout.write("Adding to class(es) {}".format(', '.join(school_class_names)))
for school_class_name in school_class_names:
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("")

View File

@ -13,7 +13,7 @@ from graphene import relay
from core.views import SetPasswordView
from registration.models import License
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):
@ -61,7 +61,10 @@ class Registration(relay.ClientIDMutation):
UserRole.objects.get_or_create(user=user, role=teacher_role)
default_class_name = SchoolClass.generate_default_group_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:
student_role = Role.objects.get(key=Role.objects.STUDENT_KEY)
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):
model = SchoolClass.users.through
extra = 1
class RoleInline(admin.TabularInline):
model = UserRole
extra = 1
@admin.register(SchoolClass)
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)

View File

@ -2,7 +2,7 @@ import random
import factory
from users.models import SchoolClass
from users.models import SchoolClass, SchoolClassMember
class_types = ['DA', 'KV', 'INF', 'EE']
class_suffix = ['A', 'B', 'C', 'D', 'E']
@ -16,7 +16,8 @@ class SchoolClassFactory(factory.django.DjangoModelFactory):
class Meta:
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
@factory.post_generation
@ -28,4 +29,4 @@ class SchoolClassFactory(factory.django.DjangoModelFactory):
if extracted:
# A list of groups were passed in, use them
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)
def get_teacher(self):
if self.user_roles.filter(role__key='teacher').exists():
if self.is_teacher():
return self
elif self.school_classes.count() > 0:
return self.school_classes.first().get_teacher()
else:
return None
def is_teacher(self):
return self.user_roles.filter(role__key='teacher').exists()
def selected_class(self):
try:
settings = UserSetting.objects.get(user=self)
@ -69,7 +72,8 @@ class User(AbstractUser):
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)
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)
class Meta:
@ -175,3 +179,9 @@ class UserRole(models.Model):
class UserSetting(models.Model):
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)
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.db.models import Q
from graphene import relay
from graphql_relay import from_global_id
from api.utils import get_object
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.serializers import PasswordSerialzer, AvatarUrlSerializer
@ -122,15 +123,45 @@ class JoinClass(relay.ClientIDMutation):
try:
school_class = SchoolClass.objects.get(Q(code__iexact=code))
if user not in list(school_class.users.all()):
school_class.users.add(user)
SchoolClassMember.objects.create(
user=user,
school_class=school_class
)
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
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') # 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:
@ -138,3 +169,4 @@ class ProfileMutations:
update_avatar = UpdateAvatar.Field()
update_setting = UpdateSetting.Field()
join_class = JoinClass.Field()
add_remove_member = AddRemoveMember.Field()

View File

@ -1,19 +1,21 @@
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.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.queries import InstrumentNode
from books.models import Module
from books.schema.queries import ModuleNode
from users.models import User, SchoolClass
from users.models import User, SchoolClass, SchoolClassMember
class SchoolClassNode(DjangoObjectType):
pk = graphene.Int()
members = graphene.List('users.schema.ClassMemberNode')
class Meta:
model = SchoolClass
@ -23,11 +25,16 @@ class SchoolClassNode(DjangoObjectType):
def resolve_pk(self, *args, **kwargs):
return self.id
def resolve_members(self, info, **kwargs):
return SchoolClassMember.objects.filter(school_class=self)
class UserNode(DjangoObjectType):
pk = graphene.Int()
permissions = graphene.List(graphene.String)
selected_class = graphene.Field(SchoolClassNode)
is_teacher = graphene.Boolean()
old_classes = DjangoFilterConnectionField(SchoolClassNode)
class Meta:
model = User
@ -45,6 +52,49 @@ class UserNode(DjangoObjectType):
def resolve_selected_class(self, info):
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):
me = graphene.Field(UserNode)

View File

@ -4,7 +4,7 @@ from graphene.test import Client
from core.factories import UserFactory
from users.factories import SchoolClassFactory
from users.models import SchoolClass
from users.models import SchoolClass, SchoolClassMember
from api.schema import schema
@ -45,7 +45,10 @@ class JoinSchoolClassTest(TestCase):
def test_class_already_joined(self):
code = 'YYYY'
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)

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 graphene.test import Client
from api.schema import schema
from api.utils import get_graphql_query
from core.factories import UserFactory
from django.contrib.auth import authenticate
from users.factories import SchoolClassFactory
from users.models import SchoolClassMember
class MySchoolClasses(TestCase):
@ -24,13 +26,17 @@ class MySchoolClasses(TestCase):
self.class1 = SchoolClassFactory(users=[self.user, self.another_user])
self.class2 = SchoolClassFactory(users=[self.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.user = self.user
self.client = Client(schema=schema, context_value=request)
def make_query(self):
def test_user_sees_her_classes(self):
query = '''
query {
me {
@ -55,11 +61,8 @@ class MySchoolClasses(TestCase):
}
}
'''
return self.client.execute(query)
def test_user_sees_her_classes(self):
result = self.make_query()
result = self.client.execute(query)
classes = result.get('data').get('me').get('schoolClasses').get('edges')
self.assertEqual(len(classes), 2)
@ -72,5 +75,12 @@ class MySchoolClasses(TestCase):
elif school_class.get('name') == self.class2:
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)