Merged develop into master

This commit is contained in:
Ramon Wenger 2019-12-11 14:51:19 +00:00
commit 08608bf828
62 changed files with 14860 additions and 277 deletions

View File

@ -0,0 +1,39 @@
describe('Survey', () => {
beforeEach(() => {
cy.exec("python ../server/manage.py prepare_bookmarks_for_cypress");
cy.viewport('macbook-15');
cy.startGraphQLCapture();
cy.login('rahel.cueni', 'test', true);
cy.get('body').contains('Neues Wissen erwerben');
});
it('should bookmark content block', () => {
cy.visit('/module/lohn-und-budget/');
cy.get('.content-component').contains('Das folgende Interview').parent().parent().as('interviewContent');
cy.get('@interviewContent').within(() => {
cy.get('.bookmark-actions__bookmark').click();
cy.get('.bookmark-actions__add-note').click();
});
cy.get('[data-cy=bookmark-note]').within(() => {
cy.get('.skillbox-input').type('Hallo Velo');
});
cy.get('[data-cy=modal-save-button]').click();
cy.get('@interviewContent').within(() => {
cy.get('.bookmark-actions__edit-note').click();
});
cy.get('[data-cy=bookmark-note]').within(() => {
cy.get('.skillbox-input').clear().type('Hello Bike');
});
cy.get('[data-cy=modal-save-button]').click();
});
});

View File

@ -24,6 +24,7 @@
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
// todo: replace with apollo call
Cypress.Commands.add("login", (username, password, visitLogin=false) => {
if (visitLogin) {
cy.visit('/login');

View File

@ -13,7 +13,7 @@
import FullScreenLayout from '@/layouts/FullScreenLayout';
import PublicLayout from '@/layouts/PublicLayout';
import Modal from '@/components/Modal';
import MobileNavigation from '@/components/MobileNavigation';
import MobileNavigation from '@/components/book-navigation/MobileNavigation';
import NewContentBlockWizard from '@/components/content-block-form/NewContentBlockWizard';
import EditContentBlockWizard from '@/components/content-block-form/EditContentBlockWizard';
import NewRoomEntryWizard from '@/components/rooms/room-entries/NewRoomEntryWizard';

View File

@ -1,24 +1,35 @@
<template>
<div class="assignment-with-submissions">
<!--<h1 class="assignment-with-submissions__title">{{assignment.assignment}}</h1>-->
<h4 class="assignment-with-submissions__heading">Aufgabe</h4>
<p class="assignment-with-submissions__text">{{assignment.assignment}}</p>
<div>
<a class="button button--primary submissions-page__back" @click="$emit('back')">Aufgabe im Modul anzeigen</a>
</div>
<div class="assignment-with-submissions__solution" v-if="assignment.solution">
<h4 class="assignment-with-submissions__heading">Lösung</h4>
<p class="assignment-with-submissions__solution-text">{{assignment.solution}}</p>
</div>
<p v-if="!assignment.submissions.length">Zu diesem Auftrag sind noch keine Ergebnisse vorhanden</p>
<p class="assignment-with-submissions__no-submissions" v-if="!assignment.submissions.length">Zu diesem Auftrag sind noch keine Ergebnisse vorhanden</p>
<div v-if="assignment.submissions.length" class="assignment-with-submissions__submissions submissions">
<div class="submissions__header student-submission-row submission-header">
<p class="submission-header__title">Lernende</p>
<p class="submission-header__title">Ergebnisse</p>
<p class="submission-header__title">Feedback</p>
</div>
<router-link
:to="submissionLink(submission)"
v-for="(submission, index) in submissions"
v-for="submission in submissions"
class="assignment-with-submissions__link"
:key="index">
:key="submission.id">
<student-submission class="assignment-with-submissions__submission"
:submission="submission"
>
</student-submission>
</router-link>
</div>
</div>
</template>
@ -80,6 +91,7 @@
}
&__text {
font-size: toRem(26px);
margin-bottom: 1rem;
}
@ -95,10 +107,27 @@
&__link {
display: block;
&:first-of-type {
border-top: 1px solid $color-silver-dark;
}
}
&__submissions {
margin-top: 3rem;
}
&__no-submissions {
margin-top: $large-spacing;
}
}
.submissions {
width: 100%;
}
.submission-header {
&__title {
color: $color-silver-dark;
font-family: $sans-serif-font-family;
}
}
</style>

View File

@ -2,6 +2,14 @@
<div class="chapter">
<h3 :id="'chapter-' + index">{{chapter.title}}</h3>
<bookmark-actions
class="chapter__bookmark-actions"
@add-note="addNote"
@edit-note="editNote"
:bookmarked="chapter.bookmark"
@bookmark="bookmark(!chapter.bookmark)"
:note="note"
></bookmark-actions>
<p class="chapter__description">
{{chapter.description}}
</p>
@ -18,15 +26,20 @@
<script>
import ContentBlock from '@/components/ContentBlock';
import AddContentButton from '@/components/AddContentButton';
import BookmarkActions from '@/components/notes/BookmarkActions';
import {mapGetters} from 'vuex';
import {isHidden} from '@/helpers/content-block';
import {meQuery} from '@/graphql/queries';
import UPDATE_CHAPTER_BOOKMARK_MUTATION from '@/graphql/gql/mutations/updateChapterBookmark.gql';
import CHAPTER_QUERY from '@/graphql/gql/chapterQuery.gql';
export default {
props: ['chapter', 'index'],
components: {
BookmarkActions,
ContentBlock,
AddContentButton
},
@ -45,6 +58,12 @@
schoolClass() {
return this.me.selectedClass;
},
note() {
if (!(this.chapter && this.chapter.bookmark)) {
return;
}
return this.chapter.bookmark.note;
}
},
data() {
@ -53,6 +72,64 @@
}
},
methods: {
bookmark(bookmarked) {
const id = this.chapter.id;
this.$apollo.mutate({
mutation: UPDATE_CHAPTER_BOOKMARK_MUTATION,
variables: {
input: {
chapter: id,
bookmarked
}
},
update: (store, response) => {
const query = CHAPTER_QUERY;
const variables = {id};
const data = store.readQuery({
query,
variables
});
const chapter = data.chapter;
if (bookmarked) {
chapter.bookmark = {
__typename: 'ChapterBookmarkNode',
note: null
}
} else {
chapter.bookmark = null;
}
data.chapter = chapter;
store.writeQuery({
data,
query,
variables
});
},
optimisticResponse: {
__typename: 'Mutation',
updateChapterBookmark: {
__typename: 'UpdateChapterBookmarkPayload',
success: true
}
}
});
},
addNote(id) {
this.$store.dispatch('addNote', {
content: id,
parent: this.chapter.id
});
},
editNote() {
this.$store.dispatch('editNote', this.chapter.bookmark.note);
},
},
apollo: {
me: meQuery
}
@ -63,6 +140,12 @@
@import "@/styles/_mixins.scss";
.chapter {
position: relative;
&__bookmark-actions {
margin-top: 3px;
}
&__description {
@include lead-paragraph;

View File

@ -1,5 +1,5 @@
<template>
<div class="student-submission">
<div class="student-submission student-submission-row">
<div class="student-submission__student-name">
{{name}}
</div>
@ -9,6 +9,9 @@
<student-submission-document :document="submission.document" class="entry-document"></student-submission-document>
</p>
</div>
<div class="student-submission__feedback entry" v-if="submission.submissionFeedback">
<p class="entry__text" :class="{'entry__text--final': submission.submissionFeedback.final}">{{submission.submissionFeedback.text | trimToLength(50)}}</p>
</div>
</div>
</template>
@ -50,18 +53,13 @@
@import "@/styles/_functions.scss";
.student-submission {
display: grid;
grid-template-columns: 170px 1fr;
grid-column-gap: 80px;
align-items: center;
border-bottom: 1px solid $color-silver-dark;
padding: 15px 0;
&__student-name {
font-size: toRem(17px);
font-weight: 800;
font-family: $sans-serif-font-family;
}
&__entry {
font-size: toRem(14px);
font-family: $sans-serif-font-family;
@ -71,4 +69,13 @@
margin-top: 1rem;
}
}
.entry {
&__text {
color: $color-silver-dark;
&--final {
color: $color-charcoal-dark;
}
}
}
</style>

View File

@ -1,17 +1,34 @@
<template>
<nav class="top-navigation" :class="{'top-navigation--mobile': mobile}">
<router-link to="/book/topic/berufliche-grundbildung" active-class="top-navigation__link--active"
:class="{'top-navigation__link--active': isActive('book')}"
class="top-navigation__link">Inhalte
</router-link>
<router-link to="/rooms" active-class="top-navigation__link--active" class="top-navigation__link">Räume
</router-link>
<router-link to="/portfolio" active-class="top-navigation__link--active" class="top-navigation__link">Portfolio
</router-link>
<div class="top-navigation__item">
<router-link to="/book/topic/berufliche-grundbildung" active-class="top-navigation__link--active"
:class="{'top-navigation__link--active': isActive('book')}"
@click.native="hideMobileNavigation"
class="top-navigation__link">Inhalte
</router-link>
<mobile-subnavigation v-if="mobile"></mobile-subnavigation>
</div>
<div class="top-navigation__item">
<router-link to="/rooms" active-class="top-navigation__link--active" @click.native="hideMobileNavigation"
class="top-navigation__link">Räume
</router-link>
</div>
<div class="top-navigation__item">
<router-link to="/portfolio" active-class="top-navigation__link--active" @click.native="hideMobileNavigation"
class="top-navigation__link">Portfolio
</router-link>
</div>
</nav>
</template>
<script>
import MobileSubnavigation from '@/components/book-navigation/MobileSubnavigation';
export default {
props: {
mobile: {
@ -19,9 +36,16 @@
}
},
components: {
MobileSubnavigation
},
methods: {
isActive(linkName) {
return linkName === 'book' && this.$route.path.indexOf('module') > -1;
},
hideMobileNavigation() {
this.$store.dispatch('showMobileNavigation', false);
}
}
}
@ -52,13 +76,31 @@
flex-direction: column;
#{$parent}__link {
color: rgba($color-white, 0.6);
color: $color-white;
@include heading-4;
line-height: 2em;
line-height: 2.5em;
padding: 0;
display: block;
margin-bottom: 0.5*$small-spacing;
&--active {
color: $color-white;
&:only-child {
margin-bottom: 0;
}
}
#{$parent}__item {
border-bottom: 1px solid $color-white;
&:nth-child(1) {
order: 3;
border-bottom: 0;
}
&:nth-child(2) {
order: 1;
}
&:nth-child(3) {
order: 2;
}
}
}

