diff --git a/core/tests/test_feedback.py b/core/tests/test_feedback.py index ad541d8f7f173a8171f9637b2a3d2dab136e5c92..da2efe5582d822041d000b4a07025f3378ae3c35 100644 --- a/core/tests/test_feedback.py +++ b/core/tests/test_feedback.py @@ -502,7 +502,7 @@ class FeedbackCommentApiEndpointTest(APITestCase): response = self.client.delete(self.url % comment.pk) self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) - def test_reviewer_can_delete_everything_they_want(self): + def test_reviewer_can_delete_other_users_comments(self): reviewer = self.data['reviewers'][0] self.client.force_authenticate(user=reviewer) comment01 = FeedbackComment.objects.get(of_tutor=self.tutor01) @@ -512,10 +512,11 @@ class FeedbackCommentApiEndpointTest(APITestCase): self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) response = self.client.delete(self.url % comment02.pk) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + self.assertTrue(FeedbackComment.objects.filter(of_tutor=self.tutor02).exists(), + msg='Second comment should not be deleted for feedback with not full score') try: FeedbackComment.objects.get(of_tutor=self.tutor01) - FeedbackComment.objects.get(of_tutor=self.tutor02) except FeedbackComment.DoesNotExist: pass else: diff --git a/core/views/feedback.py b/core/views/feedback.py index 0f681bdbf81ae16c8884c2eee2a06f02494b5943..f633009615a01ed2ad4db448ffe1e3e41ea03cc8 100644 --- a/core/views/feedback.py +++ b/core/views/feedback.py @@ -1,4 +1,5 @@ import logging +from multiprocessing import Lock from rest_framework import mixins, status, viewsets from rest_framework.exceptions import PermissionDenied @@ -143,3 +144,14 @@ class FeedbackCommentApiView( serializer.is_valid() serializer.save() return Response(serializer.data) + + def destroy(self, request, *args, **kwargs): + with Lock(): + instance = self.get_object() + if instance.of_feedback.feedback_lines.count() == 1 and \ + not instance.of_feedback.is_full_score(): + raise PermissionDenied(detail="Last comment can not be deleted for submissions " + "with non full score") + + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/frontend/src/components/student_list/StudentListHelpCard.vue b/frontend/src/components/student_list/StudentListHelpCard.vue index 637443a44f0f16268c6d27a4df8b9f3a92f5f442..79f156cd86bedc28343267861ec08c6801e0b86d 100644 --- a/frontend/src/components/student_list/StudentListHelpCard.vue +++ b/frontend/src/components/student_list/StudentListHelpCard.vue @@ -1,5 +1,5 @@ <template> - <v-layout justify-center class="mg-bottom"> + <v-layout justify-center> <v-card class="mt-5"> <v-card-title class="title"> This is the student overview page! @@ -25,7 +25,7 @@ export default { </script> <style scoped> - .mg-bottom { + /* .mg-bottom { margin-bottom: 25px; - } + } */ </style> diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue index 4dc2172cf715c061ce4295173c75489b0b9f5669..5f0a7e370851826577ff780c97720c553feb6de3 100644 --- a/frontend/src/components/submission_notes/SubmissionCorrection.vue +++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue @@ -146,6 +146,8 @@ export default { duration: -1 }) }).finally(() => { + this.$emit('feedbackChanged') + SubmissionNotes.RESET_MARKED_COMMENTS_FOR_DELETE() this.loading = false }) }, diff --git a/frontend/src/pages/StudentSubmissionSideView.vue b/frontend/src/pages/StudentSubmissionSideView.vue index 91086e733301e68cc1140dce9e5bc975dbfdac3f..d2859dad1a71d3421167b17467c3a38d484f4215 100644 --- a/frontend/src/pages/StudentSubmissionSideView.vue +++ b/frontend/src/pages/StudentSubmissionSideView.vue @@ -5,6 +5,7 @@ :submission-without-assignment="submission" :feedback="submission.feedback" @feedbackCreated="refresh" + @feedbackChanged="refresh" :ignoreHiddenState="true" /> <submission-tests diff --git a/frontend/src/pages/reviewer/StudentOverviewPage.vue b/frontend/src/pages/reviewer/StudentOverviewPage.vue index 528c575edb142069b443749dc0c0f0d5072871e7..68c3d29c92f6c64f094a618b6c870907a61c22ee 100644 --- a/frontend/src/pages/reviewer/StudentOverviewPage.vue +++ b/frontend/src/pages/reviewer/StudentOverviewPage.vue @@ -3,7 +3,7 @@ <v-flex xs6> <student-list class="ma-1"></student-list> </v-flex> - <v-flex xs6> + <v-flex xs6 class="right-view"> <router-view></router-view> </v-flex> </v-layout> @@ -51,5 +51,10 @@ export default { </script> <style scoped> - + .right-view { + position: sticky; + top: 80px; + overflow-y: scroll; + height: 90vh; + } </style> diff --git a/frontend/src/store/modules/submission-notes.ts b/frontend/src/store/modules/submission-notes.ts index 9f7286bef81fbc611ed82b939b4f240f7344bae7..926cac6d1d1df23aa61ea282908a2f47fe8303b1 100644 --- a/frontend/src/store/modules/submission-notes.ts +++ b/frontend/src/store/modules/submission-notes.ts @@ -5,21 +5,22 @@ import { Feedback, FeedbackComment, SubmissionNoType } from '@/models' import { RootState } from '@/store/store' import { getStoreBuilder, BareActionContext } from 'vuex-typex' import { syntaxPostProcess } from '@/util/helpers'; +import { AxiosResponse } from 'axios'; export interface SubmissionNotesState { submission: SubmissionNoType ui: { - showEditorOnLine: {[lineNo: number]: boolean} - selectedCommentOnLine: {[lineNo: number]: FeedbackComment} - showFeedback: boolean + showEditorOnLine: { [lineNo: number]: boolean } + selectedCommentOnLine: { [lineNo: number]: FeedbackComment } + showFeedback: boolean }, hasOrigFeedback: boolean origFeedback: Feedback updatedFeedback: Feedback - commentsMarkedForDeletion: {[pk: string]: FeedbackComment} + commentsMarkedForDeletion: { [pk: string]: FeedbackComment } } -function initialState (): SubmissionNotesState { +function initialState(): SubmissionNotesState { return { submission: { text: '', @@ -52,90 +53,97 @@ const mb = getStoreBuilder<RootState>().module('SubmissionNotes', initialState() const stateGetter = mb.state() -const submissionTypeGetter = mb.read(function submissionType (state, getters, rootState) { +const submissionTypeGetter = mb.read(function submissionType(state, getters, rootState) { return rootState.submissionTypes[state.submission.type] }) // highlight the submission the reduce the string // submission.text into an object where the keys are the // line indexes starting at one and the values the corresponding submission line // this makes iterating over the submission much more pleasant -const submissionGetter = mb.read(function submission (state, getters) { +const submissionGetter = mb.read(function submission(state, getters) { const language = getters.submissionType ? getters.submissionType.programmingLanguage : 'c' const highlighted = hljs.highlight(language, state.submission.text || '', true).value const postProcessed = syntaxPostProcess(highlighted); - const splitted = postProcessed.split('\n').reduce((acc: {[k: number]: string}, cur, index) => { + const splitted = postProcessed.split('\n').reduce((acc: { [k: number]: string }, cur, index) => { acc[index + 1] = cur return acc }, {}) return splitted; }) -const scoreGetter = mb.read(function score (state) { +const scoreGetter = mb.read(function score(state) { return state.updatedFeedback.score !== undefined ? state.updatedFeedback.score : state.origFeedback.score }) -const workInProgressGetter = mb.read(function workInProgress (state) { +const workInProgressGetter = mb.read(function workInProgress(state) { const openEditor = Object.values(state.ui.showEditorOnLine).reduce((acc, curr) => acc || curr, false) const feedbackWritten = Object.entries(state.updatedFeedback.feedbackLines || {}).length > 0 return openEditor || feedbackWritten }) -const isFeedbackCreationGetter = mb.read(function isFeedbackCreation (state) { +const isFeedbackCreationGetter = mb.read(function isFeedbackCreation(state) { return !state.origFeedback['feedbackStageForUser'] || state.origFeedback['feedbackStageForUser'] === 'feedback-creation' }) -function SET_SUBMISSION (state: SubmissionNotesState, submission: SubmissionNoType) { +function SET_SUBMISSION(state: SubmissionNotesState, submission: SubmissionNoType) { state.submission = submission } -function SET_ORIG_FEEDBACK (state: SubmissionNotesState, feedback: Feedback) { +function SET_ORIG_FEEDBACK(state: SubmissionNotesState, feedback: Feedback) { if (feedback) { state.origFeedback = feedback state.hasOrigFeedback = true } } -function SET_SHOW_FEEDBACK (state: SubmissionNotesState, val: boolean) { +function SET_SHOW_FEEDBACK(state: SubmissionNotesState, val: boolean) { state.ui.showFeedback = val } -function UPDATE_FEEDBACK_LINE (state: SubmissionNotesState, feedback: {lineNo: number, comment: Partial<FeedbackComment>}) { +function UPDATE_FEEDBACK_LINE(state: SubmissionNotesState, feedback: { lineNo: number, comment: Partial<FeedbackComment> }) { // explicit .toString() on lineNo is necessary because of Vue.set typings if (state.updatedFeedback.feedbackLines) { Vue.set(state.updatedFeedback.feedbackLines, feedback.lineNo.toString(), feedback.comment) } } -function UPDATE_FEEDBACK_SCORE (state: SubmissionNotesState, score: number) { +function UPDATE_FEEDBACK_SCORE(state: SubmissionNotesState, score: number) { state.updatedFeedback.score = score } -function DELETE_FEEDBACK_LINE (state: SubmissionNotesState, lineNo: number) { +function DELETE_FEEDBACK_LINE(state: SubmissionNotesState, lineNo: number) { if (state.updatedFeedback.feedbackLines) { // Vue dosn't like objects that are indexed with numbers... Vue.delete(state.updatedFeedback.feedbackLines, <string><any>lineNo) } } -function TOGGLE_EDITOR_ON_LINE (state: SubmissionNotesState, { lineNo, comment }: {lineNo: number, comment: FeedbackComment}) { +function TOGGLE_EDITOR_ON_LINE(state: SubmissionNotesState, { lineNo, comment }: { lineNo: number, comment: FeedbackComment }) { Vue.set(state.ui.selectedCommentOnLine, <string><any>lineNo, comment) - Vue.set(state.ui.showEditorOnLine, <string><any> lineNo, !state.ui.showEditorOnLine[lineNo]) + Vue.set(state.ui.showEditorOnLine, <string><any>lineNo, !state.ui.showEditorOnLine[lineNo]) } -function MARK_COMMENT_FOR_DELETION (state: SubmissionNotesState, comment: FeedbackComment) { +function MARK_COMMENT_FOR_DELETION(state: SubmissionNotesState, comment: FeedbackComment) { Vue.set(state.commentsMarkedForDeletion, comment.pk, comment) } -function UN_MARK_COMMENT_FOR_DELETION (state: SubmissionNotesState, comment: FeedbackComment) { +function UN_MARK_COMMENT_FOR_DELETION(state: SubmissionNotesState, comment: FeedbackComment) { Vue.delete(state.commentsMarkedForDeletion, comment.pk) } -function RESET_UPDATED_FEEDBACK (state: SubmissionNotesState) { +function RESET_MARKED_COMMENTS_FOR_DELETE(state: SubmissionNotesState) { + state.commentsMarkedForDeletion = {} +} +function RESET_UPDATED_FEEDBACK(state: SubmissionNotesState) { state.updatedFeedback = initialState().updatedFeedback } -function RESET_STATE (state: SubmissionNotesState) { +function RESET_STATE(state: SubmissionNotesState) { Object.assign(state, initialState()) } -async function deleteComments ({ state }: BareActionContext<SubmissionNotesState, RootState>) { +async function deleteComments({ state }: BareActionContext<SubmissionNotesState, RootState>) { return Promise.all( Object.values(state.commentsMarkedForDeletion).map(comment => { return api.deleteComment(comment) }) ) } -async function submitFeedback ({ state }: BareActionContext<SubmissionNotesState, RootState>, { isFinal = false }) { +async function submitFeedback( +{ state }: BareActionContext<SubmissionNotesState, RootState>, +{ isFinal = false }): +Promise<AxiosResponse<void>[]> { + let feedback: Partial<Feedback> = { isFinal: isFinal, ofSubmission: state.submission.pk @@ -153,23 +161,23 @@ async function submitFeedback ({ state }: BareActionContext<SubmissionNotesState } else if (feedback.score! < SubmissionNotes.submissionType.fullScore! && !state.hasOrigFeedback) { throw new Error('You need to add or change a comment when setting a non full score.') } - // delete those comments that have been marked for deletion - await SubmissionNotes.deleteComments() if (!state.hasOrigFeedback) { - return api.submitFeedbackForAssignment({ feedback }) + await api.submitFeedbackForAssignment({ feedback }) } else { feedback.pk = state.origFeedback.pk - return api.submitUpdatedFeedback(<{feedback: Feedback}> { feedback }) + await api.submitUpdatedFeedback(<{ feedback: Feedback }>{ feedback }) } + // delete those comments that have been marked for deletion + return SubmissionNotes.deleteComments() } export const SubmissionNotes = { - get state () { return stateGetter() }, - get submissionType () { return submissionTypeGetter() }, - get submission () { return submissionGetter() }, - get score () { return scoreGetter() }, - get workInProgress () { return workInProgressGetter() }, - get isFeedbackCreation () { return isFeedbackCreationGetter() }, + get state() { return stateGetter() }, + get submissionType() { return submissionTypeGetter() }, + get submission() { return submissionGetter() }, + get score() { return scoreGetter() }, + get workInProgress() { return workInProgressGetter() }, + get isFeedbackCreation() { return isFeedbackCreationGetter() }, SET_SUBMISSION: mb.commit(SET_SUBMISSION), SET_ORIG_FEEDBACK: mb.commit(SET_ORIG_FEEDBACK), @@ -180,6 +188,7 @@ export const SubmissionNotes = { TOGGLE_EDITOR_ON_LINE: mb.commit(TOGGLE_EDITOR_ON_LINE), MARK_COMMENT_FOR_DELETION: mb.commit(MARK_COMMENT_FOR_DELETION), UN_MARK_COMMENT_FOR_DELETION: mb.commit(UN_MARK_COMMENT_FOR_DELETION), + RESET_MARKED_COMMENTS_FOR_DELETE: mb.commit(RESET_MARKED_COMMENTS_FOR_DELETE), RESET_UPDATED_FEEDBACK: mb.commit(RESET_UPDATED_FEEDBACK), RESET_STATE: mb.commit(RESET_STATE),