Merged in feature/rooms-read-only (pull request #88)

Feature/rooms read only
This commit is contained in:
Ramon Wenger 2021-07-30 09:20:58 +00:00
commit b50ba068a0
27 changed files with 811 additions and 27402 deletions

View File

@ -2,6 +2,13 @@ import { GraphQLError } from 'graphql';
const schema = require('../../../fixtures/schema.json'); const schema = require('../../../fixtures/schema.json');
const redeemCoupon = coupon => {
if (coupon !== '') {
cy.get('[data-cy="coupon-input"]').type(coupon);
}
cy.get('[data-cy="coupon-button"]').click();
};
describe('Email Verifcation', () => { describe('Email Verifcation', () => {
beforeEach(() => { beforeEach(() => {
cy.server(); cy.server();
@ -22,7 +29,7 @@ describe('Email Verifcation', () => {
cy.apolloLogin('rahel.cueni', 'test'); cy.apolloLogin('rahel.cueni', 'test');
cy.visit('/license-activation'); cy.visit('/license-activation');
cy.redeemCoupon('12345asfd'); redeemCoupon('12345asfd');
cy.assertStartPage(); cy.assertStartPage();
}); });
@ -31,7 +38,7 @@ describe('Email Verifcation', () => {
cy.apolloLogin('rahel.cueni', 'test'); cy.apolloLogin('rahel.cueni', 'test');
cy.visit('/license-activation'); cy.visit('/license-activation');
cy.redeemCoupon(''); redeemCoupon('');
cy.get('[data-cy="coupon-local-errors"]').contains('Coupon ist ein Pflichtfeld.'); cy.get('[data-cy="coupon-local-errors"]').contains('Coupon ist ein Pflichtfeld.');
}); });
@ -46,7 +53,7 @@ describe('Email Verifcation', () => {
cy.apolloLogin('rahel.cueni', 'test'); cy.apolloLogin('rahel.cueni', 'test');
cy.visit('/license-activation'); cy.visit('/license-activation');
cy.redeemCoupon('12345asfd'); redeemCoupon('12345asfd');
cy.get('[data-cy="coupon-remote-errors"]').contains('Der angegebene Coupon-Code ist ungültig.'); cy.get('[data-cy="coupon-remote-errors"]').contains('Der angegebene Coupon-Code ist ungültig.');
}); });
@ -61,7 +68,7 @@ describe('Email Verifcation', () => {
cy.apolloLogin('rahel.cueni', 'test'); cy.apolloLogin('rahel.cueni', 'test');
cy.visit('/license-activation'); cy.visit('/license-activation');
cy.redeemCoupon('12345asfd'); redeemCoupon('12345asfd');
cy.get('[data-cy="coupon-remote-errors"]').contains('Es ist ein Fehler aufgetreten. Bitte versuchen Sie es nochmals oder kontaktieren Sie den Administrator.'); cy.get('[data-cy="coupon-remote-errors"]').contains('Es ist ein Fehler aufgetreten. Bitte versuchen Sie es nochmals oder kontaktieren Sie den Administrator.');
}); });
}); });

View File

@ -55,7 +55,7 @@ describe('Assignment feedback read-only - Teacher', () => {
// cy.get('@textarea').should('have.attr', 'readonly'); // cy.get('@textarea').should('have.attr', 'readonly');
cy.isSubmissionReadOnly(myText); cy.isSubmissionReadOnly(myText);
cy.canReopen(false); cy.getByDataCy('final-submission-reopen').should('not.exist');
}); });
it('can edit', () => { it('can edit', () => {
cy.mockGraphqlOps({ cy.mockGraphqlOps({
@ -63,6 +63,7 @@ describe('Assignment feedback read-only - Teacher', () => {
}); });
cy.visit('submission/submission-id'); cy.visit('submission/submission-id');
cy.canReopen(false);
cy.getByDataCy('final-submission-reopen').should('exist');
}); });
}); });

View File

@ -90,7 +90,7 @@ describe('Assignments read-only - Student', () => {
cy.visit('module/module-with-assignment'); cy.visit('module/module-with-assignment');
cy.isSubmissionReadOnly(myText); cy.isSubmissionReadOnly(myText);
cy.canReopen(false); cy.getByDataCy('final-submission-reopen').should('not.exist');
}); });
it('can revoke turn in', () => { it('can revoke turn in', () => {
@ -110,6 +110,6 @@ describe('Assignments read-only - Student', () => {
cy.visit('module/module-with-assignment'); cy.visit('module/module-with-assignment');
cy.getByDataCy('final-submission').should('exist'); cy.getByDataCy('final-submission').should('exist');
cy.canReopen(false); cy.getByDataCy('final-submission-reopen').should('not.exist');
}); });
}); });

