diff --git a/core/serializers/__init__.py b/core/serializers/__init__.py index 0306ce607329110c9e7268cf88d30968638a2615..20236b110f2d5e4079f7e7af4d2c13456667aef0 100644 --- a/core/serializers/__init__.py +++ b/core/serializers/__init__.py @@ -1,5 +1,6 @@ from .common_serializers import * # noqa -from .feedback import FeedbackSerializer, FeedbackCommentSerializer # noqa +from .feedback import (FeedbackSerializer, FeedbackCommentSerializer, # noqa + VisibleCommentFeedbackSerializer) # noqa from .subscription import * # noqa from .student import * # noqa from .submission import * # noqa diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py index d5e48833dc0b3cb257462186fd57af307399cd84..9a4317ff814323d4015a1fa13ee85b1248e59412 100644 --- a/core/serializers/feedback.py +++ b/core/serializers/feedback.py @@ -62,7 +62,7 @@ class FeedbackCommentDictionarySerializer(serializers.ListSerializer): return ret -class FeedbackCommentSerializer(serializers.ModelSerializer): +class FeedbackCommentSerializer(DynamicFieldsModelSerializer): of_tutor = serializers.StringRelatedField(source='of_tutor.username') class Meta: @@ -179,3 +179,28 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): model = Feedback fields = ('pk', 'of_submission', 'is_final', 'score', 'feedback_lines', 'created', 'of_submission_type', 'feedback_stage_for_user') + + +class VisibleCommentFeedbackSerializer(FeedbackSerializer): + feedback_lines = serializers.SerializerMethodField() + of_submission_type = serializers.ReadOnlyField( + source='of_submission.type.pk') + + def get_feedback_lines(self, feedback): + comments = feedback.feedback_lines.filter(visible_to_student=True) + serializer = FeedbackCommentSerializer( + comments, + many=True, + fields=('pk', 'text', 'created', 'of_line',) + ) + # this is a weird hack because, for some reason, serializer.data + # just won't contain the correct data. Instead .data returns a list + # containing just the `of_line` attr of the serialized comments + # after long debugging i found that for inexplicable reasons + # `data.serializer._data` contains the correct data. No clue why. + return serializer.data.serializer._data + + class Meta: + model = Feedback + fields = ('pk', 'of_submission', 'is_final', 'score', 'feedback_lines', + 'created', 'of_submission_type') diff --git a/core/serializers/student.py b/core/serializers/student.py index d09aade105325a0dceb4fb5c9f5478c6f915152e..efa8c537fbe9ed2023c1ab16a4d1f1217c97a3af 100644 --- a/core/serializers/student.py +++ b/core/serializers/student.py @@ -22,7 +22,9 @@ class StudentInfoSerializerForListView(DynamicFieldsModelSerializer): user = serializers.ReadOnlyField(source='user.username') exam = serializers.ReadOnlyField(source='exam.module_reference') submissions = SubmissionNoTextFieldsSerializer(many=True) + is_active = serializers.BooleanField(source='user.is_active') class Meta: model = StudentInfo - fields = ('pk', 'name', 'user', 'exam', 'submissions', 'matrikel_no') + fields = ('pk', 'name', 'user', 'exam', 'submissions', + 'matrikel_no', 'is_active') diff --git a/core/serializers/submission.py b/core/serializers/submission.py index 8286e58ba29ee0d05f3196951ee8f818505492cb..8e0b1d0853ea7d44768eb42913a67e556c867888 100644 --- a/core/serializers/submission.py +++ b/core/serializers/submission.py @@ -2,6 +2,7 @@ from rest_framework import serializers from core.models import Submission from core.serializers import (DynamicFieldsModelSerializer, FeedbackSerializer, + VisibleCommentFeedbackSerializer, SubmissionTypeListSerializer, SubmissionTypeSerializer, TestSerializer) @@ -18,7 +19,7 @@ class SubmissionNoTextFieldsSerializer(DynamicFieldsModelSerializer): class SubmissionSerializer(DynamicFieldsModelSerializer): type = SubmissionTypeSerializer() - feedback = FeedbackSerializer() + feedback = VisibleCommentFeedbackSerializer() tests = TestSerializer(many=True) class Meta: diff --git a/core/tests/test_student_page.py b/core/tests/test_student_page.py index c8a1365b5ecbad07fbba760276c7774e9a6a2d30..fa57aea9f949c6a73bb2f06fec1f3faec48e1773 100644 --- a/core/tests/test_student_page.py +++ b/core/tests/test_student_page.py @@ -139,9 +139,14 @@ class StudentSelfSubmissionsTests(APITestCase): 'students': [{ 'username': 'user01', }], - 'tutors': [{ - 'username': 'tutor01' - }], + 'tutors': [ + { + 'username': 'tutor01' + }, + { + 'username': 'tutor02' + } + ], 'submissions': [{ 'user': 'user01', 'type': 'problem01', @@ -150,10 +155,19 @@ class StudentSelfSubmissionsTests(APITestCase): 'text': 'Very bad!', 'score': 3, 'feedback_lines': { - '1': [{ - 'text': 'This is very bad!', - 'of_tutor': 'tutor01' - }], + '1': [ + { + 'text': 'This is very bad!', + 'of_tutor': 'tutor01', + # explicitness to required + # will also be set automatically + 'visible_to_student': False + }, + { + 'text': 'This is good!', + 'of_tutor': 'tutor02' + } + ], } } }] @@ -210,12 +224,20 @@ class StudentSelfSubmissionsTests(APITestCase): self.submission_list_first_entry['feedback']['score'], self.student_info.submissions.first().feedback.score) - def submssion_feedback_contains_submission_lines(self): + def test_submission_feedback_contains_submission_lines(self): self.assertIn( 'feedback_lines', self.submission_list_first_entry['feedback'] ) + def test_feedback_contains_one_comment_per_line(self): + lines = self.submission_list_first_entry['feedback']['feedback_lines'] + self.assertEqual(len(lines[1]), 1) + + def test_feedback_comment_does_not_contain_tutor(self): + lines = self.submission_list_first_entry['feedback']['feedback_lines'] + self.assertNotIn('of_tutor', lines[1][0]) + # We don't want a matriculation number here def test_matriculation_number_is_not_send(self): self.assertNotIn('matrikel_no', self.submission_list_first_entry) diff --git a/core/tests/test_student_reviewer_viewset.py b/core/tests/test_student_reviewer_viewset.py index 9d22d7c1f24e4bc8fec393f2bdb6eb06d187ede7..9e07425edfe4f2808f406b9d97319f9276940723 100644 --- a/core/tests/test_student_reviewer_viewset.py +++ b/core/tests/test_student_reviewer_viewset.py @@ -3,6 +3,7 @@ from rest_framework import status from rest_framework.test import (APIRequestFactory, APITestCase, force_authenticate) +from core import models from core.views import StudentReviewerApiViewSet from util.factories import make_test_data @@ -26,11 +27,18 @@ class StudentPageTests(APITestCase): 'description': 'Very hard', 'solution': 'Impossible!' }], - 'students': [{ - 'username': 'user01', - 'fullname': 'us er01', - 'exam': 'TestExam B.Inf.0042' - }], + 'students': [ + { + 'username': 'user01', + 'fullname': 'us er01', + 'exam': 'TestExam B.Inf.0042' + }, + { + 'username': 'user02', + 'exam': 'TestExam B.Inf.0042' + } + + ], 'tutors': [{ 'username': 'tutor' }], @@ -67,7 +75,7 @@ class StudentPageTests(APITestCase): self.assertEqual(self.response.status_code, status.HTTP_200_OK) def test_can_see_all_students(self): - self.assertEqual(1, len(self.response.data)) + self.assertEqual(2, len(self.response.data)) def test_submissions_score_is_included(self): self.assertEqual(self.student.submissions.first().feedback.score, @@ -77,3 +85,15 @@ class StudentPageTests(APITestCase): print(self.response.data[0]['submissions'][0]) self.assertEqual(self.student.submissions.first().type.full_score, self.response.data[0]['submissions'][0]['full_score']) + + def test_can_deactivate_all_students(self): + self.client.force_authenticate(user=self.reviewer) + self.client.post(reverse('student-list') + 'deactivate/') + users = [stud.user for stud in models.StudentInfo.objects.all()] + self.assertTrue(all([not user.is_active for user in users])) + + def test_can_activate_all_students(self): + self.client.force_authenticate(user=self.reviewer) + self.client.post(reverse('student-list') + 'activate/') + users = [stud.user for stud in models.StudentInfo.objects.all()] + self.assertTrue(all([user.is_active for user in users])) diff --git a/core/views/common_views.py b/core/views/common_views.py index 8aed85b1e5e90870d6e1cd5412f25921cd85c8da..ff4d6abbcd8fa28dce6b5aa20e71bb243f5b3161 100644 --- a/core/views/common_views.py +++ b/core/views/common_views.py @@ -5,8 +5,8 @@ import logging from django.conf import settings from django.db.models import Avg -from rest_framework import generics, mixins, viewsets -from rest_framework.decorators import api_view +from rest_framework import generics, mixins, viewsets, status +from rest_framework.decorators import api_view, list_route from rest_framework.response import Response from core import models @@ -63,6 +63,22 @@ class StudentReviewerApiViewSet(viewsets.ReadOnlyModelViewSet): .all() serializer_class = StudentInfoSerializerForListView + def _set_students_active(self, active): + for student in self.get_queryset(): + user = student.user + user.is_active = active + user.save() + + @list_route(methods=['post']) + def deactivate(self, request): + self._set_students_active(False) + return Response(status=status.HTTP_200_OK) + + @list_route(methods=['post']) + def activate(self, request): + self._set_students_active(True) + return Response(status=status.HTTP_200_OK) + class ExamApiViewSet(viewsets.ReadOnlyModelViewSet): """ Gets a list of an individual exam by Id if provided """ diff --git a/frontend/src/api.js b/frontend/src/api.js index 0e040c64290f0510f4dd1393704f6af999fbfb38..26799adabe41b99cebce55c8b9acfd1eec67fe6c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -167,4 +167,12 @@ export async function patchComment (comment = {pk: undefined}) { return (await ax.patch(url, comment)).data } +export async function activateAllStudentAccess () { + return ax.post('/api/student/activate/') +} + +export async function deactivateAllStudentAccess () { + return ax.post('/api/student/deactivate/') +} + export default ax diff --git a/frontend/src/components/student/ExamInformation.vue b/frontend/src/components/student/ExamInformation.vue index 21795f0503e1eb9d97590fe94ac943eda1243318..6ebcf482f13d6cf3b2ef3b19471eaaa4fbd20c34 100644 --- a/frontend/src/components/student/ExamInformation.vue +++ b/frontend/src/components/student/ExamInformation.vue @@ -2,7 +2,7 @@ <table class="table table-info rounded"> <tbody> <tr> - <th>Modul</th> + <th>Module</th> <td>{{ exam.module_reference }}</td> </tr> <tr> diff --git a/frontend/src/components/student/NonFinalFeedbackAlert.vue b/frontend/src/components/student/NonFinalFeedbackAlert.vue new file mode 100644 index 0000000000000000000000000000000000000000..801c22043543af0da92519a276f3e552bfb402bb --- /dev/null +++ b/frontend/src/components/student/NonFinalFeedbackAlert.vue @@ -0,0 +1,23 @@ +<template> + <v-alert type="warning" :value="value" class="non-final-alert "> + This feedback is not final! Changes will likely occur! + </v-alert> +</template> + +<script> + export default { + name: 'non-final-feedback-alert', + props: { + value: { + type: Boolean, + default: true + } + } + } +</script> + +<style scoped> + .non-final-alert { + font-weight: bolder; + } +</style> diff --git a/frontend/src/components/student/SubmissionList.vue b/frontend/src/components/student/SubmissionList.vue index 916563d5d09bdbbf2a7dae9fc1fc0add52e5ec48..1c624da3c6ecd2e5ba08f778880e2d1deb61ddca 100644 --- a/frontend/src/components/student/SubmissionList.vue +++ b/frontend/src/components/student/SubmissionList.vue @@ -10,7 +10,11 @@ <td>{{ props.item.type.name }}</td> <td class="text-xs-right">{{ props.item.feedback.score }}</td> <td class="text-xs-right">{{ props.item.type.full_score }}</td> - <td class="text-xs-right"><v-btn :to="`/student/submission/${props.item.type.pk}`" color="orange lighten-2"><v-icon>chevron_right</v-icon></v-btn></td> + <td class="text-xs-right"> + <v-btn :to="`/submission/${props.item.type.pk}`" color="orange lighten-2"> + <v-icon>chevron_right</v-icon> + </v-btn> + </td> </template> </v-data-table> <v-alert color="info" value="true"> @@ -34,11 +38,18 @@ }, { text: 'Score', + align: 'right', value: 'feedback.score' }, { text: 'Maximum Score', + align: 'right', value: 'type.full_score' + }, + { + text: 'View', + align: 'center', + sortable: false } ] } diff --git a/frontend/src/components/student_list/StudentList.vue b/frontend/src/components/student_list/StudentList.vue index cdca5006422f1345fcbb92a6f36260e678ec33f4..fd8e2e16bd559cc736a2edc640d3e8141e7ac097 100644 --- a/frontend/src/components/student_list/StudentList.vue +++ b/frontend/src/components/student_list/StudentList.vue @@ -14,6 +14,7 @@ ></v-text-field> <v-card-actions> <v-btn icon @click="refresh"><v-icon>refresh</v-icon></v-btn> + <student-list-menu/> </v-card-actions> </v-card-title> <v-data-table @@ -98,8 +99,10 @@ <script> import {mapActions, mapState} from 'vuex' + import StudentListMenu from '@/components/student_list/StudentListMenu' export default { + components: {StudentListMenu}, name: 'student-list', data () { return { diff --git a/frontend/src/components/student_list/StudentListMenu.vue b/frontend/src/components/student_list/StudentListMenu.vue new file mode 100644 index 0000000000000000000000000000000000000000..a1a2c92ffd62be04e7c3872ef330933d1c57c7ed --- /dev/null +++ b/frontend/src/components/student_list/StudentListMenu.vue @@ -0,0 +1,78 @@ +<template> + <v-menu open-on-hover bottom offset-y> + <v-btn icon slot="activator"> + <v-icon>menu</v-icon> + </v-btn> + <v-list> + <v-list-tile v-for="item in items" :key="item.title" @click="item.action"> + <v-list-tile-title>{{ item.title }}</v-list-tile-title> + </v-list-tile> + </v-list> + </v-menu> +</template> + +<script> + import {activateAllStudentAccess, + deactivateAllStudentAccess} from '@/api' + + export default { + name: 'student-list-menu', + computed: { + studentsActive () { + const firstStudent = Object.values(this.$store.state.students)[0] + return firstStudent ? firstStudent.is_active === true : false + }, + items () { + return [ + { + title: this.studentsActive + ? 'Deactivate student access' + : 'Activate student access', + action: this.changeStudentsAccess + } + ] + } + }, + methods: { + updateStudentData (fields = []) { + this.$store.dispatch('getStudents', { + studentPks: Object.keys(this.$store.state.students), + fields + }).catch(() => { + this.$notify({ + title: 'ERROR', + text: 'Unable to update student data!', + type: 'error' + }) + }) + }, + changeStudentsAccess () { + if (this.studentsActive) { + deactivateAllStudentAccess().then(() => { + this.updateStudentData() + }).catch(() => { + this.$notify({ + title: 'ERROR', + text: 'Unable to disable access', + type: 'error' + }) + }) + } else { + activateAllStudentAccess().then(() => { + this.updateStudentData() + }).catch(() => { + this.$notify({ + title: 'ERROR', + text: 'Unable to activate access', + type: 'error' + }) + }) + } + } + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/student/StudentLayout.vue b/frontend/src/pages/student/StudentLayout.vue index c3100896d7ac58d9db20ec31e7dc11bb07a51f80..02e351af65cf11e1d949f05601a9d58d67292fc9 100644 --- a/frontend/src/pages/student/StudentLayout.vue +++ b/frontend/src/pages/student/StudentLayout.vue @@ -25,7 +25,7 @@ v-if="!mini" class="elevation-1 exam-info ma-1" /> - <v-list-tile exact v-for="(item, i) in submissionNavItems" :key="i" :to="item.route"> + <v-list-tile exact v-for="item in submissionNavItems" :key="item.route" :to="item.route"> <v-list-tile-action> <v-icon v-if="!visited[item.id]">assignment</v-icon> <v-icon v-else>check</v-icon> @@ -54,11 +54,6 @@ name: 'Overview', icon: 'home', route: '/home' - }, - { - name: 'Statistics', - icon: 'show_chart', - route: '/home' } ] } diff --git a/frontend/src/pages/student/StudentSubmissionPage.vue b/frontend/src/pages/student/StudentSubmissionPage.vue index b751e545159d569d1159d8fc583864b5c7e5adbf..80f6e312fb0e3e2798b07f9f43f1c032ea543f83 100644 --- a/frontend/src/pages/student/StudentSubmissionPage.vue +++ b/frontend/src/pages/student/StudentSubmissionPage.vue @@ -1,5 +1,6 @@ <template> <v-container flex> + <non-final-feedback-alert :value="!feedbackIsFinal"/> <v-layout row wrap> <v-flex lg6 md12 mt-5> <base-annotated-submission> @@ -19,18 +20,25 @@ </v-toolbar> <template slot="table-content"> <tr v-for="(code, lineNo) in submission" :key="lineNo"> - <submission-line :code="code" :lineNo="lineNo"/> - <feedback-comment - v-if="feedback[lineNo] && showFeedback" - v-for="(comment, index) in feedback[lineNo]" - v-bind="comment" - :key="index" - /> + <submission-line :code="code" :lineNo="lineNo"> + <feedback-comment + v-if="feedback[lineNo] && showFeedback" + v-for="(comment, index) in feedback[lineNo]" + v-bind="comment" + :line-no="lineNo" + :key="index" + /> + </submission-line> </tr> </template> </base-annotated-submission> + <submission-tests + :tests="submission.tests" + :expand="true" + class="mt-3" + ></submission-tests> </v-flex> - <v-flex lg6 md12> + <v-flex lg6 md12 mt-5 pl-3> <submission-type v-bind="submissionType"> </submission-type> @@ -49,10 +57,14 @@ import FeedbackComment from '@/components/submission_notes/base/FeedbackComment' import {studentPageMut} from '@/store/modules/student-page' import {subNotesMut} from '@/store/modules/submission-notes' + import SubmissionTests from '@/components/SubmissionTests' + import NonFinalFeedbackAlert from '@/components/student/NonFinalFeedbackAlert' export default { name: 'student-submission-page', components: { + NonFinalFeedbackAlert, + SubmissionTests, FeedbackComment, SubmissionLine, BaseAnnotatedSubmission, @@ -71,10 +83,13 @@ 'submission' ]), ...mapState({ - score: function (state) { return state.studentPage.submissionData[this.id].feedback.score }, - submissionType: function (state) { return state.studentPage.submissionData[this.id].type }, - feedback: function (state) { + score (state) { return state.studentPage.submissionData[this.id].feedback.score }, + submissionType (state) { return state.studentPage.submissionData[this.id].type }, + feedback (state) { return state.studentPage.submissionData[this.$route.params.id].feedback.feedback_lines + }, + feedbackIsFinal (state) { + return state.studentPage.submissionData[this.$route.params.id].feedback.is_final } }) }, @@ -87,9 +102,6 @@ }, mounted () { this.onRouteMountOrUpdate(this.id) - this.$nextTick(() => { - window.PR.prettyPrint() - }) }, beforeRouteUpdate (to, from, next) { this.onRouteMountOrUpdate(to.params.id)