View File

@ -4,8 +4,7 @@
<book-topic-navigation></book-topic-navigation>
</sub-navigation-item>
<sub-navigation-item title="Instrument">
<router-link tag="div" class="book-subnavigation__item" to="/instruments/sprache-kommunikation">Sprache und Kommunikation</router-link>
<router-link tag="div" class="book-subnavigation__item" to="/instruments/gesellschaft">Gesellschaft</router-link>
<instrument-navigation></instrument-navigation>
</sub-navigation-item>
<!--<sub-navigation-item title="News">-->
<!--<template slot="title">-->
@ -18,11 +17,13 @@
<script>
import SubNavigationItem from '@/components/book-navigation/SubNavigationItem';
import BookTopicNavigation from '@/components/book-navigation/BookTopicNavigation';
import InstrumentNavigation from '@/components/book-navigation/InstrumentNavigation';
export default {
components: {
SubNavigationItem,
BookTopicNavigation
BookTopicNavigation,
InstrumentNavigation
}
}
</script>

View File

@ -1,11 +1,12 @@
<template>
<nav class="book-topics">
<router-link :to="{name: 'topic', params: {topicSlug: topic.slug}}"
tag="div"
class="book-topics__topic book-subnavigation__item"
:class="{'book-topics__topic--active': topic.active}"
v-for="topic in topics"
:key="topic.id">
@click.native="hideMobileNavigation"
tag="div"
class="book-topics__topic book-subnavigation__item"
:class="{'book-topics__topic--active': topic.active, 'book-subnavigation__item--mobile': mobile}"
v-for="topic in topics"
:key="topic.id">
{{topic.order}}.
{{topic.title}}
</router-link>
@ -16,16 +17,24 @@
import ALL_TOPICS_QUERY from '@/graphql/gql/allTopicsQuery.gql';
export default {
props: {
mobile: {
default: false
}
},
data() {
return {
topics: [
]
topics: []
}
},
methods: {
topicId(id) {
return atob(id)
},
hideMobileNavigation() {
this.$store.dispatch('showMobileNavigation', false);
}
},

View File

@ -0,0 +1,31 @@
<template>
<div>
<router-link tag="div" class="book-subnavigation__item"
:class="{'book-subnavigation__item--mobile': mobile}"
@click.native="hideMobileNavigation"
to="/instruments/sprache-kommunikation">Sprache und
Kommunikation
</router-link>
<router-link tag="div" class="book-subnavigation__item"
:class="{'book-subnavigation__item--mobile': mobile}"
@click.native="hideMobileNavigation"
to="/instruments/gesellschaft">Gesellschaft
</router-link>
</div>
</template>
<script>
export default {
props: {
mobile: {
default: false
}
},
methods: {
hideMobileNavigation() {
this.$store.dispatch('showMobileNavigation', false);
}
}
}
</script>

View File

@ -64,9 +64,9 @@
display: grid;
grid-template-columns: 1fr 50px;
grid-template-rows: 50px 100px auto 100px;
grid-template-rows: 50px max-content auto 100px;
grid-template-areas: "m m" "m m" "s s";
grid-template-areas: "m m" "m m" "s s" "s s";
&--with-subnavigation {
grid-template-areas: "m m" "m m" "sub sub" "s s";

View File

@ -0,0 +1,44 @@
<template>
<div class="mobile-subnavigation">
<div class="mobile-subnavigation__section">
<h3 class="mobile-subnavigation__title">Themen</h3>
<book-topic-navigation :mobile="true"></book-topic-navigation>
</div>
<div class="mobile-subnavigation__section">
<h3 class="mobile-subnavigation__title">Instrumente</h3>
<instrument-navigation :mobile="true"></instrument-navigation>
</div>
</div>
</template>
<script>
import BookTopicNavigation from '@/components/book-navigation/BookTopicNavigation';
import InstrumentNavigation from '@/components/book-navigation/InstrumentNavigation';
export default {
components: {
BookTopicNavigation,
InstrumentNavigation
},
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.mobile-subnavigation {
&__title {
@include small-text;
color: rgba($color-white, 0.6);
margin-bottom: $small-spacing;
}
&__section {
margin-bottom: $medium-spacing;
}
}
</style>

View File

@ -49,12 +49,16 @@
.book-subnavigation {
&__item {
font-family: $sans-serif-font-family;
font-size: toRem(14px);
margin-bottom: $medium-spacing;
color: $color-silver-dark;
@include small-text;
margin-bottom: $small-spacing;
cursor: pointer;
color: $color-silver-dark;
&--mobile {
color: $color-white;
}
&:last-of-type {
margin-bottom: 0;
}

View File

@ -1,7 +1,7 @@
<template>
<div class="content-component" :class="{'content-component--bookmarked': bookmarked}">
<bookmark-actions
v-if="showBookmarkActions()"
v-if="showBookmarkActions"
@add-note="addNote(component.id)"
@edit-note="editNote"
@bookmark="bookmarkContent(component.id, !bookmarked)"
@ -72,6 +72,9 @@
note() {
const bookmark = this.bookmarks && this.bookmarks.find(bookmark => bookmark.uuid === this.component.id);
return bookmark && bookmark.note;
},
showBookmarkActions() {
return this.component.type !== 'content_list' && this.component.type !== 'basic_knowledge' && !this.editModule;
}
},
@ -136,9 +139,6 @@
}
}
});
},
showBookmarkActions() {
return this.component.type !== 'content_list' && this.component.type !== 'basic_knowledge' && !this.editModule;
}
}
};

View File

@ -7,39 +7,22 @@
<solution :value="solution" v-if="assignment.solution"></solution>
<template v-if="isStudent">
<div class="assignment__submission">
<submission-form
v-if="isStudent"
@turnIn="turnIn"
@saveInput="saveInput"
@reopen="reopen"
@changeDocumentUrl="changeDocumentUrl"
:user-input="submission"
placeholder="Ergebnis erfassen"
action="Ergebnis mit Lehrperson teilen"
shared-msg="Das Ergebnis wurde mit der Lehrperson geteilt."
:saved="!unsaved"
>
</submission-form>
<div class="assignment__inputs">
<submission-form
@input="saveInput"
:submission="submission"
:saved="!unsaved"
:final="final"
></submission-form>
</div>
<div class="assignment__actions" v-if="!final">
<button
class="assignment__submit button button--primary button--white-bg"
@click="turnIn"
>Ergebnis mit Lehrperson teilen
</button>
<div v-if="assignment.submission.document">
<document-block
:value="{url: assignment.submission.document}"
show-trash-icon
v-on:trash="changeDocumentUrl('')"
></document-block>
</div>
<simple-file-upload
v-on:link-change-url="changeDocumentUrl"
:value="assignment.submission.document"
></simple-file-upload>
</div>
<final-submission :submission="assignment.submission" v-if="final" @reopen="reopen"></final-submission>
<div v-if="this.assignment.submission.submissionFeedback" class="assignment__feedback">
<p>{{feedbackText}}</p>
</div>
</template>
<template v-if="!isStudent">
@ -59,6 +42,7 @@
import cloneDeep from 'lodash/cloneDeep'
import FinalSubmission from '@/components/content-blocks/assignment/FinalSubmission';
import SubmissionInput from '@/components/content-blocks/assignment/SubmissionInput';
import SubmissionForm from '@/components/content-blocks/assignment/SubmissionForm';
import DocumentForm from '@/components/content-forms/DocumentForm';
import DocumentBlock from '@/components/content-blocks/DocumentBlock';
@ -71,10 +55,11 @@
components: {
DocumentBlock,
DocumentForm,
SubmissionForm,
SubmissionInput,
FinalSubmission,
Solution,
SimpleFileUpload
SimpleFileUpload,
SubmissionForm
},
computed: {
@ -95,6 +80,10 @@
},
id() {
return this.assignment.id ? this.assignment.id.replace(/=/g, '') : ''
},
feedbackText() {
let feedback = this.assignment.submission.submissionFeedback;
return `Feedback von ${feedback.teacher.firstName} ${feedback.teacher.lastName}: ${feedback.text}`;
}
},
@ -266,25 +255,9 @@
}
}
&__submission {
@include input-box-shadow;
background-color: $color-white;
border-radius: $input-border-radius;
border: 1px solid $color-silver;
padding: $medium-spacing;
}
&__inputs {
margin-bottom: 12px;
}
&__submit {
margin-right: $medium-spacing;
}
&__actions {
display: flex;
align-items: center;
&__feedback {
margin-top: $medium-spacing;
}
}
</style>