View File

@ -0,0 +1,81 @@
import mocks from '../../../fixtures/mocks';
const SELECTED_CLASS_ID = 'selectedClassId';
const getOperations = ({readOnly, classReadOnly}) => ({
MeQuery: {
me: {
onboardingVisited: true,
readOnly,
isTeacher: true,
selectedClass: {
id: SELECTED_CLASS_ID,
readOnly: classReadOnly,
},
},
},
RoomEntriesQuery: {
room: {
id: 'roomId',
slug: '',
title: 'room title',
entryCount: 3,
appearance: 'blue',
description: 'room description',
schoolClass: {
id: SELECTED_CLASS_ID,
name: 'selected class',
},
roomEntries: {
edges: [
{
node: {
id: 'entryId',
slug: '',
title: 'entry title',
contents: [],
author: {
id: 'authorId',
firstName: 'first',
lastName: 'last',
avatarUrl: ''
}
},
},
],
},
},
},
});
const checkRoomReadOnly = ({editable, readOnly, classReadOnly = false}) => {
const operations = getOperations({readOnly, classReadOnly});
cy.mockGraphqlOps({
operations,
});
const exist = editable ? 'exist' : 'not.exist';
cy.visit('room/some-room');
cy.get('.room-entry').should('exist');
cy.getByDataCy('add-room-entry-button').should(exist);
cy.getByDataCy('room-actions').should(exist);
};
describe('Room Team Management - Read only', () => {
beforeEach(() => {
cy.setup();
});
it('can edit room', () => {
checkRoomReadOnly({editable: true, readOnly: false});
});
it('can not edit room', () => {
checkRoomReadOnly({editable: false, readOnly: true});
});
it('can not edit room of inactive class', () => {
checkRoomReadOnly({editable: false, readOnly: false, classReadOnly: true});
});
});

View File

@ -0,0 +1,70 @@
import mocks from '../../../fixtures/mocks';
const SELECTED_CLASS_ID = 'selectedClassId';
const getOperations = ({readOnly, classReadOnly}) => ({
MeQuery: {
me: {
onboardingVisited: true,
readOnly,
isTeacher: true,
selectedClass: {
id: SELECTED_CLASS_ID,
readOnly: classReadOnly,
},
},
},
RoomsQuery: {
rooms: {
edges: [
{
node: {
id: '',
slug: '',
title: 'some room',
entryCount: 3,
appearance: 'red',
description: 'some description',
schoolClass: {
id: SELECTED_CLASS_ID,
name: 'bla'
},
},
},
],
},
},
});
const checkRoomsReadOnly = ({editable, readOnly, classReadOnly = false}) => {
const operations = getOperations({readOnly, classReadOnly});
cy.mockGraphqlOps({
operations,
});
const exist = editable ? 'exist' : 'not.exist';
cy.visit('rooms');
cy.log('visit');
cy.get('.room-widget').should('exist');
cy.getByDataCy('add-room').should(exist);
cy.getByDataCy('widget-footer').should(exist);
};
describe('Room Team Management - Read only', () => {
beforeEach(() => {
cy.setup();
});
it('can edit room', () => {
checkRoomsReadOnly({editable: true, readOnly: false});
});
it('can not edit room', () => {
checkRoomsReadOnly({editable: false, readOnly: true});
});
it('can not edit room of inactive class', () => {
checkRoomsReadOnly({editable: false, readOnly: false, classReadOnly: true});
});
});

View File

