Merged in feature/assignment-read-only (pull request #87)

Feature/assignment read only
This commit is contained in:
Ramon Wenger 2021-07-07 14:05:18 +00:00
commit fe3d8eb00c
26 changed files with 512 additions and 141 deletions

View File

@ -1,11 +1,11 @@
export default {
UUID: () => '123-456-789',
GenericStreamFieldType: () => 'GenericStreamFieldType',
GenericStreamFieldType: () => ({type: 'text_block', value: 'Generic Stream Field Type'}),
DateTime: () => '2021-01-01Z10:01:23',
SnapshotNode: () => ({
// id: ID!
// module: ModuleNode!
chapters: [],
chapters: [],
// chapters: [SnapshotChapterNode]
// hiddenContentBlocks(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ContentBlockNodeConnection!
// created: DateTime!
@ -13,13 +13,31 @@ export default {
// shared: Boolean!
// objectiveGroups: [SnapshotObjectiveGroupNode]
// hiddenObjectives(offset: Int, before: String, after: String, first: Int, last: Int, text: String): ObjectiveNodeConnection!
title: 'MockSnapshotTitle',
metaTitle: 'MockSnapshotMetaTitle'
title: 'MockSnapshotTitle',
metaTitle: 'MockSnapshotMetaTitle',
// heroImage: String
// changes: SnapshotChangesNode
// mine: Boolean
}),
ChapterNode: () => ({
slug: 'chapter-slug',
id: 'chapter-id',
title: 'chapter-title',
description: 'chapter-description',
}),
ContentBlockNode: () => ({
contents: [],
title: 'content-block-title',
slug: 'content-block-slug',
userCreated: false,
type: '',
id: 'content-block-id',
}),
AssignmentNode: () => ({
id: 'assignment-id',
title: 'Assignment Title',
assignment: 'Assignment Text',
solution: 'Assignment Solution',
}),
};

View File

@ -0,0 +1,68 @@
import mocks from '../../fixtures/mocks';
const myText = 'Mein Feedback';
const getOperations = ({readOnly}) => ({
MeQuery: {
me: {
onboardingVisited: true,
readOnly,
},
},
StudentSubmissions: {
studentSubmission: {
id: 'id',
text: 'Submission Text',
document: '',
student: {
firstName: 'Peter',
lastName: 'Student',
},
assignment: {
title: 'Assignment Title',
assignment: 'Assignment Text',
},
submissionFeedback: {
id: 'feedback-id',
text: myText,
final: true,
},
},
},
});
describe('Assignment feedback read-only - Teacher', () => {
beforeEach(() => {
cy.fakeLogin('nico.teacher', 'test');
cy.server();
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
mocks,
});
});
});
it('can not edit', () => {
cy.mockGraphqlOps({
operations: getOperations({readOnly: true}),
});
cy.visit('submission/submission-id');
// cy.get('.submission-form__textarea--readonly').as('textarea');
//
// cy.get('@textarea').invoke('val').should('contain', myText);
// cy.get('@textarea').should('have.attr', 'readonly');
cy.isSubmissionReadOnly(myText);
cy.canReopen(false);
});
it('can edit', () => {
cy.mockGraphqlOps({
operations: getOperations({readOnly: false}),
});
cy.visit('submission/submission-id');
cy.canReopen(false);
});
});

View File