View File

@ -1,13 +1,13 @@
<template>
<div class="final-submission">
<document-block
v-if="submission.document"
:value="{url: submission.document}"
v-if="userInput.document"
:value="{url: userInput.document}"
class="final-submission__document"
></document-block>
<div class="final-submission__explanation">
<info-icon class="final-submission__explanation-icon"></info-icon>
<span class="final-submission__explanation-text">Das Ergebnis wurde mit der Lehrperson geteilt</span>
<span class="final-submission__explanation-text">{{sharedMsg}}</span>
<a class="final-submission__reopen" @click="$emit('reopen')">Bearbeiten</a>
</div>
</div>
@ -19,7 +19,7 @@
import {newLineToParagraph} from '@/helpers/text';
export default {
props: ['submission'],
props: ['userInput', 'sharedMsg'],
components: {
InfoIcon,
@ -28,7 +28,7 @@
computed: {
text() {
return newLineToParagraph(this.submission.text);
return newLineToParagraph(this.userInput.text);
}
}
}

View File

@ -1,77 +1,123 @@
<template>
<div class="submission-form__text-answer submission-form">
<textarea
v-auto-grow
rows="1"
class="submission-form__textarea"
placeholder="Ergebnis erfassen"
:readonly="final"
:value="submission.text"
@input="$emit('input', $event.target.value)"
></textarea>
<div class="submission-form__save-status submission-form__save-status--saved" v-if="saved">
<tick-circle-icon class="submission-form__save-status-icon"></tick-circle-icon>
<div class="feedback__submission submission-form-container">
<div class="submission-form-container__inputs">
<submission-input
@input="saveInput"
:input-text="userInput.text"
:saved="saved"
:final="final"
:placeholder="placeholder"
:reopen="reopenSubmission"
></submission-input>
</div>
<div class="submission-form-container__actions" v-if="!final">
<button class="submission-form-container__submit button button--primary button--white-bg"
@click="$emit('turnIn')"
>{{action}}
</button>
<div v-if="userInput.document">
<document-block
:value="{url: userInput.document}"
show-trash-icon
v-on:trash="changeDocumentUrl('')"
></document-block>
</div>
<simple-file-upload
v-if="allowsDocuments"
v-on:link-change-url="changeDocumentUrl"
:value="userInput.document"
class="submission-form-container__document"
></simple-file-upload>
<slot></slot>
</div>
<final-submission
v-if="final"
:user-input="userInput"
:shared-msg="sharedMsg"
@reopen="$emit('reopen')"></final-submission>
</div>
<div class="submission-form__save-status submission-form__save-status--unsaved" v-if="!saved">
<loading-icon class="submission-form__save-status-icon submission-form__saving-icon"></loading-icon>
</div>
</div>
</template>
<script>
import TickCircleIcon from '@/components/icons/TickCircleIcon';
import LoadingIcon from '@/components/icons/LoadingIcon';
import SubmissionInput from '@/components/content-blocks/assignment/SubmissionInput';
import FinalSubmission from '@/components/content-blocks/assignment/FinalSubmission';
import SimpleFileUpload from '@/components/SimpleFileUpload';
import DocumentBlock from '@/components/content-blocks/DocumentBlock';
export default {
props: ['submission', 'saved', 'final'],
components: {
TickCircleIcon,
LoadingIcon
}
SubmissionInput,
FinalSubmission,
SimpleFileUpload,
DocumentBlock
},
props: {
userInput: Object,
saved: Boolean,
placeholder: String,
action: String,
reopen: Function,
document: String,
sharedMsg: String
},
computed: {
final() {
return !!this.userInput && this.userInput.final
},
allowsDocuments() {
return 'document' in this.userInput;
}
},
methods: {
reopenSubmission() {
this.$emit('reopen');
},
saveInput(input) {
this.$emit('saveInput', input);
},
changeDocumentUrl(documentUrl) {
this.$emit('changeDocumentUrl', documentUrl);
}
},
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
@import '@/styles/_mixins.scss';
.submission-form {
.submission-form-container {
@include input-box-shadow;
background-color: $color-white;
border-radius: $input-border-radius;
border: 1px solid $color-silver;
padding: $medium-spacing;
&__inputs {
margin-bottom: 12px;
}
&__submit {
margin-right: $medium-spacing;
}
&__actions {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&__textarea {
display: flex;
width: 95%;
padding: 0;
box-sizing: border-box;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
line-height: 1.5;
border: 0;
min-height: 110px;
}
&__save-status {
position: relative;
align-items: center;
}
&__save-status-icon {
width: 22px;
height: 22px;
fill: $color-silver-dark;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
&__saving-icon {
animation: spin 2.5s linear infinite;
&__document {
&:hover {
cursor: pointer;
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="submission-form__text-answer submission-form">
<textarea
v-auto-grow
rows="1"
class="submission-form__textarea"
:placeholder="placeholder"
:readonly="final"
:value="inputText"
@input="$emit('input', $event.target.value)"
></textarea>
<div class="submission-form__save-status submission-form__save-status--saved" v-if="saved">
<tick-circle-icon class="submission-form__save-status-icon"></tick-circle-icon>
</div>
<div class="submission-form__save-status submission-form__save-status--unsaved" v-if="!saved">
<loading-icon class="submission-form__save-status-icon submission-form__saving-icon"></loading-icon>
</div>
</div>
</template>
<script>
import TickCircleIcon from '@/components/icons/TickCircleIcon';
import LoadingIcon from '@/components/icons/LoadingIcon';
export default {
props: {
inputText: String,
saved: Boolean,
final: Boolean,
placeholder: {
type: String,
default: 'Ergebnis erfassen'
}
},
components: {
TickCircleIcon,
LoadingIcon
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.submission-form {
display: flex;
flex-direction: row;
justify-content: space-between;
&__textarea {
display: flex;
width: 95%;
padding: 0;
box-sizing: border-box;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
line-height: 1.5;
border: 0;
min-height: 110px;
}
&__save-status {
position: relative;
align-items: center;
}
&__save-status-icon {
width: 22px;
height: 22px;
fill: $color-silver-dark;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
&__saving-icon {
animation: spin 2.5s linear infinite;
}
}
</style>

View File

@ -5,7 +5,17 @@
<img
:src="module.heroImage"
alt="" class="module__hero">
<div class="module__intro" v-html="module.intro"></div>
<div class="module__intro-wrapper">
<bookmark-actions
class="module__bookmark-actions"
@add-note="addNote"
@edit-note="editNote"
:bookmarked="module.bookmark"
:note="note"
@bookmark="bookmark(!module.bookmark)"></bookmark-actions>
<div class="module__intro" v-html="module.intro"></div>
</div>
<h3 id="objectives">Lernziele</h3>
@ -26,13 +36,17 @@
import UPDATE_OBJECTIVE_PROGRESS_MUTATION from '@/graphql/gql/mutations/updateObjectiveProgress.gql';
import UPDATE_LAST_MODULE_MUTATION from '@/graphql/gql/mutations/updateLastModule.gql';
import UPDATE_MODULE_BOOKMARK_MUTATION from '@/graphql/gql/mutations/updateModuleBookmark.gql';
import OBJECTIVE_QUERY from '@/graphql/gql/objectiveQuery.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import MODULE_QUERY from '@/graphql/gql/moduleByIdQuery.gql';
import {withoutOwnerFirst} from '@/helpers/sorting';
import BookmarkActions from '@/components/notes/BookmarkActions';
export default {
components: {
BookmarkActions,
ObjectiveGroups,
ObjectiveGroupControl,
AddObjectiveGroupButton,
@ -67,6 +81,12 @@
},
isStudent() {
return !this.me.permissions.includes('users.can_manage_school_class_content');
},
note() {
if (!(this.module && this.module.bookmark)) {
return;
}
return this.module.bookmark.note;
}
},
@ -112,6 +132,61 @@
}
})
},
bookmark(bookmarked) {
const id = this.module.id;
this.$apollo.mutate({
mutation: UPDATE_MODULE_BOOKMARK_MUTATION,
variables: {
input: {
module: id,
bookmarked
}
},
update: (store, response) => {
const query = MODULE_QUERY;
const variables = {id};
const data = store.readQuery({
query,
variables
});
const module = data.module;
if (bookmarked) {
module.bookmark = {
__typename: 'ModuleBookmarkNode',
note: null
}
} else {
module.bookmark = null;
}
data.module = module;
store.writeQuery({
data,
query,
variables
});
},
optimisticResponse: {
__typename: 'Mutation',
updateModuleBookmark: {
__typename: 'UpdateModuleBookmarkPayload',
success: true
}
}
});
},
addNote(id) {
this.$store.dispatch('addNote', {
content: id,
parent: this.module.id
});
},
editNote() {
this.$store.dispatch('editNote', this.module.bookmark.note);
},
},
apollo: {
@ -138,6 +213,7 @@
.module {
display: flex;
justify-self: center;
@include desktop {
width: 800px;
}
@ -157,6 +233,10 @@
@include meta-title;
}
&__intro-wrapper {
position: relative;
}
&__intro {
line-height: 1.5;
margin-bottom: 3em;
@ -171,5 +251,9 @@
}
}
&__bookmark-actions {
margin-top: 3px;
}
}
</style>

View File

@ -1,13 +1,13 @@
<template>
<div class="bookmark-actions">
<a class="bookmark-actions__action" @click="$emit('bookmark')"
<a class="bookmark-actions__action bookmark-actions__bookmark" @click="$emit('bookmark')"
:class="{'bookmark-actions__action--bookmarked': bookmarked}">
<bookmark-icon :bookmarked="bookmarked"></bookmark-icon>
</a>
<a class="bookmark-actions__action" v-if="bookmarked && !note" @click="$emit('add-note')">
<a class="bookmark-actions__action bookmark-actions__add-note" v-if="bookmarked && !note" @click="$emit('add-note')">
<add-note-icon></add-note-icon>
</a>
<a class="bookmark-actions__action bookmark-actions__action--noted" @click="$emit('edit-note')" v-if="note">
<a class="bookmark-actions__action bookmark-actions__edit-note bookmark-actions__action--noted" @click="$emit('edit-note')" v-if="note">
<note-icon></note-icon>
</a>
</div>

View File

@ -5,10 +5,8 @@
<script>
import NoteForm from '@/components/notes/NoteForm';
import ADD_NOTE_MUTATION from '@/graphql/gql/mutations/addNote.gql';
import CONTENT_BLOCK_QUERY from '@/graphql/gql/contentBlockQuery.gql';
import {mapGetters} from 'vuex';
import {constructNoteMutation} from '@/helpers/new-note-mutation.js';
export default {
components: {
@ -22,68 +20,32 @@
},
computed: {
...mapGetters(['currentContent', 'currentContentBlock'])
...mapGetters(['currentContent', 'currentContentBlock', 'currentNoteParent'])
},
methods: {
addNote(note) {
addNote(n) {
const content = this.currentContent;
const contentBlock = this.currentContentBlock;
const text = note.text;
this.$apollo.mutate({
mutation: ADD_NOTE_MUTATION,
variables: {
input: {
note: {
content,
contentBlock,
text
}
}
},
update: (store, {data: {addNote: {note}}}) => {
const query = CONTENT_BLOCK_QUERY;
const variables = {id: contentBlock};
const data = store.readQuery({
query,
variables
});
const bookmarks = data.contentBlock.bookmarks;
let index = bookmarks.findIndex(element => {
return element.uuid === content;
});
if (index > -1) {
let el = bookmarks[index];
el.note = note;
bookmarks.splice(index, 1, el);
}
data.contentBlock.bookmarks = bookmarks;
store.writeQuery({
data,
query,
variables
});
},
optimisticResponse: {
__typename: 'Mutation',
addNote: {
__typename: 'AddNotePayload',
note: {
__typename: 'NoteNode',
id: -1,
text: text
}
}
const parent = this.currentNoteParent;
const text = n.text;
let note = {};
if (content > '') {
note = {
content,
contentBlock,
text
}
}).then(() => {
this.$store.dispatch('hideModal');
});
} else {
note = {
parent,
text
}
}
this.$apollo
.mutate(constructNoteMutation(note))
.then(this.hide);
},
hide() {
this.$store.dispatch('hideModal');

View File

@ -2,6 +2,7 @@
<modal :hide-header="true" :small="true">
<modal-input v-on:input="localNote.text = $event"
placeholder="Notiz erfassen"
data-cy="bookmark-note"
:value="localNote.text"
></modal-input>
<div slot="footer">

View File

@ -19,6 +19,11 @@ query AssignmentWithSubmissions($id: ID!) {
}
}
}
submissionFeedback {
id
text
final
}
}
}
}

View File

@ -8,5 +8,13 @@ fragment AssignmentParts on AssignmentNode {
text
final
document
submissionFeedback {
id
text
teacher {
firstName
lastName
}
}
}
}

View File

@ -3,6 +3,12 @@ fragment ChapterParts on ChapterNode {
id
title
description
bookmark {
note {
id
text
}
}
contentBlocks {
edges {
node {

View File

@ -7,4 +7,10 @@ fragment ModuleParts on ModuleNode {
slug
heroImage
solutionsEnabled
bookmark {
note {
id
text
}
}
}

View File

@ -0,0 +1,5 @@
mutation UpdateChapterBookmark($input: UpdateChapterBookmarkInput!) {
updateChapterBookmark(input: $input) {
success
}
}

View File

@ -0,0 +1,10 @@
mutation UpdateSubmissionFeedback($input: UpdateSubmissionFeedbackInput!) {
updateSubmissionFeedback(input: $input){
successful
updatedSubmissionFeedback {
id
text
final
}
}
}

View File

@ -0,0 +1,5 @@
mutation UpdateModuleBookmark($input: UpdateModuleBookmarkInput!) {
updateModuleBookmark(input: $input) {
success
}
}

View File

@ -9,6 +9,12 @@ query StudentSubmissions($id: ID!) {
}
assignment {
title
assignment
}
submissionFeedback {
id
text
final
}
}
}

View File

@ -0,0 +1,85 @@
import ADD_NOTE_MUTATION from '@/graphql/gql/mutations/addNote.gql';
import CONTENT_BLOCK_QUERY from '@/graphql/gql/contentBlockQuery.gql';
import CHAPTER_QUERY from '@/graphql/gql/chapterQuery.gql';
import MODULE_QUERY from '@/graphql/gql/moduleByIdQuery.gql';
const getBlockType = id => atob(id).split(':')[0]
export const constructNoteMutation = (n) => {
let update = () => {
};
if (n.contentBlock) { // has a content block, so it is a content block bookmark
update = (store, {data: {addNote: {note}}}) => {
const query = CONTENT_BLOCK_QUERY;
const variables = {id: n.contentBlock};
const data = store.readQuery({
query,
variables
});
const bookmarks = data.contentBlock.bookmarks;
let index = bookmarks.findIndex(element => {
return element.uuid === n.content;
});
if (index > -1) {
let el = bookmarks[index];
el.note = note;
bookmarks.splice(index, 1, el);
}
data.contentBlock.bookmarks = bookmarks;
store.writeQuery({
data,
query,
variables
});
};
} else { // it's a chapter bookmark or a module bookmark
update = (store, {data: {addNote: {note}}}) => {
const type = getBlockType(n.parent) === 'ChapterNode' ? 'chapter' : 'module';
const query = type === 'chapter' ? CHAPTER_QUERY : MODULE_QUERY;
const variables = {id: n.parent};
const data = store.readQuery({
query,
variables
});
const bookmark = data[type].bookmark;
bookmark.note = note;
data[type].bookmark = bookmark;
store.writeQuery({
data,
query,
variables
});
};
}
return {
mutation: ADD_NOTE_MUTATION,
variables: {
input: {
note: n
}
},
update,
optimisticResponse: {
__typename: 'Mutation',
addNote: {
__typename: 'AddNotePayload',
note: {
__typename: 'NoteNode',
id: -1,
text: n.text
}
}
}
}
};

View File

@ -1,37 +1,71 @@
<template>
<div class="article submission-page">
<div class="article__header">
<h1 class="article__title">{{studentSubmission.assignment.title}}</h1>
<h2 class="article__subtitle">{{fullName}}</h2>
<div class="submission-page">
<div class="submission-page__header submission-header">
<h2>Aufgabe</h2>
<p>{{studentSubmission.assignment.assignment}}</p>
</div>
<div class="article__content article-content">
<div class="submission-page__content submission-content">
<h2>Ergebnis von {{fullName}}</h2>
<p v-html="text"></p>
<p v-if="studentSubmission.document && studentSubmission.document.length > 0" class="article-content__document">
<a :href="studentSubmission.document" class="entry-document__link link" target="_blank">
<student-submission-document :document="studentSubmission.document"></student-submission-document>
</a>
</p>
<p class="article-content__text" v-html="text"></p>
</div>
<div class="submission-page__feedback feedback">
<submission-form
v-if="studentSubmission"
@turnIn="turnIn"
@saveInput="saveInput"
@reopen="reopen"
:user-input="feedback"
placholder="Feedback erfassen"
action="Feedback teilen"
shared-msg="Dieses Feedback wurde geteilt."
:saved="!unsaved"
>
<div v-if="!final" class="feedback-submission__emojis emojis">
<span v-for="(emoji, index) in emojis"
:key="index"
@click="addEmoji(emoji)"
class="emojis__emoji">{{emoji}}</span>
</div>
</submission-form>
</div>
</div>
</template>
<script>
import {newLineToParagraph} from '@/helpers/text';
import debounce from 'lodash/debounce';
import StudentSubmissionDocument from '@/components/StudentSubmissionDocument';
import STUDENT_SUBMISSIONS_QUERY from '@/graphql/gql/studentSubmissionQuery.gql';
import UPDATE_FEEDBACK_MUTATION from '@/graphql/gql/mutations/updateFeedback.gql';
import SubmissionForm from '@/components/content-blocks/assignment/SubmissionForm';
export default {
components: {
StudentSubmissionDocument
StudentSubmissionDocument,
SubmissionForm
},
computed: {
final() {
return !!this.studentSubmission && this.studentSubmission.final;
},
text() {
return newLineToParagraph(this.studentSubmission.text);
},
fullName() {
return `${this.studentSubmission.student.firstName} ${this.studentSubmission.student.lastName}`
},
feedback() {
return this.studentSubmission.submissionFeedback ? this.studentSubmission.submissionFeedback : {
text: '',
final: false
};
}
},
@ -48,6 +82,98 @@
},
},
methods: {
addEmoji(emoji) {
const feedbackText = this.feedback.text + emoji;
this.updateFeedbackText(feedbackText);
},
_save: debounce(function () {
this.saving++;
this.$apollo.mutate({
mutation: UPDATE_FEEDBACK_MUTATION,
variables: {
input: {
submissionFeedback: {
studentSubmission: this.studentSubmission.id,
text: this.studentSubmission.submissionFeedback.text,
}
}
},
update: this.updateCache
}).then(() => {
this.saving--;
if (this.saving === 0) {
this.unsaved = false;
}
});
}, 500),
saveInput: function (feedbackText) {
this.unsaved = true;
/*
We update the assignment on this component, so the changes are reflected on it. The server does not return
the updated entity, to prevent the UI to update when the user is entering his input
*/
this.updateFeedbackText(feedbackText);
this._save();
},
turnIn() {
this.$apollo.mutate({
mutation: UPDATE_FEEDBACK_MUTATION,
variables: {
input: {
submissionFeedback: {
studentSubmission: this.studentSubmission.id,
text: this.studentSubmission.submissionFeedback.text,
final: true
}
}
},
update: this.updateCache
});
},
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
});
},
updateCache(store, {data: {updateSubmissionFeedback: {successful, updatedSubmissionFeedback}}}) {
try {
if (successful) {
const query = STUDENT_SUBMISSIONS_QUERY;
const variables = {
id: this.studentSubmission.id
};
const data = store.readQuery({query, variables});
data.studentSubmission.submissionFeedback = Object.assign({}, updatedSubmissionFeedback);
store.writeQuery({query, variables, data});
}
} catch (e) {
console.error(e);
// Query did not exist in the cache, and apollo throws a generic Error. Do nothing
}
},
updateFeedbackText(text) {
this.studentSubmission = Object.assign({}, this.studentSubmission, {
submissionFeedback: Object.assign({}, this.studentSubmission.submissionFeedback, {text: text})
})
}
},
data() {
return {
studentSubmission: {
@ -59,21 +185,39 @@
lastName: ''
},
text: '',
document: ''
}
document: '',
submissionFeedback: {
text: '',
final: false
}
},
unsaved: false,
saving: 0,
emojis: ['👍', '👎', '🙂', '😐', '😕', '🙁', '😮', '😉', '🙄', '❕', '❔', '🧐', '🤩', '🤗', '🤬', '🤢']
}
}
}
</script>
<style scoped lang="scss">
.article-content {
&__document {
margin-bottom: 1rem;
@import "@/styles/_variables.scss";
@import "@/styles/_functions.scss";
.submission-page {
&__content {
margin-top: 1.5*$large-spacing;
}
&__text /deep/ > p {
margin-bottom: 1em;
&__feedback {
margin-top: $large-spacing;
}
}
.emojis {
font-size: toRem(32px);
&__emoji {
cursor: pointer;
}
}
</style>

View File

@ -1,18 +1,15 @@
<template>
<div class="submissions-page">
<div>
<a class="button button--primary submissions-page__back" @click="back">Zurück zur Aufgabe</a>
</div>
<h2 class="submissions-page__heading">Aufgabe</h2>
<assignment-with-submissions v-if="!$apollo.queries.assignment.loading"
:assignment="assignment"></assignment-with-submissions>
:assignment="assignment"
@back="back"></assignment-with-submissions>
</div>
</template>
<script>
import AssignmentWithSubmissions from '@/components/AssignmentWithSubmissions';
import ASSIGNMENT_WITH_SUBMISSIONS_QUERY from '@/graphql/gql/assignmentWithSubmissionsQuery.gql';
export default {
@ -54,15 +51,21 @@
<style scoped lang="scss">
@import "@/styles/_mixins.scss";
@import "@/styles/_variables.scss";
.submissions-page {
display: grid;
grid-row-gap: $large-spacing;
grid-template-rows: auto 1fr;
margin-top: 2rem;
margin-left: $large-spacing;
margin-right: $large-spacing;
@include desktop {
width: 800px;
margin-left: $medium-spacing;
margin-right: $medium-spacing;
}
}
</style>

View File

@ -22,6 +22,7 @@ export default new Vuex.Store({
parentProject: null,
currentNote: null,
currentProjectEntry: null,
currentNoteParent: '',
imageUrl: '',
infographic: {
id: 0,
@ -46,6 +47,7 @@ export default new Vuex.Store({
currentContent: state => state.currentContent,
currentContentBlock: state => state.currentContentBlock,
currentNote: state => state.currentNote,
currentNoteParent: state => state.currentNoteParent,
},
actions: {
@ -61,6 +63,7 @@ export default new Vuex.Store({
commit('setCurrentRoomEntry', '');
commit('setCurrentContent', '');
commit('setCurrentContentBlock', '');
commit('setCurrentNoteParent', '');
commit('setContentBlockPosition', {});
commit('setParentRoom', null);
commit('setParentModule', '');
@ -126,8 +129,12 @@ export default new Vuex.Store({
dispatch('showModal', 'edit-project-entry-wizard');
},
addNote({commit, dispatch}, payload) {
commit('setCurrentContentBlock', payload.contentBlock);
commit('setCurrentContent', payload.content);
if (payload.contentBlock) {
commit('setCurrentContentBlock', payload.contentBlock);
commit('setCurrentContent', payload.content);
} else {
commit('setCurrentNoteParent', payload.parent);
}
dispatch('showModal', 'new-note-wizard');
},
editNote({commit, dispatch}, payload) {
@ -240,6 +247,9 @@ export default new Vuex.Store({
},
setEditModule(state, payload) {
state.editModule = payload;
},
setCurrentNoteParent(state, payload) {
state.currentNoteParent = payload;
}
}
})

View File

@ -0,0 +1,17 @@
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.student-submission-row {
display: grid;
grid-template-columns: 100px 1fr 1fr;
grid-column-gap: 20px;
@include desktop {
grid-template-columns: 170px 1fr 1fr;
grid-column-gap: 80px;
}
align-items: center;
border-bottom: 1px solid $color-silver-dark;
padding: 15px 0;
}

View File

@ -20,3 +20,4 @@
@import "solutions";
@import "password_forms";
@import "public-page";
@import "student-submission";

13067
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ import random
import factory
from books.factories import ModuleFactory
from .models import Assignment, StudentSubmission
from .models import Assignment, StudentSubmission, SubmissionFeedback
from core.factories import fake
@ -24,3 +24,12 @@ class StudentSubmissionFactory(factory.django.DjangoModelFactory):
text = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8)))
assignment = factory.SubFactory(AssignmentFactory)
final = False
class SubmissionFeedbackFactory(factory.django.DjangoModelFactory):
class Meta:
model = SubmissionFeedback
text = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8)))
student_submission = factory.SubFactory(StudentSubmissionFactory)
final = False

View File

@ -0,0 +1,33 @@
# Generated by Django 2.0.6 on 2019-11-12 14:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('assignments', '0005_assignment_solution'),
]
operations = [
migrations.CreateModel(
name='SubmissionFeedback',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('text', models.TextField(blank=True)),
('student_submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='assignments.StudentSubmission')),
('teacher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-modified', '-created'),
'get_latest_by': 'modified',
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0.6 on 2019-11-13 12:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assignments', '0006_submissionfeedback'),
]
operations = [
migrations.AddField(
model_name='submissionfeedback',
name='final',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.6 on 2019-11-13 14:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('assignments', '0007_submissionfeedback_final'),
]
operations = [
migrations.AlterField(
model_name='submissionfeedback',
name='student_submission',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='assignments.StudentSubmission'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.0.6 on 2019-11-13 14:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('assignments', '0008_auto_20191113_1430'),
]
operations = [
migrations.RemoveField(
model_name='submissionfeedback',
name='id',
),
migrations.AlterField(
model_name='submissionfeedback',
name='student_submission',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='assignments.StudentSubmission'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.6 on 2019-12-10 14:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('assignments', '0009_auto_20191113_1433'),
]
operations = [
migrations.AlterField(
model_name='submissionfeedback',
name='student_submission',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='submission_feedback', serialize=False, to='assignments.StudentSubmission'),
),
]

View File

@ -37,3 +37,11 @@ class StudentSubmission(TimeStampedModel):
def __str__(self):
return '{} - {}'.format(self.student.full_name, self.text)
class SubmissionFeedback(TimeStampedModel):
text = models.TextField(blank=True)
teacher = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='feedbacks')
student_submission = models.OneToOneField(StudentSubmission, on_delete=models.CASCADE, primary_key=True, related_name='submission_feedback')
final = models.BooleanField(default=False)

View File

@ -7,3 +7,10 @@ class AssignmentInput(InputObjectType):
answer = graphene.String(required=True)
document = graphene.String()
final = graphene.Boolean()
class SubmissionFeedbackInput(InputObjectType):
id = graphene.ID()
student_submission = graphene.ID(required=True)
text = graphene.String(required=True)
final = graphene.Boolean()

View File

@ -1,10 +1,12 @@
import graphene
from graphene import relay
from graphql_relay import from_global_id
from rest_framework.exceptions import PermissionDenied
from api.utils import get_object
from assignments.models import Assignment
from assignments.schema.types import AssignmentNode, StudentSubmissionNode
from .inputs import AssignmentInput
from assignments.models import Assignment, SubmissionFeedback
from assignments.schema.types import AssignmentNode, StudentSubmissionNode, SubmissionFeedbackNode
from .inputs import AssignmentInput, SubmissionFeedbackInput
class UpdateAssignment(relay.ClientIDMutation):
@ -30,5 +32,35 @@ class UpdateAssignment(relay.ClientIDMutation):
return cls(successful=True, updated_assignment=assignment, submission=submission, errors=None)
class UpdateSubmissionFeedback(relay.ClientIDMutation):
class Input:
submission_feedback = graphene.Argument(SubmissionFeedbackInput)
updated_submission_feedback = graphene.Field(SubmissionFeedbackNode)
successful = graphene.Boolean()
errors = graphene.List(graphene.String)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
submission_feedback_data = kwargs.get('submission_feedback')
user = info.context.user
student_submission_id = from_global_id(submission_feedback_data['student_submission'])[1]
if not user.has_perm('users.can_manage_school_class_content'):
raise PermissionDenied('Missing permissions')
(submission_feedback, created) = SubmissionFeedback.objects.get_or_create(teacher=user,
student_submission_id=student_submission_id)
final = submission_feedback_data.get('final') if 'final' in submission_feedback_data else submission_feedback.final
submission_feedback.final = final
submission_feedback.text = submission_feedback_data.get('text')
submission_feedback.save()
return cls(successful=True, updated_submission_feedback=submission_feedback, errors=None)
class AssignmentMutations(object):
update_assignment = UpdateAssignment.Field()
update_submission_feedback = UpdateSubmissionFeedback.Field()

View File

@ -1,17 +1,48 @@
import graphene
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from graphene import relay
from graphene_django import DjangoObjectType
from assignments.models import Assignment, StudentSubmission
from assignments.models import Assignment, StudentSubmission, SubmissionFeedback
from books.utils import are_solutions_enabled_for
class SubmissionFeedbackNode(DjangoObjectType):
class Meta:
model = SubmissionFeedback
filter_fields = []
interfaces = (relay.Node,)
class StudentSubmissionNode(DjangoObjectType):
submission_feedback = graphene.Field(SubmissionFeedbackNode)
class Meta:
model = StudentSubmission
filter_fields = []
interfaces = (relay.Node,)
def resolve_submission_feedback(self, info, **kwargs):
user = info.context.user
if not hasattr(self, 'submission_feedback'):
return None
# teacher path
if user.has_perm('users.can_manage_school_class_content'):
if self.submission_feedback.teacher == user:
return self.submission_feedback
else:
raise PermissionDenied('Missing permissions')
# student path
if self.submission_feedback.final:
return self.submission_feedback
return None
class AssignmentNode(DjangoObjectType):
submission = graphene.Field(StudentSubmissionNode)

View File

@ -15,7 +15,6 @@ class AssignmentPermissionsTestCase(DefaultUserTestCase):
self.assignment_id = to_global_id('AssignmentNode', self.assignment.pk)
self.module_id = to_global_id('ModuleNode', self.assignment.module.pk)
def _submit_submission(self, user=None):
mutation = '''
mutation UpdateAssignment($input: UpdateAssignmentInput!) {

View File

@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
#
# ITerativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
#
# Created on 2019-11-13
# @author: chrigu <christian.cueni@iterativ.ch>
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 ..factories import AssignmentFactory, StudentSubmissionFactory, SubmissionFeedbackFactory
class SubmissionFeedbackTestCase(DefaultUserTestCase):
def setUp(self):
super(SubmissionFeedbackTestCase, self).setUp()
self.assignment = AssignmentFactory(
owner=self.teacher
)
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_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)
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={
'input': {
"submissionFeedback": {
"studentSubmission": student_submission_id,
"text": text,
"final": final
}
}
})
def _fetch_assignment_student(self, user):
client = create_client(user)
query = '''
query AssignmentWithSubmissions($id: ID!) {
assignment(id: $id) {
title
submission {
id
text
document
submissionFeedback {
text
}
}
}
}
'''
return client.execute(query, variables={
'id': self.assignment_id
})
def _fetch_assignment_teacher(self, user):
client = create_client(user)
query = '''
query AssignmentWithSubmissions($id: ID!) {
assignment(id: $id) {
title
submissions {
id
text
document
submissionFeedback {
text
}
}
}
}
'''
return client.execute(query, variables={
'id': self.assignment_id
})
def _fetch_submission_feedback(self, user):
client = create_client(user)
query = '''
query AssignmentWithSubmissions($id: ID!) {
assignment(id: $id) {
title
submissions {
id
text
document
submissionFeedback {
text
}
}
}
}
'''
return client.execute(query, variables={
'id': self.assignment_id
})
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'))
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
)
student_submission = StudentSubmissionFactory(assignment=assignment, student=self.student1, final=False)
submission_feedback = SubmissionFeedbackFactory(teacher=self.teacher, final=False,
student_submission=student_submission)
submission_feedback_id = to_global_id('SubmissionFeedback', submission_feedback.pk)
result = self._create_submission_feedback(self.teacher, True, 'Some', submission_feedback_id)
self.assertIsNone(result.get('errors'))
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
)
student_submission = StudentSubmissionFactory(assignment=assignment, student=self.student1, final=False)
submission_feedback = SubmissionFeedbackFactory(teacher=self.teacher, final=False,
student_submission=student_submission)
submission_feedback_id = to_global_id('SubmissionFeedback', submission_feedback.pk)
result = self._create_submission_feedback(self.teacher2, True, 'Some', submission_feedback_id)
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)
self.assertEqual(result.get('data').get('assignment').get('submission').get('submissionFeedback')
.get('text'), submission_feedback.text)
def test_teacher_can_see_feedback_for_submission(self):
submission_feedback = SubmissionFeedbackFactory(teacher=self.teacher, final=False,
student_submission=self.student_submission)
self.student_submission.final = True
self.student_submission.save()
result = self._fetch_assignment_teacher(self.teacher)
self.assertEqual(result.get('data').get('assignment').get('submissions')[0].get('submissionFeedback')
.get('text'), submission_feedback.text)
def test_rogue_teacher_cannot_see_feedback(self):
SubmissionFeedbackFactory(teacher=self.teacher, final=False,
student_submission=self.student_submission)
self.student_submission.final = True
self.student_submission.save()
result = self._fetch_assignment_teacher(self.teacher2)
self.assertIsNone(result.get('data').get('assignment').get('submissions')[0].get('submissionFeedback'))

View File

@ -0,0 +1,21 @@
# Generated by Django 2.0.6 on 2019-11-28 16:01
from django.db import migrations
import wagtail.core.blocks
import wagtail.core.fields
import wagtail.images.blocks
class Migration(migrations.Migration):
dependencies = [
('basicknowledge', '0003_auto_20190912_1228'),
]
operations = [
migrations.AlterField(
model_name='basicknowledge',
name='contents',
field=wagtail.core.fields.StreamField([('text_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock(features=['bold', 'ul']))])), ('image_block', wagtail.images.blocks.ImageChooserBlock()), ('link_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('video_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('document_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('section_title', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock())])), ('infogram_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock()), ('title', wagtail.core.blocks.TextBlock())])), ('genially_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock())])), ('thinglink_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock())])), ('subtitle', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock())]))], blank=True, null=True),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.0.6 on 2019-11-28 16:01
import assignments.models
from django.db import migrations
import surveys.models
import wagtail.core.blocks
import wagtail.core.fields
import wagtail.images.blocks
import wagtail.snippets.blocks
class Migration(migrations.Migration):
dependencies = [
('books', '0015_contentblock_bookmarks'),
]
operations = [
migrations.AlterField(
model_name='contentblock',
name='contents',
field=wagtail.core.fields.StreamField([('text_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock(features=['ul']))])), ('basic_knowledge', wagtail.core.blocks.StructBlock([('description', wagtail.core.blocks.RichTextBlock(required=False)), ('basic_knowledge', wagtail.core.blocks.PageChooserBlock(required=True, target_model=['basicknowledge.BasicKnowledge']))])), ('assignment', wagtail.core.blocks.StructBlock([('assignment_id', wagtail.snippets.blocks.SnippetChooserBlock(assignments.models.Assignment))])), ('survey', wagtail.core.blocks.StructBlock([('survey_id', wagtail.snippets.blocks.SnippetChooserBlock(surveys.models.Survey))])), ('image_block', wagtail.images.blocks.ImageChooserBlock()), ('image_url_block', wagtail.core.blocks.StructBlock([('title', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('link_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('solution', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock(features=['ul']))], icon='tick')), ('video_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('document_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('infogram_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock()), ('title', wagtail.core.blocks.TextBlock())])), ('genially_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock())])), ('thinglink_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock())])), ('subtitle', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock())])), ('module_room_slug', wagtail.core.blocks.StructBlock([('title', wagtail.core.blocks.TextBlock())])), ('content_list_item', wagtail.core.blocks.StreamBlock([('text_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock(features=['ul']))])), ('basic_knowledge', wagtail.core.blocks.StructBlock([('description', wagtail.core.blocks.RichTextBlock(required=False)), ('basic_knowledge', wagtail.core.blocks.PageChooserBlock(required=True, target_model=['basicknowledge.BasicKnowledge']))])), ('assignment', wagtail.core.blocks.StructBlock([('assignment_id', wagtail.snippets.blocks.SnippetChooserBlock(assignments.models.Assignment))])), ('survey', wagtail.core.blocks.StructBlock([('survey_id', wagtail.snippets.blocks.SnippetChooserBlock(surveys.models.Survey))])), ('image_block', wagtail.images.blocks.ImageChooserBlock()), ('image_url_block', wagtail.core.blocks.StructBlock([('title', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('link_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('solution', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock(features=['ul']))], icon='tick')), ('video_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('document_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('infogram_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock()), ('title', wagtail.core.blocks.TextBlock())])), ('genially_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock())])), ('thinglink_block', wagtail.core.blocks.StructBlock([('id', wagtail.core.blocks.TextBlock())])), ('subtitle', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock())])), ('module_room_slug', wagtail.core.blocks.StructBlock([('title', wagtail.core.blocks.TextBlock())]))]))], blank=True, null=True),
),
]

View File

@ -5,8 +5,8 @@ from graphene_django.filter import DjangoFilterConnectionField
from api.utils import get_object
from books.utils import are_solutions_enabled_for
from notes.models import ContentBlockBookmark
from notes.schema import ContentBlockBookmarkNode
from notes.models import ContentBlockBookmark, ChapterBookmark, ModuleBookmark
from notes.schema import ContentBlockBookmarkNode, ChapterBookmarkNode, ModuleBookmarkNode
from rooms.models import ModuleRoomSlug
from ..models import Book, Topic, Module, Chapter, ContentBlock
@ -66,6 +66,7 @@ class ContentBlockNode(DjangoObjectType):
class ChapterNode(DjangoObjectType):
content_blocks = DjangoFilterConnectionField(ContentBlockNode)
bookmark = graphene.Field(ChapterBookmarkNode)
class Meta:
model = Chapter
@ -96,6 +97,11 @@ class ChapterNode(DjangoObjectType):
return publisher_content_blocks.union(user_created_content_blocks)
def resolve_bookmark(self, info, **kwags):
return ChapterBookmark.objects.filter(
user=info.context.user,
chapter=self
).first()
class ModuleNode(DjangoObjectType):
pk = graphene.Int()
@ -103,6 +109,7 @@ class ModuleNode(DjangoObjectType):
topic = graphene.Field('books.schema.queries.TopicNode')
hero_image = graphene.String()
solutions_enabled = graphene.Boolean()
bookmark = graphene.Field(ModuleBookmarkNode)
class Meta:
model = Module
@ -132,6 +139,12 @@ class ModuleNode(DjangoObjectType):
teacher = info.context.user.get_teacher()
return self.solutions_enabled_by.filter(pk=teacher.pk).exists() if teacher is not None else False
def resolve_bookmark(self, info, **kwags):
return ModuleBookmark.objects.filter(
user=info.context.user,
module=self
).first()
class TopicNode(DjangoObjectType):
pk = graphene.Int()

View File

@ -0,0 +1,139 @@
from django.test import TestCase, RequestFactory
from graphene.test import Client
from graphql_relay import to_global_id
from api.schema import schema
from api.utils import get_object, get_graphql_mutation
from books.models import ContentBlock, Chapter
from books.factories import ModuleFactory
from core.factories import UserFactory
from notes.factories import ChapterBookmarkFactory, ModuleBookmarkFactory
from notes.models import Note, ModuleBookmark, ChapterBookmark
class NoteMutationTestCase(TestCase):
def setUp(self):
self.module = ModuleFactory()
self.chapter = Chapter(title='Hello')
self.module.add_child(instance=self.chapter)
self.user = user = UserFactory(username='aschi')
content_block = ContentBlock(title='bla', slug='bla')
self.chapter.specific.add_child(instance=content_block)
ChapterBookmarkFactory.create(chapter=self.chapter, user=user)
ModuleBookmarkFactory.create(module=self.module, user=user)
request = RequestFactory().get('/')
request.user = user
self.client = Client(schema=schema, context_value=request)
self.add_mutation = get_graphql_mutation('addNote.gql')
self.update_mutation = get_graphql_mutation('updateNote.gql')
class NewNoteMutationTestCase(NoteMutationTestCase):
def test_add_chapter_note(self):
self.assertEqual(Note.objects.count(), 0)
text = 'Hello World'
result = self.client.execute(self.add_mutation, variables={
'input': {
'note': {
'parent': to_global_id('ChapterNode', self.chapter.pk),
'text': text
}
}
})
self.assertIsNone(result.get('errors'))
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(Note.objects.first().text, text)
def test_add_module_note(self):
self.assertEqual(Note.objects.count(), 0)
text = 'Hello World'
result = self.client.execute(self.add_mutation, variables={
'input': {
'note': {
'parent': to_global_id('ModuleNode', self.module.pk),
'text': text
}
}
})
self.assertIsNone(result.get('errors'))
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(Note.objects.first().text, text)
class UpdateNoteMutationTestCase(NoteMutationTestCase):
def setUp(self):
super(UpdateNoteMutationTestCase, self).setUp()
self.chapter_note = Note.objects.create(text='Hello World')
self.module_note = Note.objects.create(text='Hello World')
self.chapter_bookmark = ChapterBookmark.objects.get(user=self.user, chapter=self.chapter)
self.chapter_bookmark.note = self.chapter_note
self.chapter_bookmark.save()
self.module_bookmark = ModuleBookmark.objects.get(user=self.user, module=self.module)
self.module_bookmark.note = self.module_note
self.module_bookmark.save()
def test_change_module_note(self):
self.assertEqual(Note.objects.count(), 2)
self.assertEqual(self.module_bookmark.note.text, 'Hello World')
new_text = 'Salut monde'
result = self.client.execute(self.update_mutation, variables={
'input': {
'note': {
'id': to_global_id('NoteNode', self.module_bookmark.note.pk),
'text': new_text
}
}
})
self.assertIsNone(result.get('errors'))
bookmark = ModuleBookmark.objects.get(user=self.user, module=self.module)
self.assertEqual(bookmark.note.text, new_text)
self.assertEqual(Note.objects.count(), 2)
def test_change_chapter_note(self):
self.assertEqual(Note.objects.count(), 2)
self.assertEqual(self.chapter_bookmark.note.text, 'Hello World')
new_text = 'Salut monde'
result = self.client.execute(self.update_mutation, variables={
'input': {
'note': {
'id': to_global_id('NoteNode', self.chapter_bookmark.note.pk),
'text': new_text
}
}
})
self.assertIsNone(result.get('errors'))
bookmark = ChapterBookmark.objects.get(user=self.user, chapter=self.chapter)
self.assertEqual(bookmark.note.text, new_text)
self.assertEqual(Note.objects.count(), 2)
def test_update_wrong_user(self):
godis_note = Note.objects.create(text='Hello Godi')
godi = UserFactory(username='godi')
godis_bookmark = ModuleBookmarkFactory(module=self.module, user=godi)
godis_bookmark.note = godis_note
godis_bookmark.save()
result = self.client.execute(self.update_mutation, variables={
'input': {
'note': {
'id': to_global_id('NoteNode', godis_note.pk),
'text': 'Hello Aschi'
}
}
})
print(result.get('errors'))
self.assertIsNotNone(result.get('errors'))

View File

@ -47,6 +47,7 @@ objective_groups_1 = [
module_1_chapter_1 = {
'title': '1.1 Lehrbeginn',
'description': 'Wie sieht Ihr Konsumverhalten aus?',
'content_blocks': [
{
'type': 'task',
@ -186,6 +187,7 @@ module_1_chapter_1 = {
}
module_1_chapter_2 = {
'title': '1.2 Die drei Lernorte',
'description': 'Haben Sie sich beim Shoppen schon mal überlegt, aus welchem Beweggrund Sie ein bestimmtes Produkt eigentlich unbedingt haben wollten? Wir gehen im Folgenden anhand Ihres letzten Kleiderkaufs dieser Frage nach.',
'content_blocks': [
{
'type': 'base_society',

View File

@ -0,0 +1,11 @@
from django.core.management import BaseCommand
from notes.models import ContentBlockBookmark, ModuleBookmark, ChapterBookmark
class Command(BaseCommand):
def handle(self, *args, **options):
self.stdout.write("Preparing bookmarks")
ContentBlockBookmark.objects.all().delete()
ModuleBookmark.objects.all().delete()
ChapterBookmark.objects.all().delete()

12
server/notes/factories.py Normal file
View File

@ -0,0 +1,12 @@
import factory
from notes.models import ChapterBookmark, ModuleBookmark
class ChapterBookmarkFactory(factory.DjangoModelFactory):
class Meta:
model = ChapterBookmark
class ModuleBookmarkFactory(factory.DjangoModelFactory):
class Meta:
model = ModuleBookmark

View File

@ -3,8 +3,9 @@ from graphene import InputObjectType
class AddNoteArgument(InputObjectType):
content = graphene.UUID(required=True)
content_block = graphene.ID(required=True)
content = graphene.UUID()
content_block = graphene.ID()
parent = graphene.ID()
text = graphene.String(required=True)

View File

@ -0,0 +1,41 @@
# Generated by Django 2.0.6 on 2019-11-28 16:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('books', '0016_auto_20191128_1601'),
('notes', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ChapterBookmark',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.Chapter')),
('note', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='notes.Note')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ModuleBookmark',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.Module')),
('note', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='notes.Note')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -10,7 +10,6 @@ class Note(models.Model):
class Bookmark(models.Model):
uuid = models.UUIDField(unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
note = models.OneToOneField(Note, null=True, on_delete=models.SET_NULL)
@ -19,4 +18,13 @@ class Bookmark(models.Model):
class ContentBlockBookmark(Bookmark):
uuid = models.UUIDField(unique=True)
content_block = models.ForeignKey('books.ContentBlock', on_delete=models.CASCADE)
class ModuleBookmark(Bookmark):
module = models.ForeignKey('books.Module', on_delete=models.CASCADE)
class ChapterBookmark(Bookmark):
chapter = models.ForeignKey('books.Chapter', on_delete=models.CASCADE)

View File

@ -3,11 +3,12 @@ from builtins import PermissionError
import graphene
import json
from graphene import relay
from graphql_relay import from_global_id
from api.utils import get_object
from books.models import ContentBlock
from books.models import ContentBlock, Chapter, Module
from notes.inputs import AddNoteArgument, UpdateNoteArgument
from notes.models import ContentBlockBookmark, Note
from notes.models import ContentBlockBookmark, Note, ChapterBookmark, ModuleBookmark
from notes.schema import NoteNode
@ -56,16 +57,31 @@ class AddNote(relay.ClientIDMutation):
user = info.context.user
note = kwargs.get('note')
content_uuid = note.get('content')
content_block_id = note.get('content_block')
content_block = get_object(ContentBlock, content_block_id)
content_uuid = note.get('content', '')
content_block_id = note.get('content_block', '')
parent = note.get('parent')
text = note.get('text')
bookmark = ContentBlockBookmark.objects.get(
content_block=content_block,
uuid=content_uuid,
user=user
)
if content_uuid != '':
content_block = get_object(ContentBlock, content_block_id)
bookmark = ContentBlockBookmark.objects.get(
content_block=content_block,
uuid=content_uuid,
user=user
)
else:
type, id = from_global_id(parent)
if type == 'ModuleNode':
bookmark = ModuleBookmark.objects.get(
module__id=id,
user=user
)
else:
bookmark = ChapterBookmark.objects.get(
chapter__id=id,
user=user
)
bookmark.note = Note.objects.create(text=text)
bookmark.save()
@ -88,7 +104,9 @@ class UpdateNote(relay.ClientIDMutation):
text = note.get('text')
note = get_object(Note, id)
if note.contentblockbookmark.user != user:
if hasattr(note, 'contentblockbookmark') and note.contentblockbookmark.user != user \
or hasattr(note, 'chapterbookmark') and note.chapterbookmark.user != user \
or hasattr(note, 'modulebookmark') and note.modulebookmark.user != user:
raise PermissionError
note.text = text
@ -96,7 +114,67 @@ class UpdateNote(relay.ClientIDMutation):
return cls(note=note)
class UpdateChapterBookmark(relay.ClientIDMutation):
class Input:
chapter = graphene.ID(required=True)
bookmarked = graphene.Boolean(required=True)
success = graphene.Boolean()
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
user = info.context.user
chapter_id = kwargs.get('chapter')
bookmarked = kwargs.get('bookmarked')
chapter = get_object(Chapter, chapter_id)
if bookmarked:
ChapterBookmark.objects.create(
chapter=chapter,
user=user
)
else:
ChapterBookmark.objects.get(
chapter=chapter,
user=user
).delete()
return cls(success=True)
class UpdateModuleBookmark(relay.ClientIDMutation):
class Input:
module = graphene.ID(required=True)
bookmarked = graphene.Boolean(required=True)
success = graphene.Boolean()
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
user = info.context.user
module_id = kwargs.get('module')
bookmarked = kwargs.get('bookmarked')
module = get_object(Module, module_id)
if bookmarked:
ModuleBookmark.objects.create(
module=module,
user=user
)
else:
ModuleBookmark.objects.get(
module=module,
user=user
).delete()
return cls(success=True)
class NoteMutations:
add_note = AddNote.Field()
update_note = UpdateNote.Field()
update_content_bookmark = UpdateContentBookmark.Field()
update_chapter_bookmark = UpdateChapterBookmark.Field()
update_module_bookmark = UpdateModuleBookmark.Field()

View File

@ -2,7 +2,7 @@ import graphene
from graphene import relay
from graphene_django import DjangoObjectType
from notes.models import Note, ContentBlockBookmark
from notes.models import Note, ContentBlockBookmark, ModuleBookmark, ChapterBookmark
class NoteNode(DjangoObjectType):
@ -23,3 +23,17 @@ class ContentBlockBookmarkNode(DjangoObjectType):
class Meta:
model = ContentBlockBookmark
class ModuleBookmarkNode(DjangoObjectType):
note = graphene.Field(NoteNode)
class Meta:
model = ModuleBookmark
class ChapterBookmarkNode(DjangoObjectType):
note = graphene.Field(NoteNode)
class Meta:
model = ChapterBookmark

View File

@ -44,7 +44,7 @@ class User(AbstractUser):
def get_teacher(self):
if self.user_roles.filter(role__key='teacher').exists():
return self
elif self.school_classes.count()>0:
elif self.school_classes.count() > 0:
return self.school_classes.first().get_teacher()
else:
return None