@ -32,7 +32,7 @@ const getOperations = ({readOnly}) => ({
}, },
}); });
Cypress.Commands.add('checkSchoolClassTeamReadOnly', (readOnly) => { const checkSchoolClassTeamReadOnly = (readOnly) => {
cy.mockGraphqlOps({ cy.mockGraphqlOps({
operations: getOperations({readOnly}), operations: getOperations({readOnly}),
}); });
@ -47,7 +47,7 @@ Cypress.Commands.add('checkSchoolClassTeamReadOnly', (readOnly) => {
cy.getByDataCy('current-class-name').should('exist'); cy.getByDataCy('current-class-name').should('exist');
cy.getByDataCy('create-class-link').should(exist); cy.getByDataCy('create-class-link').should(exist);
cy.getByDataCy('my-team-link').should(exist); cy.getByDataCy('my-team-link').should(exist);
}); };
describe('School Class and Team Management - Read only', () => { describe('School Class and Team Management - Read only', () => {
beforeEach(() => { beforeEach(() => {
@ -62,21 +62,10 @@ describe('School Class and Team Management - Read only', () => {
}); });
it('can see menu items', () => { it('can see menu items', () => {
cy.checkSchoolClassTeamReadOnly(false); checkSchoolClassTeamReadOnly(false);
// cy.visit('me/class');
// cy.getByDataCy('group-list-name').should('exist').should('contain', selectedClassName);
// cy.getByDataCy('show-code-button').should('exist');
// cy.getByDataCy('edit-group-name-link').should('exist');
// cy.openSidebar();
// cy.getByDataCy('class-selection').click();
// cy.getByDataCy('current-class-name').should('exist');
// cy.getByDataCy('create-class-link').should('exist');
}); });
it('can not see menu items', () => { it('can not see menu items', () => {
// cy.mockGraphqlOps({ checkSchoolClassTeamReadOnly(true);
// operations: getOperations({readOnly: true}),
// });
cy.checkSchoolClassTeamReadOnly(true);
}); });
}); });

View File

@ -28,6 +28,7 @@
// todo: once above issue is fixed, go back to the original repo -> npm install cypress-graphql-mock // todo: once above issue is fixed, go back to the original repo -> npm install cypress-graphql-mock
// import 'cypress-graphql-mock'; // import 'cypress-graphql-mock';
import '@iam4x/cypress-graphql-mock'; import '@iam4x/cypress-graphql-mock';
import mocks from '../fixtures/mocks';
Cypress.Commands.add('apolloLogin', (username, password) => { Cypress.Commands.add('apolloLogin', (username, password) => {
const payload = { const payload = {
@ -118,51 +119,6 @@ Cypress.Commands.add('enterPassword', (password) => {
cy.get('[data-cy="login-button"]').click(); cy.get('[data-cy="login-button"]').click();
}); });
Cypress.Commands.add('register', (prefix, firstname, lastname, street, city, postcode, password, passwordConfirmation, acceptTerms) => {
let selection = prefix === 1 ? 'Herr' : 'Frau';
cy.get('[data-cy="prefix-selection"]').select(selection);
if (firstname !== '') {
cy.get('[data-cy="firstname-input"]').type(firstname);
}
if (lastname !== '') {
cy.get('[data-cy="lastname-input"]').type(lastname);
}
if (street !== '') {
cy.get('[data-cy="street-input"]').type(street);
}
if (city !== '') {
cy.get('[data-cy="city-input"]').type(city);
}
if (postcode !== '') {
cy.get('[data-cy="postcode-input"]').type(postcode);
}
if (password !== '') {
cy.get('[data-cy="password-input"]').type(password);
}
if (acceptTerms) {
cy.get('[data-cy="acceptedTerms-input"] > input').first().check({force: true}).then(() => {
cy.get('[data-cy="acceptedTerms-input"] > input:checkbox').should('be.checked');
});
}
cy.get('[data-cy="passwordConfirmation-input"]').type(passwordConfirmation);
cy.get('[data-cy="register-button"]').click();
});
Cypress.Commands.add('redeemCoupon', coupon => {
if (coupon !== '') {
cy.get('[data-cy="coupon-input"]').type(coupon);
}
cy.get('[data-cy="coupon-button"]').click();
});
Cypress.Commands.add('assertStartPage', (onboarding) => { Cypress.Commands.add('assertStartPage', (onboarding) => {
if (onboarding) { if (onboarding) {
cy.get('[data-cy=onboarding-page]').should('exist'); cy.get('[data-cy=onboarding-page]').should('exist');
@ -190,11 +146,6 @@ Cypress.Commands.add('fakeLogin', () => {
cy.setCookie('loginStatus', 'true'); cy.setCookie('loginStatus', 'true');
}); });
Cypress.Commands.add('canReopen', (exists) => {
let check = exists ? 'exist' : 'not.exist';
cy.getByDataCy('final-submission-reopen').should(check);
});
Cypress.Commands.add('isSubmissionReadOnly', (myText) => { Cypress.Commands.add('isSubmissionReadOnly', (myText) => {
cy.get('.submission-form__textarea--readonly').as('textarea'); cy.get('.submission-form__textarea--readonly').as('textarea');
@ -206,3 +157,15 @@ Cypress.Commands.add('isSubmissionReadOnly', (myText) => {
Cypress.Commands.add('openSidebar', () => { Cypress.Commands.add('openSidebar', () => {
cy.getByDataCy('user-widget-avatar').click(); cy.getByDataCy('user-widget-avatar').click();
}); });
Cypress.Commands.add('setup', () => {
cy.fakeLogin('nino.teacher', 'test');
cy.server();
cy.viewport('macbook-15');
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
mocks,
});
});
});

View File

@ -1,3 +1,5 @@
// todo: clean up this file
const getSchoolClassNode = (id, schoolClassName) => ({ const getSchoolClassNode = (id, schoolClassName) => ({
'id': btoa(`SchoolClassNode:${id}`), 'id': btoa(`SchoolClassNode:${id}`),
'name': schoolClassName, 'name': schoolClassName,
@ -52,43 +54,6 @@ export const getMe = ({schoolClasses, teacher}) => {
}; };
}; };
export const getAssignments = () => {
return {
'assignments': {
'edges': [
{
'node': {
'id': 'QXNzaWdubWVudE5vZGU6MQ==',
'title': 'Ein Auftragstitel',
'assignment': 'Ein Auftrag',
'solution': null,
'submission': {
'id': 'U3R1ZGVudFN1Ym1pc3Npb25Ob2RlOjE=',
'text': 'Hir ist ein Feler gewesen',
'final': false,
'document': '',
'submissionFeedback': {
'id': 'U3VibWlzc2lvbkZlZWRiYWNrTm9kZTox',
'text': '\ud83d\ude42\ud83d\ude10\ud83e\udd2c\ud83d\udc4d\ud83e\udd22\ud83e\udd22\ud83e\udd22\ud83e\udd22\ud83d\ude2e\ud83e\udd17',
'teacher': {
'firstName': 'Nico',
'lastName': 'Zickgraf',
'__typename': 'UserNode',
},
'__typename': 'SubmissionFeedbackNode',
},
'__typename': 'StudentSubmissionNode',
},
'__typename': 'AssignmentNode',
},
'__typename': 'AssignmentNodeEdge',
},
],
'__typename': 'AssignmentNodeConnection',
},
};
};
export const getModules = () => { export const getModules = () => {
return { return {
'lohn-und-budget': { 'lohn-und-budget': {

View File

@ -24,10 +24,10 @@ declare namespace Cypress {
fakeLogin(username: string, password: string): void fakeLogin(username: string, password: string): void
canReopen(exists: boolean): void
isSubmissionReadOnly(myText: string): void isSubmissionReadOnly(myText: string): void
openSidebar(): void openSidebar(): void
setup(): void
} }
} }

27764
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -118,7 +118,7 @@
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-jest": "^24.8.0", "babel-jest": "^24.8.0",
"canvas": "^2.5.0", "canvas": "^2.5.0",
"cypress": "^6.2.1", "cypress": "^8.0.0",
"graphql-config": "^3.2.0", "graphql-config": "^3.2.0",
"jest": "^24.8.0", "jest": "^24.8.0",
"jest-serializer-vue": "^2.0.2", "jest-serializer-vue": "^2.0.2",

View File

@ -1,5 +1,7 @@
<template> <template>
<div class="room-actions"> <div
class="room-actions"
data-cy="room-actions">
<a <a
class="room-actions__more-link" class="room-actions__more-link"
@click="toggleMenu"> @click="toggleMenu">
@ -68,8 +70,7 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_variables.scss"; @import "~styles/helpers";
@import "@/styles/_mixins.scss";
.room-actions { .room-actions {
&__more-link { &__more-link {

View File

@ -10,7 +10,9 @@
<room-group-widget v-bind="schoolClass"/> <room-group-widget v-bind="schoolClass"/>
<entry-count-widget :entry-count="entryCount"/> <entry-count-widget :entry-count="entryCount"/>
</router-link> </router-link>
<widget-footer v-if="canEditRoom"> <widget-footer
data-cy="widget-footer"
v-if="canEditRoom">
<room-actions :id="id"/> <room-actions :id="id"/>
</widget-footer> </widget-footer>
</div> </div>
@ -49,7 +51,7 @@
return `room-widget--${this.appearance}`; return `room-widget--${this.appearance}`;
}, },
canEditRoom() { canEditRoom() {
return this.me.permissions.includes('users.can_manage_school_class_content'); return this.me.isTeacher && !this.me.readOnly && !this.me.selectedClass.readOnly;
} }
}, },

View File

@ -20,6 +20,7 @@ fragment UserParts on PrivateUserNode {
} }
selectedClass { selectedClass {
id id
readOnly
} }
recentModules(orderBy: "-visited") { recentModules(orderBy: "-visited") {
edges { edges {

View File

@ -6,7 +6,9 @@
{{ room.description }} {{ room.description }}
</p> </p>
<div class="room__meta"> <div class="room__meta">
<room-actions :id="room.id"/> <room-actions
:id="room.id"
v-if="canEdit"/>
<room-group-widget v-bind="room.schoolClass"/> <room-group-widget v-bind="room.schoolClass"/>
<entry-count-widget :entry-count="roomEntryCount"/> <entry-count-widget :entry-count="roomEntryCount"/>
</div> </div>
@ -14,7 +16,7 @@
<div class="room__content"> <div class="room__content">
<add-room-entry-button <add-room-entry-button
:parent="room" :parent="room"
v-if="room.id"> v-if="room.id && canEdit">
<!-- <!--
the v-if is there for the case where the room hasn't loaded yet, but there is already an attempt to create the v-if is there for the case where the room hasn't loaded yet, but there is already an attempt to create
a new room entry. mainly happens during cypress testing, but could also happen on a very slow connection a new room entry. mainly happens during cypress testing, but could also happen on a very slow connection
@ -30,11 +32,18 @@
<script> <script>
import ROOM_ENTRIES_QUERY from '@/graphql/gql/queries/roomEntriesQuery.gql'; import ROOM_ENTRIES_QUERY from '@/graphql/gql/queries/roomEntriesQuery.gql';
import roomMixin from '@/mixins/room'; import room from '@/mixins/room';
import me from '@/mixins/me';
export default { export default {
props: ['slug'], props: ['slug'],
mixins: [roomMixin], mixins: [room, me],
computed: {
canEdit() {
return !this.me.readOnly && !this.me.selectedClass.readOnly;
}
},
apollo: { apollo: {
modules: { modules: {
@ -59,5 +68,5 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_room.scss"; @import "~styles/room";
</style> </style>

View File

@ -44,7 +44,7 @@
return this.me.selectedClass.id; return this.me.selectedClass.id;
}, },
canAddRoom() { canAddRoom() {
return this.me.permissions.includes('users.can_manage_school_class_content'); return this.me.isTeacher && !this.me.readOnly && !this.me.selectedClass.readOnly;
} }
}, },
@ -70,7 +70,7 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/_mixins.scss"; @import "~styles/helpers";
.rooms-page { .rooms-page {
display: flex; display: flex;

View File

@ -24,7 +24,7 @@ class UpdateSolutionVisibility(relay.ClientIDMutation):
slug = args.get('slug') slug = args.get('slug')
enabled = args.get('enabled') enabled = args.get('enabled')
user = info.context.user user = info.context.user
selected_class = user.selected_class() selected_class = user.selected_class
if 'users.can_manage_school_class_content' not in user.get_role_permissions(): if 'users.can_manage_school_class_content' not in user.get_role_permissions():
raise PermissionError() raise PermissionError()

View File

@ -45,7 +45,7 @@ class ModuleNode(DjangoObjectType):
return self.get_parent().specific return self.get_parent().specific
def resolve_solutions_enabled(self, info, **kwargs): def resolve_solutions_enabled(self, info, **kwargs):
school_class = info.context.user.selected_class() school_class = info.context.user.selected_class
return self.solutions_enabled_for.filter(pk=school_class.pk).exists() if school_class is not None else False return self.solutions_enabled_for.filter(pk=school_class.pk).exists() if school_class is not None else False
def resolve_bookmark(self, info, **kwags): def resolve_bookmark(self, info, **kwags):

View File

@ -30,7 +30,7 @@ class ModuleSolutionVisibilityTest(TestCase):
) )
self.teacher = User.objects.get(username="teacher") self.teacher = User.objects.get(username="teacher")
self.selected_class = self.teacher.selected_class() self.selected_class = self.teacher.selected_class
self.student = User.objects.get(username="student1") self.student = User.objects.get(username="student1")
student_request = RequestFactory().get('/') student_request = RequestFactory().get('/')
student_request.user = self.student student_request.user = self.student
@ -54,7 +54,7 @@ class ModuleSolutionVisibilityTest(TestCase):
""" """
def test_hide_solutions_for_students_and_then_show_them(self): def test_hide_solutions_for_students_and_then_show_them(self):
self.assertEqual(self.student.selected_class(), self.teacher.selected_class()) self.assertEqual(self.student.selected_class, self.teacher.selected_class)
student_result = self.student_client.execute(self.query, variables={ student_result = self.student_client.execute(self.query, variables={
'id': self.content_block_id 'id': self.content_block_id

View File

@ -3,7 +3,7 @@ from users.models import User
def are_solutions_enabled_for(user: User, module: Module): def are_solutions_enabled_for(user: User, module: Module):
school_class = user.selected_class() school_class = user.selected_class
return module.solutions_enabled_for.filter(pk=school_class.pk).exists() return module.solutions_enabled_for.filter(pk=school_class.pk).exists()

View File

@ -370,7 +370,6 @@ type CreateTeamPayload {
} }
type CustomMutation { type CustomMutation {
redeemCoupon(input: CouponInput!): CouponPayload
spellCheck(input: SpellCheckInput!): SpellCheckPayload spellCheck(input: SpellCheckInput!): SpellCheckPayload
addNote(input: AddNoteInput!): AddNotePayload addNote(input: AddNoteInput!): AddNotePayload
updateNote(input: UpdateNoteInput!): UpdateNotePayload updateNote(input: UpdateNoteInput!): UpdateNotePayload
@ -920,6 +919,7 @@ type SchoolClassNode implements Node {
rooms(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, appearance: String): RoomNodeConnection! rooms(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, appearance: String): RoomNodeConnection!
pk: Int pk: Int
members: [ClassMemberNode] members: [ClassMemberNode]
readOnly: Boolean
} }
type SchoolClassNodeConnection { type SchoolClassNodeConnection {

View File

@ -7,9 +7,11 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Permission from django.contrib.auth.models import AbstractUser, Permission
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import models from django.db import models
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, is_aware from django.utils.timezone import make_aware, is_aware
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from typing import Union
from users.licenses import MYSKILLBOX_LICENSES from users.licenses import MYSKILLBOX_LICENSES
from users.managers import RoleManager, UserRoleManager, UserManager, LicenseManager from users.managers import RoleManager, UserRoleManager, UserManager, LicenseManager
@ -75,7 +77,8 @@ class User(AbstractUser):
def is_teacher(self): def is_teacher(self):
return self.user_roles.filter(role__key='teacher').exists() return self.user_roles.filter(role__key='teacher').exists()
def selected_class(self): @cached_property
def selected_class(self) -> Union['SchoolClass', None]:
try: try:
settings = UserSetting.objects.get(user=self) settings = UserSetting.objects.get(user=self)
return settings.selected_class return settings.selected_class

View File

@ -4,7 +4,7 @@ import graphene
from django.db.models import Q from django.db.models import Q
from django.utils.dateformat import format from django.utils.dateformat import format
from django_filters import FilterSet, OrderingFilter from django_filters import FilterSet, OrderingFilter
from graphene import relay, ObjectType from graphene import ObjectType, relay
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 graphql_relay import to_global_id
@ -13,13 +13,14 @@ 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, SchoolClassMember, Team from users.models import SchoolClass, SchoolClassMember, Team, User
class SchoolClassNode(DjangoObjectType): class SchoolClassNode(DjangoObjectType):
pk = graphene.Int() pk = graphene.Int()
members = graphene.List('users.schema.ClassMemberNode') members = graphene.List('users.schema.ClassMemberNode')
code = graphene.String() code = graphene.String()
read_only = graphene.Boolean()
class Meta: class Meta:
model = SchoolClass model = SchoolClass
@ -32,13 +33,19 @@ class SchoolClassNode(DjangoObjectType):
def resolve_members(self, info, **kwargs): def resolve_members(self, info, **kwargs):
return SchoolClassMember.objects.filter(school_class=self) return SchoolClassMember.objects.filter(school_class=self)
def resolve_code(self, info, **kwargs): def resolve_code(self: SchoolClass, info, **kwargs):
if not info.context.user.is_teacher(): if not info.context.user.is_teacher():
return None return None
if self.code is None: if self.code is None:
self.generate_code() self.generate_code()
return self.code return self.code
@staticmethod
def resolve_read_only(root: SchoolClass, info, **kwargs):
user = info.context.user
member = SchoolClassMember.objects.get(user=user, school_class=root)
return not member.active
class TeamNode(DjangoObjectType): class TeamNode(DjangoObjectType):
class Meta: class Meta:
@ -107,25 +114,28 @@ class PrivateUserNode(DjangoObjectType):
def resolve_permissions(self, info): def resolve_permissions(self, info):
return self.get_all_permissions() return self.get_all_permissions()
def resolve_selected_class(self, info): @staticmethod
return self.selected_class() def resolve_selected_class(root: User, info):
return root.selected_class
def resolve_expiry_date(self, info): @staticmethod
if not self.hep_id: # concerns users that already have an (old) account def resolve_expiry_date(root: User, info):
if not root.hep_id: # concerns users that already have an (old) account
return format(datetime(2020, 7, 31), 'U') # just set some expiry date return format(datetime(2020, 7, 31), 'U') # just set some expiry date
else: else:
return self.license_expiry_date return root.license_expiry_date
def resolve_is_teacher(self, info): def resolve_is_teacher(root: User, info):
return self.is_teacher() return root.is_teacher()
def resolve_school_classes(self, info): @staticmethod
if self.selected_class() is None: # then we don't have any class to return def resolve_school_classes(root: User, info):
if root.selected_class is None: # then we don't have any class to return
return SchoolClass.objects.none() return SchoolClass.objects.none()
return SchoolClass.objects.filter( return SchoolClass.objects.filter(
Q(schoolclassmember__active=True, schoolclassmember__user=self) | Q(pk=self.selected_class().pk)).distinct() Q(schoolclassmember__active=True, schoolclassmember__user=root) | Q(pk=root.selected_class.pk)).distinct()
def resolve_old_classes(self, info): def resolve_old_classes(self: User, info):
return SchoolClass.objects.filter(schoolclassmember__active=False, schoolclassmember__user=self) return SchoolClass.objects.filter(schoolclassmember__active=False, schoolclassmember__user=self)
def resolve_recent_modules(self, info, **kwargs): def resolve_recent_modules(self, info, **kwargs):

View File

@ -41,7 +41,7 @@ class JoinSchoolClassTest(TestCase):
self.assertEqual(result['success'], True) self.assertEqual(result['success'], True)
self.assertEqual(result['schoolClass']['name'], 'Klasse 2B') self.assertEqual(result['schoolClass']['name'], 'Klasse 2B')
self.assertEqual(self.user.school_classes.count(), 2) self.assertEqual(self.user.school_classes.count(), 2)
self.assertEqual(self.user.selected_class().code, 'YYYY') self.assertEqual(self.user.selected_class.code, 'YYYY')
def test_class_already_joined(self): def test_class_already_joined(self):
code = 'YYYY' code = 'YYYY'

View File

@ -82,3 +82,5 @@ class MySchoolClasses(TestCase):
self.assertEqual(len(old_classes), 1) self.assertEqual(len(old_classes), 1)

View File

@ -6,6 +6,7 @@ from graphql_relay import to_global_id
from api.schema import schema from api.schema import schema
from api.utils import get_graphql_mutation, get_object from api.utils import get_graphql_mutation, get_object
from core.factories import UserFactory, TeacherFactory from core.factories import UserFactory, TeacherFactory
from core.tests.base_test import SkillboxTestCase
from users.models import SchoolClass, User from users.models import SchoolClass, User
from users.services import create_users from users.services import create_users
@ -38,18 +39,14 @@ class SchoolClassesTest(TestCase):
self.assertEqual(f'{self.prefix} {user.pk}', class_name) self.assertEqual(f'{self.prefix} {user.pk}', class_name)
class ModifySchoolClassTest(TestCase): class ModifySchoolClassTest(SkillboxTestCase):
def setUp(self): def setUp(self):
create_users() create_users()
self.teacher = User.objects.get(username='teacher') self.teacher = User.objects.get(username='teacher')
self.student = User.objects.get(username='student1') self.student = User.objects.get(username='student1')
request = RequestFactory().get('/') self.client = self.get_client(user=self.teacher)
request.user = self.teacher self.student_client = self.get_client(user=self.student)
student_request = RequestFactory().get('/')
student_request.user = self.student
self.client = Client(schema=schema, context_value=request)
self.student_client = Client(schema=schema, context_value=student_request)
def test_update_school_class(self): def test_update_school_class(self):
class_name = 'The Colbert Show' class_name = 'The Colbert Show'
@ -115,7 +112,7 @@ class ModifySchoolClassTest(TestCase):
school_class = get_object(SchoolClass, id) school_class = get_object(SchoolClass, id)
self.assertEqual(school_class.name, class_name) self.assertEqual(school_class.name, class_name)
self.assertEqual(school_class.get_teacher(), self.teacher) self.assertEqual(school_class.get_teacher(), self.teacher)
self.assertEqual(self.teacher.selected_class().name, class_name) self.assertEqual(self.teacher.selected_class.name, class_name)
def test_create_school_class_fail(self): def test_create_school_class_fail(self):
self.assertEqual(SchoolClass.objects.count(), 2) self.assertEqual(SchoolClass.objects.count(), 2)

View File

@ -1,29 +1,18 @@
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase, RequestFactory
from graphene.test import Client
from graphql_relay import to_global_id from graphql_relay import to_global_id
from api.schema import schema
from core.factories import UserFactory from core.factories import UserFactory
from core.tests.base_test import SkillboxTestCase
from users.factories import SchoolClassFactory from users.factories import SchoolClassFactory
from users.models import UserSetting from users.models import SchoolClassMember, UserSetting
class UserSettingTests(TestCase): class UserSettingTests(SkillboxTestCase):
def setUp(self): def setUp(self):
self.user = UserFactory(username='aschi') self.user = UserFactory(username='aschi')
self.class1 = SchoolClassFactory(users=[self.user]) self.class1 = SchoolClassFactory(users=[self.user])
self.class2 = SchoolClassFactory(users=[self.user]) self.class2 = SchoolClassFactory(users=[self.user])
self.class3 = SchoolClassFactory(users=[]) self.class3 = SchoolClassFactory(users=[])
request = RequestFactory().get('/')
request.user = self.user
# adding session
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
self.client = Client(schema=schema, context_value=request)
def make_mutation(self, class_id): def make_mutation(self, class_id):
mutation = ''' mutation = '''
@ -37,7 +26,7 @@ class UserSettingTests(TestCase):
} }
''' '''
return self.client.execute(mutation, variables={ return self.get_client(user=self.user).execute(mutation, variables={
'input': { 'input': {
'id': to_global_id('SchoolClassNode', class_id) 'id': to_global_id('SchoolClassNode', class_id)
} }
@ -50,18 +39,20 @@ class UserSettingTests(TestCase):
selectedClass { selectedClass {
name name
id id
readOnly
} }
} }
} }
''' '''
return self.client.execute(query) return self.get_client(user=self.user).execute(query)
def test_selects_first_class_on_first_call(self): def test_selects_first_class_on_first_call(self):
result = self.make_query() result = self.make_query()
first_class = self.user.school_classes.first() first_class = self.user.school_classes.first()
self.assertIsNone(result.get('errors')) self.assertIsNone(result.get('errors'))
self.assertEqual(result.get('data').get('me').get('selectedClass').get('name'), first_class.name) self.assertEqual(result.get('data').get('me').get('selectedClass').get('name'), first_class.name)
self.assertFalse(result.get('data').get('me').get('selectedClass').get('readOnly'))
def test_returns_selected_class(self): def test_returns_selected_class(self):
selected_class = self.user.school_classes.all()[1] selected_class = self.user.school_classes.all()[1]
@ -73,7 +64,6 @@ class UserSettingTests(TestCase):
selected_class.name) selected_class.name)
def test_user_can_select_class(self): def test_user_can_select_class(self):
selected_class = self.user.school_classes.first() selected_class = self.user.school_classes.first()
setting = UserSetting.objects.create(user=self.user, selected_class=selected_class) setting = UserSetting.objects.create(user=self.user, selected_class=selected_class)
setting.save() setting.save()
@ -87,7 +77,6 @@ class UserSettingTests(TestCase):
selected_class.name) selected_class.name)
def test_user_can_select_class_even_no_settings_exist(self): def test_user_can_select_class_even_no_settings_exist(self):
selected_class = self.user.school_classes.all()[1] selected_class = self.user.school_classes.all()[1]
mutation_result = self.make_mutation(selected_class.pk) mutation_result = self.make_mutation(selected_class.pk)
self.assertIsNone(mutation_result.get('errors')) self.assertIsNone(mutation_result.get('errors'))
@ -106,4 +95,17 @@ class UserSettingTests(TestCase):
mutation_result = self.make_mutation(self.class3.pk) mutation_result = self.make_mutation(self.class3.pk)
self.assertIsNotNone(mutation_result.get('errors')) self.assertIsNotNone(mutation_result.get('errors'))
def test_inactive_class_as_selected_class(self):
selected_class = self.class2
membership = SchoolClassMember.objects.get(user=self.user, school_class=selected_class)
self.assertTrue(membership.active)
membership.active = False
membership.save()
setting = UserSetting.objects.create(user=self.user, selected_class=selected_class)
setting.save()
result = self.make_query()
self.assertIsNone(result.get('errors'))
self.assertTrue(result.get('data').get('me').get('selectedClass').get('readOnly'))