@ -0,0 +1,115 @@
import mocks from '../../fixtures/mocks';
import minimalModule from '../../fixtures/module.minimal';
const module = {
...minimalModule,
chapters: [
{
contentBlocks: [
{
type: 'task',
contents: [
{
type: 'text_block',
value: {
text: 'hallo velo',
},
id: 'bla-123',
},
{
type: 'assignment',
value: {
title: 'assignment-title',
assignment: 'assignment-assignment',
id: 'some-assignment',
},
id: '123-456',
},
],
},
],
},
],
};
const myText = 'submission text';
const getOperations = ({final, readOnly}) => ({
MeQuery: {
me: {
onboardingVisited: true,
readOnly
},
},
ModuleDetailsQuery: {
module,
},
AssignmentQuery: {
assignment: {
submission: {
text: myText,
final,
document: '',
submissionFeedback: null
},
},
},
});
describe('Assignments read-only - Student', () => {
beforeEach(() => {
cy.fakeLogin('rahel.cueni', 'test');
cy.server();
cy.task('getSchema').then(schema => {
cy.mockGraphql({
schema,
mocks
});
});
});
it('can edit and turn in', () => {
cy.mockGraphqlOps({
operations: getOperations({final: false, readOnly: false})
});
cy.visit('module/module-with-assignment');
cy.get('.submission-form__textarea').as('textarea');
cy.get('@textarea').invoke('val').should('contain', myText);
cy.get('@textarea').clear().type('Hello');
cy.get('@textarea').should('not.have.attr', 'readonly');
cy.getByDataCy('submission-form-submit').should('exist');
});
it('can not edit or turn in', () => {
cy.mockGraphqlOps({
operations: getOperations({final: false, readOnly: true})
});
cy.visit('module/module-with-assignment');
cy.isSubmissionReadOnly(myText);
cy.canReopen(false);
});
it('can revoke turn in', () => {
cy.mockGraphqlOps({
operations: getOperations({final: true, readOnly: false})
});
cy.visit('module/module-with-assignment');
cy.getByDataCy('final-submission').should('exist');
cy.getByDataCy('final-submission-reopen').should('exist');
});
it('can not revoke turn in', () => {
cy.mockGraphqlOps({
operations: getOperations({final: true, readOnly: true})
});
cy.visit('module/module-with-assignment');
cy.getByDataCy('final-submission').should('exist');
cy.canReopen(false);
});
});

View File

@ -35,16 +35,16 @@ Cypress.Commands.add('apolloLogin', (username, password) => {
'variables': {
'input': {
'usernameInput': username,
'passwordInput': password
}
'passwordInput': password,
},
},
'query': 'mutation BetaLogin($input: BetaLoginInput!) {\n betaLogin(input: $input) {\n success\n __typename\n }\n}\n'
'query': 'mutation BetaLogin($input: BetaLoginInput!) {\n betaLogin(input: $input) {\n success\n __typename\n }\n}\n',
};
cy.request({
method: 'POST',
url: '/api/graphql-public/',
body: payload
body: payload,
});
});
@ -78,8 +78,8 @@ Cypress.Commands.add('loginByCsrf', (username, password, csrftoken) => {
body: {
username: username,
password: password,
csrfmiddlewaretoken: csrftoken
}
csrfmiddlewaretoken: csrftoken,
},
});
});
@ -189,3 +189,16 @@ Cypress.Commands.add('fakeLogin', () => {
cy.log('Logging in (fake)');
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) => {
cy.get('.submission-form__textarea--readonly').as('textarea');
cy.get('@textarea').invoke('val').should('contain', myText);
cy.get('@textarea').should('have.attr', 'readonly');
cy.getByDataCy('submission-form-submit').should('not.exist');
});

View File

@ -23,5 +23,9 @@ declare namespace Cypress {
login(username: string, password: string, visitLogin?: boolean): void
fakeLogin(username: string, password: string): void
canReopen(exists: boolean): void
isSubmissionReadOnly(myText: string): void
}
}

View File

@ -16,6 +16,7 @@
:spellcheck-loading="spellcheckLoading"
:saved="!unsaved"
:spellcheck="true"
:read-only="me.readOnly"
placeholder="Ergebnis erfassen"
action="Ergebnis mit Lehrperson teilen"
shared-msg="Das Ergebnis wurde mit der Lehrperson geteilt."

View File

@ -1,5 +1,7 @@
<template>
<div class="final-submission">
<div
class="final-submission"
data-cy="final-submission">
<document-block
:value="{url: userInput.document}"
class="final-submission__document"
@ -10,6 +12,8 @@
<span class="final-submission__explanation-text">{{ sharedMsg }}</span>
<a
class="final-submission__reopen"
data-cy="final-submission-reopen"
v-if="showReopen"
@click="$emit('reopen')">Bearbeiten</a>
</div>
</div>
@ -21,7 +25,20 @@
import {newLineToParagraph} from '@/helpers/text';
export default {
props: ['userInput', 'sharedMsg'],
props: {
userInput: {
type: Object,
default: () => ({})
},
showReopen: {
type: Boolean,
default: true
},
sharedMsg: {
type: String,
default: ''
}
},
components: {
InfoIcon,
@ -37,9 +54,7 @@
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
@import "@/styles/_functions.scss";
@import "~styles/helpers";
.final-submission {
&__text {
@ -66,18 +81,21 @@
display: flex;
align-items: center;
}
&__explanation-icon {
width: 40px;
height: 40px;
fill: $color-brand;
margin-right: 8px;
}
&__explanation-text {
color: $color-brand;
font-family: $sans-serif-font-family;
font-weight: $font-weight-regular;
margin-right: $medium-spacing;
}
&__reopen {
@include small-text;
cursor: pointer;

View File

@ -4,18 +4,18 @@
<submission-input
:input-text="userInput.text"
:saved="saved"
:final="final"
:readonly="isReadOnly"
:placeholder="placeholder"
:reopen="reopenSubmission"
@input="saveInput"
/>
</div>
<div
class="submission-form-container__actions"
v-if="!final">
v-if="!isReadOnly">
<button
class="submission-form-container__submit button button--primary button--white-bg"
data-cy="submission-form-submit"
@click="$emit('turnIn')"
>{{ action }}
</button>
@ -45,7 +45,8 @@
<final-submission
:user-input="userInput"
:shared-msg="sharedMsg"
v-if="final"
:show-reopen="!readOnly"
v-if="isFinalOrReadOnly"
@reopen="$emit('reopen')"/>
</div>
</template>
@ -64,6 +65,10 @@
action: String,
reopen: Function,
document: String,
readOnly: {
type: Boolean,
default: false
},
spellcheck: {
type: Boolean,
default: false
@ -86,6 +91,9 @@
final() {
return !!this.userInput && this.userInput.final;
},
isFinalOrReadOnly() {
return this.final || this.readOnly;
},
allowsDocuments() {
return 'document' in this.userInput;
},
@ -117,7 +125,7 @@
</script>
<style scoped lang="scss">
@import '@/styles/_mixins.scss';
@import '~styles/helpers';
.submission-form-container {

View File

@ -3,8 +3,9 @@
<textarea
v-auto-grow
:placeholder="placeholder"
:readonly="final"
:readonly="readonly"
:value="inputText"
:class="{'submission-form__textarea--readonly': readonly}"
rows="1"
class="submission-form__textarea"
@input="$emit('input', $event.target.value)"
@ -30,7 +31,7 @@
props: {
inputText: String,
saved: Boolean,
final: Boolean,
readonly: Boolean,
placeholder: {
type: String,
default: 'Ergebnis erfassen'

View File

@ -31,7 +31,7 @@
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "~styles/helpers";
.assignment-form {
display: grid;

View File

@ -9,6 +9,7 @@ fragment UserParts on PrivateUserNode {
lastName
avatarUrl
expiryDate
readOnly
lastModule {
id
slug

View File

@ -22,6 +22,7 @@
<submission-form
:user-input="feedback"
:saved="!unsaved"
:read-only="readOnly"
placeholder="Feedback erfassen"
action="Feedback teilen"
shared-msg="Dieses Feedback wurde geteilt."
@ -55,32 +56,36 @@
import UPDATE_FEEDBACK_WITH_TEXT_MUTATION from '@/graphql/gql/mutations/updateFeedbackWithText.gql';
import SubmissionForm from '@/components/content-blocks/assignment/SubmissionForm';
import me from '@/mixins/me';
export default {
mixins: [me],
components: {
StudentSubmissionDocument,
SubmissionForm
SubmissionForm,
},
data() {
return {
studentSubmission: {
assignment: {
title: ''
title: '',
},
student: {
firstName: '',
lastName: ''
lastName: '',
},
text: '',
document: '',
submissionFeedback: {
text: '',
final: false
}
final: false,
},
},
unsaved: false,
saving: 0,
emojis: ['👍', '👎', '🙂', '😐', '😕', '🙁', '😮', '😉', '🙄', '❕', '❔', '🧐', '🤩', '🤗', '🤬', '🤢']
emojis: ['👍', '👎', '🙂', '😐', '😕', '🙁', '😮', '😉', '🙄', '❕', '❔', '🧐', '🤩', '🤗', '🤬', '🤢'],
};
},
@ -97,8 +102,11 @@
feedback() {
return this.studentSubmission.submissionFeedback ? this.studentSubmission.submissionFeedback : {
text: '',
final: false
final: false,
};
},
readOnly() {
return this.me.readOnly;
}
},
@ -108,7 +116,7 @@
query: STUDENT_SUBMISSIONS_QUERY,
variables() {
return {
id: this.$route.params.id
id: this.$route.params.id,
};
},
result({data: {studentSubmission}}) {
@ -116,7 +124,7 @@
this.create();
}
this.studentSubmission = cloneDeep(studentSubmission); // we don't want to update the value when the server updates
}
},
};
},
},
@ -135,10 +143,10 @@
submissionFeedback: {
studentSubmission: this.studentSubmission.id,
text: this.studentSubmission.submissionFeedback.text,
}
}
},
},
},
update: this.updateCache
update: this.updateCache,
}).then(() => {
this.saving--;
if (this.saving === 0) {
@ -155,53 +163,52 @@
this.updateFeedbackText(feedbackText);
this._save();
},
create() {
update({withText, text, final}) {
if (this.readOnly) {
this.$log.debug('read-only');
return;
}
let mutation = withText ? UPDATE_FEEDBACK_WITH_TEXT_MUTATION : UPDATE_FEEDBACK_MUTATION;
this.$apollo.mutate({
mutation: UPDATE_FEEDBACK_WITH_TEXT_MUTATION,
mutation,
variables: {
input: {
submissionFeedback: {
studentSubmission: this.studentSubmission.id,
text: '',
final: false
}
}
text: text,
final: final,
},
},
},
update: this.updateCache
update: this.updateCache,
});
},
create() {
this.$log.debug('create');
this.update({
withText: true,
text: '',
final: false,
});
},
turnIn() {
this.$apollo.mutate({
mutation: UPDATE_FEEDBACK_WITH_TEXT_MUTATION,
variables: {
input: {
submissionFeedback: {
studentSubmission: this.studentSubmission.id,
text: this.studentSubmission.submissionFeedback.text,
final: true
}
}
},
update: this.updateCache
this.$log.debug('turnIn');
this.update({
withText: true,
text: this.studentSubmission.submissionFeedback.text,
final: true,
});
},
reopen() {
this.$log.debug('reopen');
if (!this.studentSubmission.id) {
return;
}
this.$apollo.mutate({
mutation: UPDATE_FEEDBACK_MUTATION,
variables: {
input: {
submissionFeedback: {
studentSubmission: this.studentSubmission.id,
text: this.studentSubmission.submissionFeedback.text,
final: false
}
}
},
update: this.updateCache
this.update({
withText: false,
text: this.studentSubmission.submissionFeedback.text,
final: false,
});
},
updateCache(store, {data: {updateSubmissionFeedback: {successful, updatedSubmissionFeedback}}}) {
@ -209,20 +216,20 @@
if (successful) {
const query = STUDENT_SUBMISSIONS_QUERY;
const variables = {
id: this.studentSubmission.id
id: this.studentSubmission.id,
};
const data = store.readQuery({query, variables});
if (data) {
if (!data.studentSubmission.submissionFeedback) {
data.studentSubmission.submissionFeedback = {
'__typename': 'SubmissionFeedbackNode'
'__typename': 'SubmissionFeedbackNode',
};
}
data.studentSubmission.submissionFeedback = Object.assign({}, data.studentSubmission.submissionFeedback, {
id: updatedSubmissionFeedback.id,
final: updatedSubmissionFeedback.final
final: updatedSubmissionFeedback.final,
});
if (updatedSubmissionFeedback.text !== undefined) {
@ -242,16 +249,15 @@
},
updateFeedbackText(text) {
this.studentSubmission = Object.assign({}, this.studentSubmission, {
submissionFeedback: Object.assign({}, this.studentSubmission.submissionFeedback, {text: text})
submissionFeedback: Object.assign({}, this.studentSubmission.submissionFeedback, {text: text}),
});
}
},
},
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_functions.scss";
@import "~styles/helpers";
.submission-page {
&__content {

View File

@ -23,13 +23,17 @@ class UpdateAssignment(relay.ClientIDMutation):
def mutate_and_get_payload(cls, root, info, **kwargs):
assignment_data = kwargs.get('assignment')
assignment = get_object(Assignment, assignment_data.get('id'))
student = info.context.user
if student.read_only:
raise PermissionError('No valid license')
try:
(submission, _created) = assignment.submissions.get_or_create(student=info.context.user)
(submission, _created) = assignment.submissions.get_or_create(student=student)
except MultipleObjectsReturned:
for submission in assignment.submissions.filter(student=info.context.user):
for submission in assignment.submissions.filter(student=student):
submission.delete()
submission = assignment.submissions.create(student=info.context.user)
submission = assignment.submissions.create(student=student)
submission.text = assignment_data.get('answer', '')
submission.document = assignment_data.get('document', '')
final = assignment_data.get('final')
@ -56,6 +60,9 @@ class UpdateSubmissionFeedback(relay.ClientIDMutation):
if not user.has_perm('users.can_manage_school_class_content'):
raise PermissionDenied('Missing permissions')
if user.read_only:
raise PermissionError('No valid license')
(submission_feedback, created) = SubmissionFeedback.objects.get_or_create(teacher=user,
student_submission_id=student_submission_id)

View File

@ -0,0 +1,30 @@
UPDATE_ASSIGNMENT_MUTATION = '''
mutation UpdateAssignment($input: UpdateAssignmentInput!) {
updateAssignment(input: $input){
updatedAssignment {
id
title
assignment
submission {
id
text
final
document
}
}
}
}
'''
UPDATE_SUBMISSION_FEEDBACK_MUTATION = '''
mutation UpdateSubmissionFeedback($input: UpdateSubmissionFeedbackInput!) {
updateSubmissionFeedback(input: $input){
updatedSubmissionFeedback {
id
text
final
}
}
}
'''

View File

@ -2,6 +2,7 @@ from graphql_relay import to_global_id
from api.test_utils import create_client, DefaultUserTestCase
from assignments.models import Assignment, StudentSubmission
from .queries.mutations import UPDATE_ASSIGNMENT_MUTATION
from ..factories import AssignmentFactory
@ -16,31 +17,14 @@ class AssignmentPermissionsTestCase(DefaultUserTestCase):
self.module_id = to_global_id('ModuleNode', self.assignment.module.pk)
def _submit_submission(self, user=None):
mutation = '''
mutation UpdateAssignment($input: UpdateAssignmentInput!) {
updateAssignment(input: $input){
updatedAssignment {
id
title
assignment
submission {
id
text
final
document
}
}
}
}
'''
if user is None:
client = create_client(self.student1)
else:
client = create_client(user)
return client.execute(mutation, variables={
return client.execute(UPDATE_ASSIGNMENT_MUTATION, variables={
'input': {
"assignment": {
"id": self.assignment_id,

View File

@ -5,24 +5,10 @@ from graphql_relay import to_global_id
from api.schema import schema
from assignments.factories import AssignmentFactory
from assignments.models import StudentSubmission
from assignments.tests.queries.mutations import UPDATE_ASSIGNMENT_MUTATION
from users.models import User
from users.services import create_users
UPDATE_ASSIGNMENT_MUTATION = """
mutation UpdateAssignment($input: UpdateAssignmentInput!) {
updateAssignment(input: $input) {
updatedAssignment {
id
submission {
id
text
final
}
}
}
}
"""
class DuplicateStudentSubmissionsTestCase(TestCase):
def setUp(self):

View File

@ -12,14 +12,16 @@ from graphql_relay import to_global_id
from api.test_utils import create_client, DefaultUserTestCase
from assignments.models import Assignment, StudentSubmission
from core.tests.base_test import SkillboxTestCase
from users.factories import SchoolClassFactory
from users.models import SchoolClassMember
from .queries.mutations import UPDATE_SUBMISSION_FEEDBACK_MUTATION
from ..factories import AssignmentFactory, StudentSubmissionFactory, SubmissionFeedbackFactory
class SubmissionFeedbackTestCase(DefaultUserTestCase):
class SubmissionFeedbackTestCase(SkillboxTestCase):
def setUp(self):
super(SubmissionFeedbackTestCase, self).setUp()
self.createDefault()
self.assignment = AssignmentFactory(
owner=self.teacher
@ -38,21 +40,7 @@ class SubmissionFeedbackTestCase(DefaultUserTestCase):
)
def _create_submission_feedback(self, user, final, text, student_submission_id):
mutation = '''
mutation UpdateSubmissionFeedback($input: UpdateSubmissionFeedbackInput!) {
updateSubmissionFeedback(input: $input){
updatedSubmissionFeedback {
id
text
final
}
}
}
'''
client = create_client(user)
return client.execute(mutation, variables={
return self.get_client(user).execute(UPDATE_SUBMISSION_FEEDBACK_MUTATION, variables={
'input': {
"submissionFeedback": {
"studentSubmission": student_submission_id,

View File

@ -0,0 +1,82 @@
from datetime import timedelta, date
from graphql_relay import to_global_id
from assignments.factories import AssignmentFactory, StudentSubmissionFactory
from assignments.tests.queries.mutations import UPDATE_ASSIGNMENT_MUTATION, UPDATE_SUBMISSION_FEEDBACK_MUTATION
from core.tests.base_test import SkillboxTestCase
from users.models import User
class AssignmentReadOnlyTestCase(SkillboxTestCase):
def setUp(self) -> None:
self.createDefault()
yesterday = date.today() - timedelta(days=1)
self.student1.license_expiry_date = yesterday
self.student1.save()
self.teacher.license_expiry_date = yesterday
self.teacher.save()
self.assignment = AssignmentFactory()
self.assignment_id = to_global_id('AssignmentNode', self.assignment.id)
def test_edit_assignment_fails(self):
variables = {
"input": {
"assignment": {
"answer": "bla",
"document": "",
"final": False,
"id": self.assignment_id
}
}
}
result = self.get_client(self.student1).execute(UPDATE_ASSIGNMENT_MUTATION, variables=variables)
self.assertIsNotNone(result.get('errors'))
def test_share_assignment_fails(self):
variables = {
"input": {
"assignment": {
"answer": "bla",
"document": "",
"final": True,
"id": self.assignment_id
}
}
}
result = self.get_client(self.student1).execute(UPDATE_ASSIGNMENT_MUTATION, variables=variables)
self.assertIsNotNone(result.get('errors'))
def test_edit_feedback_fails(self):
student_submission = StudentSubmissionFactory(assignment=self.assignment, student=self.student1,
final=True)
student_submission_id = to_global_id('StudentSubmissionNode', student_submission.id)
result = self.get_client(self.teacher).execute(UPDATE_SUBMISSION_FEEDBACK_MUTATION, variables={
'input': {
"submissionFeedback": {
"studentSubmission": student_submission_id,
"text": "Feedback",
"final": False
}
}
})
self.assertIsNotNone(result.get('errors'))
def test_share_feedback_fails(self):
student_submission = StudentSubmissionFactory(assignment=self.assignment, student=self.student1,
final=True)
student_submission_id = to_global_id('StudentSubmissionNode', student_submission.id)
result = self.get_client(self.teacher).execute(UPDATE_SUBMISSION_FEEDBACK_MUTATION, variables={
'input': {
"submissionFeedback": {
"studentSubmission": student_submission_id,
"text": "Feedback",
"final": True
}
}
})
self.assertIsNotNone(result.get('errors'))

View File

@ -10,6 +10,10 @@ class SkillboxTestCase(TestCase):
def createDefault(self) -> None:
create_users()
self.teacher = User.objects.get(username='teacher')
self.teacher2 = User.objects.get(username='teacher2')
self.student1 = User.objects.get(username='student1')
self.student2 = User.objects.get(username='student2')
self.student_second_class = User.objects.get(username='student_second_class')
def get_client(self, user=None) -> Client:
request = RequestFactory().get('/')

View File

@ -18,7 +18,7 @@ class MiddlewareTestCase(TestCase):
self.assertTrue(is_private_api_call_allowed(user, body))
def test_user_without_valid_license_cannot_see_private_api(self):
def test_user_with_expired_license_can_see_private_api(self):
yesterday = timezone.now() - timedelta(1)
user = UserFactory(username='aschiman@ch.ch', hep_id=23)
@ -26,6 +26,14 @@ class MiddlewareTestCase(TestCase):
body = b'"{mutation {\\n addRoom}"'
self.assertTrue(is_private_api_call_allowed(user, body))
def test_user_without_valid_license_cannot_see_private_api(self):
user = UserFactory(username='aschiman@ch.ch', hep_id=23)
user.license_expiry_date = None
body = b'"{mutation {\\n addRoom}"'
self.assertFalse(is_private_api_call_allowed(user, body))
def test_logout_is_allowed_without_valid_license(self):

View File

@ -44,7 +44,7 @@ def is_private_api_call_allowed(user, body):
license_expiry = user.license_expiry_date
# all other resources are denied if the license is not valid
if license_expiry is None or license_expiry < timezone.now().date():
if license_expiry is None:
return False
return True

View File

@ -788,6 +788,7 @@ type PrivateUserNode implements Node {
isTeacher: Boolean
oldClasses(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection
recentModules(offset: Int, before: String, after: String, first: Int, last: Int, recentModules: [ID], orderBy: String): ModuleNodeConnection
readOnly: Boolean
}
type PrivateUserNodeConnection {

View File

@ -1,7 +1,7 @@
import random
import re
import string
from datetime import datetime
from datetime import date, datetime
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Permission
@ -16,6 +16,10 @@ DEFAULT_SCHOOL_ID = 1
class User(AbstractUser):
LICENSE_NONE = 'no-license'
LICENSE_VALID = 'valid-license'
LICENSE_EXPIRED = 'expired-license'
last_module = models.ForeignKey('books.Module', related_name='+', on_delete=models.SET_NULL, null=True)
recent_modules = models.ManyToManyField('books.Module', related_name='+', through='books.RecentModule')
last_topic = models.ForeignKey('books.Topic', related_name='+', on_delete=models.SET_NULL, null=True)
@ -114,6 +118,17 @@ class User(AbstractUser):
def full_name(self):
return self.get_full_name()
@property
def read_only(self):
return self.get_license_status() == User.LICENSE_EXPIRED
def get_license_status(self):
if self.license_expiry_date is None:
return User.LICENSE_NONE
if self.license_expiry_date >= date.today():
return User.LICENSE_VALID
return User.LICENSE_EXPIRED
class Meta:
ordering = ['pk', ]

View File

@ -88,7 +88,7 @@ class PrivateUserNode(DjangoObjectType):
filter_fields = ['username', 'email']
only_fields = ['username', 'email', 'first_name', 'last_name', 'school_classes', 'last_module',
'last_topic', 'avatar_url',
'selected_class', 'expiry_date', 'onboarding_visited', 'team']
'selected_class', 'expiry_date', 'onboarding_visited', 'team', 'read_only']
interfaces = (relay.Node,)
pk = graphene.Int()
@ -99,6 +99,7 @@ class PrivateUserNode(DjangoObjectType):
old_classes = DjangoFilterConnectionField(SchoolClassNode)
recent_modules = DjangoFilterConnectionField(ModuleNode, filterset_class=RecentModuleFilter)
team = graphene.Field(TeamNode)
read_only = graphene.Boolean()
def resolve_pk(self, info, **kwargs):
return self.id

View File

@ -0,0 +1,23 @@
from datetime import timedelta, date
from core.factories import UserFactory
from core.tests.base_test import SkillboxTestCase
from users.models import User
class LicenseExpiredTestCase(SkillboxTestCase):
def setUp(self) -> None:
self.user = UserFactory(username='some_user')
def test_license_expired(self):
self.user.license_expiry_date = date.today() - timedelta(days=1)
self.user.save()
self.assertTrue(self.user.get_license_status(), User.LICENSE_EXPIRED)
def test_license_valid(self):
self.user.license_expiry_date = date.today() + timedelta(days=1)
self.user.save()
self.assertTrue(self.user.get_license_status(), User.LICENSE_VALID)
def test_license_none(self):
self.assertTrue(self.user.get_license_status(), User.LICENSE_NONE)

View File

@ -1,14 +1,3 @@
# -*- coding: utf-8 -*-
#
# ITerativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
#
# Created on 2019-10-02
# @author: chrigu <christian.cueni@iterativ.ch>
import json
import os
from datetime import timedelta
from unittest.mock import patch