diff --git a/core/models.py b/core/models.py index 7acdf52b622ef530bf837a87c1b305c3f8cc0565..80c3a42fde446ee8022779ccca3695c42b868c7f 100644 --- a/core/models.py +++ b/core/models.py @@ -180,11 +180,6 @@ class UserAccount(AbstractUser): def is_reviewer(self): return self.role == 'Reviewer' - def done_assignments_count(self) -> int: - # TODO optimize this query - return sum([subscription.assignments.filter(done=True).count() - for subscription in self.subscriptions.all()]) - @classmethod def get_students(cls): return cls.objects.filter(role=cls.STUDENT) diff --git a/core/serializers/common_serializers.py b/core/serializers/common_serializers.py index 72c0a437d1871efccb5ff5f6987f1529c00748e9..52e22e7fc59d23e8cbba0680d455f3aa70cebcb1 100644 --- a/core/serializers/common_serializers.py +++ b/core/serializers/common_serializers.py @@ -2,7 +2,7 @@ import logging from rest_framework import serializers -from core.models import ExamType, SubmissionType, Test, UserAccount +from core import models from util.factories import GradyUserFactory from .generic import DynamicFieldsModelSerializer @@ -14,7 +14,7 @@ user_factory = GradyUserFactory() class ExamSerializer(DynamicFieldsModelSerializer): class Meta: - model = ExamType + model = models.ExamType fields = ('pk', 'module_reference', 'total_score', 'pass_score', 'pass_only',) @@ -22,33 +22,50 @@ class ExamSerializer(DynamicFieldsModelSerializer): class TestSerializer(DynamicFieldsModelSerializer): class Meta: - model = Test + model = models.Test fields = ('pk', 'name', 'label', 'annotation') class SubmissionTypeListSerializer(DynamicFieldsModelSerializer): class Meta: - model = SubmissionType + model = models.SubmissionType fields = ('pk', 'name', 'full_score') class SubmissionTypeSerializer(SubmissionTypeListSerializer): class Meta: - model = SubmissionType + model = models.SubmissionType fields = ('pk', 'name', 'full_score', 'description', 'solution') class TutorSerializer(DynamicFieldsModelSerializer): - done_assignments_count = serializers.IntegerField( - read_only=True) - - def create(self, validated_data) -> UserAccount: + feedback_created = serializers.SerializerMethodField() + feedback_validated = serializers.SerializerMethodField() + + @staticmethod + def _get_completed_assignments(obj): + return models.TutorSubmissionAssignment.objects.filter( + is_done=True, + subscription__owner=obj, + ) + + def get_feedback_created(self, obj): + return self._get_completed_assignments(obj).filter( + subscription__feedback_stage=models.GeneralTaskSubscription.FEEDBACK_CREATION # noqa + ).count() + + def get_feedback_validated(self, obj): + return self._get_completed_assignments(obj).filter( + subscription__feedback_stage=models.GeneralTaskSubscription.FEEDBACK_VALIDATION # noqa + ).count() + + def create(self, validated_data) -> models.UserAccount: log.info("Crating tutor from data %s", validated_data) return user_factory.make_tutor( username=validated_data['username']) class Meta: - model = UserAccount - fields = ('pk', 'username', 'done_assignments_count') + model = models.UserAccount + fields = ('pk', 'username', 'feedback_created', 'feedback_validated') diff --git a/core/serializers/submission.py b/core/serializers/submission.py index f8d777e4029156aefe3d3b5ff3672d051c5adc19..2851460590661fa89a4928befbe98d824c71fde4 100644 --- a/core/serializers/submission.py +++ b/core/serializers/submission.py @@ -8,7 +8,6 @@ from core.serializers import (DynamicFieldsModelSerializer, FeedbackSerializer, class SubmissionNoTextFieldsSerializer(DynamicFieldsModelSerializer): score = serializers.ReadOnlyField(source='feedback.score') - type = serializers.ReadOnlyField(source='type.name') full_score = serializers.ReadOnlyField(source='type.full_score') class Meta: @@ -26,9 +25,18 @@ class SubmissionSerializer(DynamicFieldsModelSerializer): fields = ('pk', 'type', 'text', 'feedback', 'tests') +class SubmissionNoTypeSerializer(DynamicFieldsModelSerializer): + feedback = FeedbackSerializer() + full_score = serializers.ReadOnlyField(source='type.full_score') + tests = TestSerializer(many=True) + + class Meta: + model = Submission + fields = ('pk', 'type', 'full_score', 'text', 'feedback', 'tests') + + class SubmissionListSerializer(DynamicFieldsModelSerializer): type = SubmissionTypeListSerializer(fields=('pk', 'name', 'full_score')) - # TODO change this according to new feedback model feedback = FeedbackSerializer() class Meta: diff --git a/core/tests/test_auth.py b/core/tests/test_auth.py index d9af01e801c52cd9de3e061c31ea3588252b306e..e517ccf073d5832d3a81491ee4ec7dfe2e998f1d 100644 --- a/core/tests/test_auth.py +++ b/core/tests/test_auth.py @@ -15,10 +15,10 @@ class AuthTests(APITestCase): cls.client = APIClient() def test_get_token(self): - response = self.client.post('/api-token-auth/', self.credentials) + response = self.client.post('/api/get-token/', self.credentials) self.assertContains(response, 'token') def test_refresh_token(self): - token = self.client.post('/api-token-auth/', self.credentials).data - response = self.client.post('/api-token-refresh/', token) + token = self.client.post('/api/get-token/', self.credentials).data + response = self.client.post('/api/refresh-token/', token) self.assertContains(response, 'token') diff --git a/core/urls.py b/core/urls.py index 4853a567b10041c67865b05753a145482a6350c9..6b37ef23531fce77a2fc49c0b28d1c202826991a 100644 --- a/core/urls.py +++ b/core/urls.py @@ -14,6 +14,7 @@ router.register('tutor', views.TutorApiViewSet, base_name='tutor') router.register('subscription', views.SubscriptionApiViewSet, base_name='subscription') router.register('assignment', views.AssignmentApiViewSet) +router.register('submission', views.SubmissionViewSet) # regular views that are not viewsets regular_views_urlpatterns = [ diff --git a/core/views/common_views.py b/core/views/common_views.py index 344db1a285598053c5216d8d8d6b672f83b82de4..b79dec5c6eaeefc8cde76beb1a7613d4471fc385 100644 --- a/core/views/common_views.py +++ b/core/views/common_views.py @@ -14,7 +14,7 @@ from core.permissions import IsReviewer, IsStudent from core.serializers import (ExamSerializer, StudentInfoSerializer, StudentInfoSerializerForListView, SubmissionSerializer, SubmissionTypeSerializer, - TutorSerializer) + TutorSerializer, SubmissionNoTypeSerializer) log = logging.getLogger(__name__) @@ -82,3 +82,9 @@ class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet): """ Gets a list or a detail view of a single SubmissionType """ queryset = SubmissionType.objects.all() serializer_class = SubmissionTypeSerializer + + +class SubmissionViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = (IsReviewer, ) + queryset = models.Submission.objects.all() + serializer_class = SubmissionNoTypeSerializer diff --git a/frontend/src/api.js b/frontend/src/api.js index 007ebc14f9879211e782a5c0d6d03d209c8b48b5..bbb375b5b58ea53474bfd129b99bb4f49153d8df 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -20,13 +20,13 @@ let ax = axios.create({ }) export async function fetchJWT (credentials) { - const token = (await ax.post('/api-token-auth/', credentials)).data.token + const token = (await ax.post('/api/get-token/', credentials)).data.token ax.defaults.headers['Authorization'] = `JWT ${token}` return token } export async function refreshJWT (token) { - const newToken = (await ax.post('/api-token-refresh/', {token})).data.token + const newToken = (await ax.post('/api/refresh-token/', {token})).data.token ax.defaults.headers['Authorization'] = `JWT ${newToken}` return token } @@ -47,6 +47,26 @@ export async function fetchStudentSubmissions () { return (await ax.get('/api/student-submissions/')).data } +export async function fetchSubmissionFeedbackTests ({pk}) { + return (await ax.get(`/api/submission/${pk}`)).data +} + +export async function fetchAllStudents (fields = []) { + const url = addFieldsToUrl({ + url: '/api/student/', + fields + }) + return (await ax.get(url)).data +} + +export async function fetchAllTutors (fields = []) { + const url = addFieldsToUrl({ + url: '/api/tutor/', + fields + }) + return (await ax.get(url)).data +} + export async function fetchSubscriptions () { return (await ax.get('/api/subscription/')).data } @@ -94,6 +114,10 @@ export async function submitFeedbackForAssignment (feedback, assignmentPk) { return (await ax.post('/api/feedback/', data)).data } +export async function submitUpdatedFeedback (feedback) { + return (await ax.patch(`/api/feedback/${feedback.pk}/`, feedback)).data +} + export async function fetchSubmissionTypes (fields = []) { let url = '/api/submissiontype/' if (fields.length > 0) { diff --git a/frontend/src/components/student_list/StudentList.vue b/frontend/src/components/student_list/StudentList.vue new file mode 100644 index 0000000000000000000000000000000000000000..28f9b13b4b042de41307442929d9dd179a1ea1f7 --- /dev/null +++ b/frontend/src/components/student_list/StudentList.vue @@ -0,0 +1,174 @@ +<template> + <v-card> + <v-card-title> + <span class="title"> + Students + </span> + <v-spacer/> + <v-text-field + append-icon="search" + label="Search" + single-line + hide-details + v-model="search" + ></v-text-field> + </v-card-title> + <v-data-table + :headers="dynamicHeaders" + :items="studentListItems" + :search="search" + :pagination.sync="pagination" + item-key="name" + hide-actions + > + <template slot="headers" slot-scope="props"> + <tr> + <th + v-for="header in props.headers" :key="header.text" + :class="['column sortable', pagination.descending ? 'desc' : 'asc', header.value === pagination.sortBy ? 'active' : '']" + style="padding: 0;" + @click="changeSort(header.value)" + > + <v-icon>arrow_upward</v-icon> + {{ header.text }} + </th> + </tr> + </template> + <template slot="items" slot-scope="props"> + <tr> + <td> + <v-btn small icon @click="props.expanded = !props.expanded"> + <v-icon v-if="props.expanded">keyboard_arrow_up</v-icon> + <v-icon v-else>keyboard_arrow_down</v-icon> + </v-btn> + {{props.item.name}} + </td> + <td + v-for="type in submissionTypeHeaders" + style="padding: 0" + :key="type.name" + class="text-xs-right" + > + <v-btn + small round outline class="submission-button" + exact + :to="{name: 'submission-side-view', params: {pk: props.item[type.pk].pk}}" + > + {{props.item[type.pk].score}} + </v-btn> + </td> + <td + style="padding: 0 15px;" + class="text-xs-right" + >{{props.item.total}}</td> + </tr> + </template> + <template slot="expand" slot-scope="props"> + <v-card flat> + <v-card-text> + Modul: {{props.item.exam}} + </v-card-text> + </v-card> + </template> + </v-data-table> + </v-card> +</template> + +<script> + import {mapActions, mapState} from 'vuex' + + export default { + name: 'student-list', + data () { + return { + search: '', + pagination: { + sortBy: 'name', + rowsPerPage: Infinity + }, + staticHeaders: [ + { + text: 'Name', + align: 'left', + value: 'name' + } + ] + } + }, + computed: { + ...mapState([ + 'students' + ]), + submissionTypeHeaders () { + const subTypes = Object.values(this.$store.state.submissionTypes) + return subTypes.map(type => { + return { + pk: type.pk, + text: type.name.substr(0, 5), + value: `${type.pk}.score`, + align: 'right' + } + }) + }, + dynamicHeaders () { + const totalScoreHeader = { + text: 'Total', + align: 'right', + value: 'total' + } + let headers = this.staticHeaders.concat(this.submissionTypeHeaders) + headers.push(totalScoreHeader) + return headers + }, + studentListItems () { + return this.students.map(student => { + return { + pk: student.pk, + user: student.user, + exam: student.exam, + name: student.name, + ...this.reduceArrToDict(student.submissions, 'type'), + total: this.sumSubmissionScores(student.submissions) + } + }) + } + }, + methods: { + ...mapActions([ + 'getStudents' + ]), + reduceArrToDict (arr, key) { + return arr.reduce((acc, curr) => { + const keyInDict = curr[key] + acc[keyInDict] = curr + return acc + }, {}) + }, + sumSubmissionScores (submissions) { + return submissions.reduce((acc, curr) => { + if (curr.score) { + acc += curr.score + } + return acc + }, 0) + }, + changeSort (column) { + if (this.pagination.sortBy === column) { + this.pagination.descending = !this.pagination.descending + } else { + this.pagination.sortBy = column + this.pagination.descending = false + } + } + }, + created () { + this.getStudents() + } + } +</script> + +<style scoped> + .submission-button { + min-width: 40px; + } +</style> diff --git a/frontend/src/components/student_list/StudentListHelpCard.vue b/frontend/src/components/student_list/StudentListHelpCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5099096cdf3cd10fd2c509eb12db484b89731de --- /dev/null +++ b/frontend/src/components/student_list/StudentListHelpCard.vue @@ -0,0 +1,31 @@ +<template> + <v-container fill-height> + <v-layout align-center justify-center> + <v-card> + <v-card-title class="title"> + This is the student overview page! + </v-card-title> + <v-card-text> + To the left you see all students as well as their scores + per task type. You can do the following:<br><br> + <ol style="padding-left: 30px;"> + <li>click the little arrow on the left to see additional student information (matrikel no., module, etc.)</li> + <li>click on a students score to see their submission including feedback, tests, etc.<br>(You can even create Feedback here!)</li> + <li>sort the table via clicking on the table headers</li> + <li>search for a student via the search bar</li> + </ol> + </v-card-text> + </v-card> + </v-layout> + </v-container> +</template> + +<script> + export default { + name: 'student-list-help-card' + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue index f2d1151c7744009e9e85e72b1352affa76ddf02a..e1232a605bd9bdd8852034247dbe3fa73dc5a119 100644 --- a/frontend/src/components/submission_notes/SubmissionCorrection.vue +++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue @@ -93,8 +93,8 @@ ...mapState({ showEditorOnLine: state => state.submissionNotes.ui.showEditorOnLine, selectedComment: state => state.submissionNotes.ui.selectedCommentOnLine, - origFeedback: state => state.submissionNotes.orig.feedbackLines, - updatedFeedback: state => state.submissionNotes.updated.feedbackLines + origFeedback: state => state.submissionNotes.origFeedback.feedback_lines, + updatedFeedback: state => state.submissionNotes.updatedFeedback.feedback_lines }), ...mapGetters([ 'isStudent', @@ -121,9 +121,12 @@ toggleEditorOnLine (lineNo, comment = '') { this.$store.commit(subNotesNamespace(subNotesMut.TOGGLE_EDITOR_ON_LINE), {lineNo, comment}) }, - submitFeedback () { + submitFeedback ({isFinal}) { this.loading = true - this.$store.dispatch(subNotesNamespace('submitFeedback'), this.assignment).then(() => { + this.$store.dispatch(subNotesNamespace('submitFeedback'), { + assignment: this.assignment, + isFinal: isFinal + }).then(() => { this.$store.commit(subNotesNamespace(subNotesMut.RESET_STATE)) this.$emit('feedbackCreated') }).catch(err => { @@ -148,6 +151,12 @@ this.$nextTick(() => { window.PR.prettyPrint() }) + }, + submissionWithoutAssignment: function () { + this.init() + this.$nextTick(() => { + window.PR.prettyPrint() + }) } }, created () { diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue index f5b0e268ab3f7850f45209a4c8b519648da2497d..180d6f9231b35c981ac3c15abfcb8c8fa26cf662 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue @@ -26,6 +26,13 @@ @click="score = fullScore" color="blue darken-3" class="score-button">{{fullScore}}</v-btn> + <v-tooltip top v-if="showFinalCheckbox"> + <v-toolbar-items slot="activator" style="margin-top: 28px;"> + <v-checkbox slot="activator" v-model="isFinal" class="final-checkbox"/> + <span>Final</span> + </v-toolbar-items> + <span>Non final feedback will be sent to the reviewer.</span> + </v-tooltip> <v-tooltip top> <v-btn color="success" @@ -45,7 +52,8 @@ name: 'annotated-submission-bottom-toolbar', data () { return { - scoreError: '' + scoreError: '', + isFinal: !this.$store.state.submissionNotes.isFeedbackCreation || this.$store.getters.isReviewer } }, props: { @@ -66,6 +74,9 @@ set: function (score) { this.$store.commit(subNotesNamespace(subNotesMut.UPDATE_FEEDBACK_SCORE), Number(score)) } + }, + showFinalCheckbox () { + return !this.$store.state.submissionNotes.isFeedbackCreation || this.$store.getters.isReviewer } }, methods: { @@ -86,7 +97,7 @@ return false }, submit () { - this.$emit('submitFeedback') + this.$emit('submitFeedback', {isFinal: this.isFinal}) } } } @@ -109,4 +120,7 @@ .score-button { min-width: 0px; } + .final-checkbox { + float: left; + } </style> diff --git a/frontend/src/components/tutor_list/TutorList.vue b/frontend/src/components/tutor_list/TutorList.vue new file mode 100644 index 0000000000000000000000000000000000000000..bbc69b7f8854bef01cbd1c1546ecaafdf6e39606 --- /dev/null +++ b/frontend/src/components/tutor_list/TutorList.vue @@ -0,0 +1,54 @@ +<template> + <v-flex md4> + <v-data-table + :headers="headers" + :items="tutors" + :search="search" + item-key="name" + hide-actions + > + <template slot="items" slot-scope="props"> + <td>{{props.item.username}}</td> + <td class="text-xs-right">{{props.item.feedback_created}}</td> + <td class="text-xs-right">{{props.item.feedback_validated}}</td> + </template> + </v-data-table> + </v-flex> +</template> + +<script> + import {mapState} from 'vuex' + + export default { + name: 'tutor-list', + data () { + return { + search: '', + headers: [ + { + text: 'Name', + align: 'left', + value: 'username' + }, + { + text: '# created', + value: 'feedback_created' + }, + { + text: '# validated', + value: 'feedback_validated' + } + ] + } + }, + computed: { + ...mapState([ + 'tutors' + ]) + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/LayoutSelector.vue b/frontend/src/pages/LayoutSelector.vue index 231a7d2974bc7d073af723b84697e049acec5a1b..5adadda974c3dfea271671f4a808cda5c1e19b75 100644 --- a/frontend/src/pages/LayoutSelector.vue +++ b/frontend/src/pages/LayoutSelector.vue @@ -11,9 +11,11 @@ import {mapGetters} from 'vuex' import TutorLayout from '@/pages/tutor/TutorLayout' import StudentLayout from '@/pages/student/StudentLayout' + import ReviewerLayout from '@/pages/reviewer/ReviewerLayout' export default { components: { + ReviewerLayout, StudentLayout, TutorLayout}, name: 'layout-selector', @@ -28,6 +30,8 @@ return 'student-layout' } else if (this.isTutor) { return 'tutor-layout' + } else if (this.isReviewer) { + return 'reviewer-layout' } } } diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index 1e6bdd9dfb452a2c561891bb741df9a321a843c3..3b1eb02e6c8b2baf0253a280a01896a57a426b33 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -13,7 +13,7 @@ >{{ msg }}</v-alert> <p v-else>But I corrected them, sir.</p> <v-form - @submit="submit"> + @submit.prevent="submit"> <v-text-field label="Username" v-model="credentials.username" diff --git a/frontend/src/pages/StartPageSelector.vue b/frontend/src/pages/StartPageSelector.vue index bfcb052b630e1c896d1ba3f12b2369a388f3974a..b41f34ae82eefceb148b82263e514a7e4c5a78af 100644 --- a/frontend/src/pages/StartPageSelector.vue +++ b/frontend/src/pages/StartPageSelector.vue @@ -7,9 +7,11 @@ import {mapGetters} from 'vuex' import TutorStartPage from '@/pages/tutor/TutorStartPage' import StudentPage from '@/pages/student/StudentPage' + import ReviewerStartPage from '@/pages/reviewer/ReviewerStartPage' export default { name: 'start-page-selector', components: { + ReviewerStartPage, StudentPage, TutorStartPage }, @@ -24,6 +26,8 @@ return 'student-page' } else if (this.isTutor) { return 'tutor-start-page' + } else if (this.isReviewer) { + return 'reviewer-start-page' } } } diff --git a/frontend/src/pages/base/TutorReviewerBaseLayout.vue b/frontend/src/pages/base/TutorReviewerBaseLayout.vue new file mode 100644 index 0000000000000000000000000000000000000000..ca9866ce4a9659e01ff4acf721f9259f443cc012 --- /dev/null +++ b/frontend/src/pages/base/TutorReviewerBaseLayout.vue @@ -0,0 +1,57 @@ +<template> + <base-layout> + + <template slot="header"> + Grady + </template> + + <template slot="sidebar-content"> + <v-list dense> + <v-list-tile exact v-for="(item, i) in generalNavItems" :key="i" :to="item.route"> + <v-list-tile-action> + <v-icon>{{ item.icon }}</v-icon> + </v-list-tile-action> + <v-list-tile-content> + <v-list-tile-title> + {{ item.name }} + </v-list-tile-title> + </v-list-tile-content> + </v-list-tile> + </v-list> + <v-divider></v-divider> + <slot name="above-subscriptions"></slot> + <subscription-list :sidebar="true"/> + <slot name="below-subscriptions"></slot> + </template> + </base-layout> +</template> + + +<script> + import BaseLayout from '@/components/BaseLayout' + import SubscriptionList from '@/components/subscriptions/SubscriptionList' + + export default { + components: { + SubscriptionList, + BaseLayout}, + name: 'tutor-reviewer-base-layout', + data () { + return { + generalNavItems: [ + { + name: 'Overview', + icon: 'home', + route: '/home' + }, + { + name: 'Progress', + icon: 'trending_up', + route: '/home' + } + ] + } + } + } +</script> + diff --git a/frontend/src/pages/reviewer/ReviewerLayout.vue b/frontend/src/pages/reviewer/ReviewerLayout.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a56fb70b29f0fc114983dbac99cec8bc3783305 --- /dev/null +++ b/frontend/src/pages/reviewer/ReviewerLayout.vue @@ -0,0 +1,45 @@ +<template> + <tutor-reviewer-base-layout> + <v-list dense slot="above-subscriptions"> + <v-list-tile v-for="(item, i) in subGeneralNavItems" :key="i" :to="item.route"> + <v-list-tile-action> + <v-icon>{{ item.icon }}</v-icon> + </v-list-tile-action> + <v-list-tile-content> + <v-list-tile-title> + {{ item.name }} + </v-list-tile-title> + </v-list-tile-content> + </v-list-tile> + </v-list> + </tutor-reviewer-base-layout> +</template> + +<script> + import TutorReviewerBaseLayout from '@/pages/base/TutorReviewerBaseLayout' + + export default { + components: {TutorReviewerBaseLayout}, + name: 'reviewer-layout', + data () { + return { + subGeneralNavItems: [ + { + name: 'Students', + route: '/student-overview', + icon: 'people' + }, + { + name: 'Tutors', + route: {name: 'tutor-overview'}, + icon: 'people' + } + ] + } + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/reviewer/ReviewerPage.vue b/frontend/src/pages/reviewer/ReviewerPage.vue deleted file mode 100644 index 1bcc4a334896da6ba29875885c1b38210c6c898b..0000000000000000000000000000000000000000 --- a/frontend/src/pages/reviewer/ReviewerPage.vue +++ /dev/null @@ -1,57 +0,0 @@ -<template> - <div> - <v-navigation-drawer persistent stateless value="true"> - <v-toolbar flat> - <v-list class="pa-1"> - <v-list-tile avatar> - <v-list-tile-avatar> - <img src="../../assets/brand.png" /> - </v-list-tile-avatar> - <v-list-tile-content> - <v-list-tile-title class="title" >Grady Menu</v-list-tile-title> - </v-list-tile-content> - </v-list-tile> - </v-list> - </v-toolbar> - <v-divider></v-divider> - <v-list> - <v-list-tile v-for="item in items" :key="item.title" :to="item.to" @click=""> - <v-list-tile-action> - <v-icon>{{ item.icon }}</v-icon> - </v-list-tile-action> - <v-list-tile-content> - <v-list-tile-title>{{ item.title }}</v-list-tile-title> - </v-list-tile-content> - </v-list-tile> - </v-list> - </v-navigation-drawer> - - <p> - Was Geht ab? - </p> - </div> -</template> - -<script> -import ReviewerToolbar from './ReviewerToolbar.vue' - -export default { - components: { - ReviewerToolbar - }, - name: 'reviewer-page', - data () { - return { - drawer: true, - items: [ - {title: 'Student List', to: '/reviewer/student-overview'}, - {title: 'Submission List', to: '/'} - ], - right: null - } - } -} -</script> - -<style lang="css" scoped> -</style> diff --git a/frontend/src/pages/reviewer/ReviewerStartPage.vue b/frontend/src/pages/reviewer/ReviewerStartPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..52f15ad28926621142cf802fed317b7eeb9d3630 --- /dev/null +++ b/frontend/src/pages/reviewer/ReviewerStartPage.vue @@ -0,0 +1,17 @@ +<template> + <v-container fill-height> + <v-layout> + <h1 align-center justify-center>You are reviewer!</h1> + </v-layout> + </v-container> +</template> + +<script> + export default { + name: 'reviewer-start-page' + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/reviewer/ReviewerStudentSubmissionPage.vue b/frontend/src/pages/reviewer/ReviewerStudentSubmissionPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..822be371e692e15a76c59f9f9062d9b05a535149 --- /dev/null +++ b/frontend/src/pages/reviewer/ReviewerStudentSubmissionPage.vue @@ -0,0 +1,59 @@ +<template> + <submission-correction + :submission-without-assignment="submission" + :feedback="submission.feedback" + ></submission-correction> +</template> + +<script> + import store from '@/store/store' + import VueInstance from '@/main' + import SubmissionCorrection from '@/components/submission_notes/SubmissionCorrection' + + function onRouteEnterOrUpdate (to, from, next) { + if (to.name === 'submission-side-view') { + let submission = store.state.submissions[to.params.pk] + if (!submission) { + store.dispatch('getSubmissionFeedbackTest', {pk: to.params.pk}).then(() => { + VueInstance.$nextTick(() => { + next() + }) + }).catch(() => { + VueInstance.$notify({ + title: 'Error', + text: 'Unable to fetch student data', + type: 'error' + }) + next(false) + }) + } else { + next() + } + } else { + next() + } + } + + export default { + components: {SubmissionCorrection}, + name: 'reviewer-student-submission-page', + computed: { + submissionPk () { + return this.$route.params['pk'] + }, + submission () { + return this.$store.state.submissions[this.submissionPk] + } + }, + beforeRouteEnter (to, from, next) { + onRouteEnterOrUpdate(to, from, next) + }, + beforeRouteUpdate (to, from, next) { + onRouteEnterOrUpdate(to, from, next) + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/reviewer/ReviewerToolbar.vue b/frontend/src/pages/reviewer/ReviewerToolbar.vue deleted file mode 100644 index 8c547490d824b9aa68d6c209f221d3a2ed543ac3..0000000000000000000000000000000000000000 --- a/frontend/src/pages/reviewer/ReviewerToolbar.vue +++ /dev/null @@ -1,20 +0,0 @@ -<template> - <v-toolbar> - <v-toolbar-items> - <v-list-tile-avatar> - <img src="../../assets/brand.png"> - </v-list-tile-avatar> - </v-toolbar-items> - <v-toolbar-title>Grady</v-toolbar-title> - <v-spacer></v-spacer> - </v-toolbar> -</template> - -<script> -export default { - name: 'reviewer-toolbar' -} -</script> - -<style scoped> -</style> diff --git a/frontend/src/pages/reviewer/StudentListOverview.vue b/frontend/src/pages/reviewer/StudentListOverview.vue deleted file mode 100644 index 08ae4caff8a21e2869f8c85cedc679a99ee451d4..0000000000000000000000000000000000000000 --- a/frontend/src/pages/reviewer/StudentListOverview.vue +++ /dev/null @@ -1,21 +0,0 @@ -<template> - <p> - Whack o ! - </p> -</template> - -<script> -export default { - - name: 'StudentListOverview', - - data () { - return { - - } - } -} -</script> - -<style lang="css" scoped> -</style> diff --git a/frontend/src/pages/reviewer/StudentOverviewPage.vue b/frontend/src/pages/reviewer/StudentOverviewPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..28c5a26416d3012599a71f93ddbb715d26e8ef77 --- /dev/null +++ b/frontend/src/pages/reviewer/StudentOverviewPage.vue @@ -0,0 +1,23 @@ +<template> + <v-layout> + <v-flex xs6> + <student-list class="ma-1"></student-list> + </v-flex> + <v-flex xs6> + <router-view></router-view> + </v-flex> + </v-layout> +</template> + +<script> + import StudentList from '@/components/student_list/StudentList' + + export default { + components: {StudentList}, + name: 'student-overview-page' + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/reviewer/TutorOverviewPage.vue b/frontend/src/pages/reviewer/TutorOverviewPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..88109335ca30197f5c64959f2eea75636c1324d9 --- /dev/null +++ b/frontend/src/pages/reviewer/TutorOverviewPage.vue @@ -0,0 +1,21 @@ +<template> + <tutor-list></tutor-list> +</template> + +<script> + import store from '@/store/store' + import TutorList from '@/components/tutor_list/TutorList' + + export default { + components: {TutorList}, + name: 'tutor-overview-page', + beforeRouteEnter (to, from, next) { + store.dispatch('getTutors') + next() + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/tutor/TutorLayout.vue b/frontend/src/pages/tutor/TutorLayout.vue index df21a894386de685714d3b940b91415b98ac3ee9..97886c9dc807868cecc8f1cc03b3f66ad565d391 100644 --- a/frontend/src/pages/tutor/TutorLayout.vue +++ b/frontend/src/pages/tutor/TutorLayout.vue @@ -1,55 +1,16 @@ <template> - <base-layout @sidebarMini="mini = $event"> - - <template slot="header"> - Grady - </template> - - <v-list dense slot="sidebar-content"> - <v-list-tile exact v-for="(item, i) in generalNavItems" :key="i" :to="item.route"> - <v-list-tile-action> - <v-icon>{{ item.icon }}</v-icon> - </v-list-tile-action> - <v-list-tile-content> - <v-list-tile-title> - {{ item.name }} - </v-list-tile-title> - </v-list-tile-content> - </v-list-tile> - - <v-divider></v-divider> - - <subscription-list :sidebar="true"/> - </v-list> - </base-layout> + <tutor-reviewer-base-layout></tutor-reviewer-base-layout> </template> <script> - import BaseLayout from '@/components/BaseLayout' - import SubscriptionList from '@/components/subscriptions/SubscriptionList' + import TutorReviewerBaseLayout from '@/pages/base/TutorReviewerBaseLayout' export default { components: { - SubscriptionList, - BaseLayout}, - name: 'tutor-layout', - data () { - return { - generalNavItems: [ - { - name: 'Overview', - icon: 'home', - route: '/home' - }, - { - name: 'Progress', - icon: 'trending_up', - route: '/home' - } - ] - } - } + TutorReviewerBaseLayout + }, + name: 'tutor-layout' } </script> diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index c49a411d62816e0e67ddb4da1926eaf2884b0e25..b186a6305c74020fe3ddd338c284892be90c2138 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -2,27 +2,57 @@ import Vue from 'vue' import Router from 'vue-router' import Login from '@/pages/Login' import StudentSubmissionPage from '@/pages/student/StudentSubmissionPage' +import StudentOverviewPage from '@/pages/reviewer/StudentOverviewPage' +import TutorOverviewPage from '@/pages/reviewer/TutorOverviewPage' import SubscriptionWorkPage from '@/pages/SubscriptionWorkPage' import PageNotFound from '@/pages/PageNotFound' import StartPageSelector from '@/pages/StartPageSelector' import LayoutSelector from '@/pages/LayoutSelector' +import ReviewerStudentSubmissionPage from '@/pages/reviewer/ReviewerStudentSubmissionPage' +import StudentListHelpCard from '@/components/student_list/StudentListHelpCard' import VueInstance from '@/main' - import store from '@/store/store' Vue.use(Router) +function denyAccess (next, redirect) { + next(redirect.path) + VueInstance.$notify({ + title: 'Access denied', + text: "You don't have permission to view this.", + type: 'error' + }) +} + function tutorOrReviewerOnly (to, from, next) { - next() if (store.getters.isTutorOrReviewer) { next() } else { - next(from.path) - VueInstance.$notify({ - title: 'Access denied', - text: "You don't have permission to view this.", - type: 'error' - }) + denyAccess(next, from.path) + } +} + +function reviewerOnly (to, from, next) { + if (store.getters.isReviewer) { + next() + } else { + denyAccess(next, from.path) + } +} + +function studentOnly (to, from, next) { + if (store.getters.isStudent) { + next() + } else { + next(false) + } +} + +function checkLoggedIn (to, from, next) { + if (store.getters.isLoggedIn) { + next() + } else { + next('/login/') } } @@ -36,6 +66,7 @@ const router = new Router({ { path: '', redirect: 'home', + beforeEnter: checkLoggedIn, component: LayoutSelector, children: [ { @@ -49,8 +80,32 @@ const router = new Router({ beforeEnter: tutorOrReviewerOnly, component: SubscriptionWorkPage }, + { + path: 'student-overview', + name: 'student-overview', + beforeEnter: reviewerOnly, + component: StudentOverviewPage, + children: [ + { + path: '', + component: StudentListHelpCard + }, + { + path: 'submission/:pk', + name: 'submission-side-view', + component: ReviewerStudentSubmissionPage + } + ] + }, + { + path: 'tutor-overview', + name: 'tutor-overview', + beforeEnter: reviewerOnly, + component: TutorOverviewPage + }, { path: 'submission/:id', + beforeEnter: studentOnly, component: StudentSubmissionPage } ] diff --git a/frontend/src/store/actions.js b/frontend/src/store/actions.js index 52ac528f9618229e5f9ab2e72be5ed6e2fa5b289..116df39eaae892c67e34eb19e3ef9dcc9d8a27f2 100644 --- a/frontend/src/store/actions.js +++ b/frontend/src/store/actions.js @@ -4,9 +4,9 @@ import {subNotesMut} from '@/store/modules/submission-notes' import * as api from '@/api' import router from '@/router/index' -function passErrorIfResponsePresent (err, fallbackMsg) { +function passErrorIfNoResponse (err, fallbackMsg) { if (err.response) { - throw err + throw new Error(err.response.data) } else { if (fallbackMsg) { throw new Error(fallbackMsg) @@ -20,7 +20,7 @@ const actions = { const subscriptions = await api.fetchSubscriptions() commit(mut.SET_SUBSCRIPTIONS, subscriptions) } catch (err) { - passErrorIfResponsePresent(err, 'Unable to fetch subscriptions') + passErrorIfNoResponse(err, 'Unable to fetch subscriptions') } }, async getExamTypes ({commit}) { @@ -28,7 +28,7 @@ const actions = { const examTypes = await api.fetchExamType({}) commit(mut.SET_EXAM_TYPES, examTypes) } catch (err) { - passErrorIfResponsePresent(err, 'Unable to fetch exam mut') + passErrorIfNoResponse(err, 'Unable to fetch exam mut') } }, async subscribeTo ({ commit }, {type, key, stage}) { @@ -36,7 +36,7 @@ const actions = { const subscription = await api.subscribeTo(type, key, stage) commit(mut.SET_SUBSCRIPTION, subscription) } catch (err) { - passErrorIfResponsePresent(err, 'Subscribing unsuccessful') + passErrorIfNoResponse(err, 'Subscribing unsuccessful') } }, async updateSubmissionTypes ({ commit }, fields) { @@ -46,7 +46,7 @@ const actions = { commit(mut.UPDATE_SUBMISSION_TYPE, type) }) } catch (err) { - passErrorIfResponsePresent(err) + passErrorIfNoResponse(err) } }, async getCurrentAssignment ({ commit }, subscriptionPk) { @@ -59,7 +59,7 @@ const actions = { }) return assignment } catch (err) { - passErrorIfResponsePresent(err, "Couldn't fetch assignment") + passErrorIfNoResponse(err, "Couldn't fetch assignment") } }, async getNextAssignment ({ commit }, subscriptionPk) { @@ -72,7 +72,32 @@ const actions = { }) return assignment } catch (err) { - passErrorIfResponsePresent(err, "Couldn't fetch assignment") + passErrorIfNoResponse(err, "Couldn't fetch assignment") + } + }, + async getStudents ({commit}) { + try { + const students = await api.fetchAllStudents() + commit(mut.SET_STUDENTS, students) + return students + } catch (err) { + passErrorIfNoResponse(err, 'Unable to fetch student data') + } + }, + async getTutors ({commit}) { + try { + const tutors = await api.fetchAllTutors() + commit(mut.SET_TUTORS, tutors) + } catch (err) { + passErrorIfNoResponse(err, 'Unable to fetch tutor data.') + } + }, + async getSubmissionFeedbackTest ({commit}, {pk}) { + try { + const submission = await api.fetchSubmissionFeedbackTests({pk}) + commit(mut.SET_SUBMISSION, submission) + } catch (err) { + passErrorIfNoResponse(err, 'Unable to fetch submission') } }, logout ({ commit }, message = '') { diff --git a/frontend/src/store/modules/submission-notes.js b/frontend/src/store/modules/submission-notes.js index a63fd12016fcd40fd3ea66a239cb776cedcdef2e..dde42b674a2bd2825696c29d6df5b78b9bad1f38 100644 --- a/frontend/src/store/modules/submission-notes.js +++ b/frontend/src/store/modules/submission-notes.js @@ -17,18 +17,19 @@ export const subNotesMut = Object.freeze({ function initialState () { return { assignment: '', + isFeedbackCreation: false, + rawSubmission: '', ui: { showEditorOnLine: {}, selectedCommentOnLine: {} }, - orig: { - rawSubmission: '', + origFeedback: { score: null, - feedbackLines: {} + feedback_lines: {} }, - updated: { + updatedFeedback: { score: null, - feedbackLines: {} + feedback_lines: {} } } } @@ -41,38 +42,39 @@ const submissionNotes = { // line indexes starting at one and the values the corresponding submission line // this makes iterating over the submission much more pleasant submission: state => { - return state.orig.rawSubmission.split('\n').reduce((acc, cur, index) => { + return state.rawSubmission.split('\n').reduce((acc, cur, index) => { acc[index + 1] = cur return acc }, {}) }, score: state => { - return state.updated.score !== null ? state.updated.score : state.orig.score + return state.updatedFeedback.score !== null ? state.updatedFeedback.score : state.origFeedback.score }, openEditorOrWrittenFeedback: state => { const openEdit = Object.values(state.ui.showEditorOnLine).some(bool => bool) - const hasWrittenFeedback = Object.keys(state.updated.feedbackLines).length > 0 + const hasWrittenFeedback = Object.keys(state.updatedFeedback.feedback_lines).length > 0 return openEdit || hasWrittenFeedback } }, mutations: { [subNotesMut.SET_RAW_SUBMISSION]: function (state, submission) { - state.orig.rawSubmission = submission + state.rawSubmission = submission }, [subNotesMut.SET_ORIG_FEEDBACK]: function (state, feedback) { if (feedback) { - state.orig.feedbackLines = feedback['feedback_lines'] ? feedback['feedback_lines'] : {} - state.orig.score = feedback.score + state.origFeedback = feedback + } else { + state.isFeedbackCreation = true } }, [subNotesMut.UPDATE_FEEDBACK_LINE]: function (state, feedback) { - Vue.set(state.updated.feedbackLines, feedback.lineNo, feedback.comment) + Vue.set(state.updatedFeedback.feedback_lines, feedback.lineNo, feedback.comment) }, [subNotesMut.UPDATE_FEEDBACK_SCORE]: function (state, score) { - state.updated.score = score + state.updatedFeedback.score = score }, [subNotesMut.DELETE_FEEDBACK_LINE]: function (state, lineNo) { - Vue.delete(state.updated.feedbackLines, lineNo) + Vue.delete(state.updatedFeedback.feedback_lines, lineNo) }, [subNotesMut.TOGGLE_EDITOR_ON_LINE]: function (state, {lineNo, comment}) { Vue.set(state.ui.selectedCommentOnLine, lineNo, comment) @@ -83,17 +85,23 @@ const submissionNotes = { } }, actions: { - submitFeedback: async function ({state}, assignment) { + submitFeedback: async function ({state}, {assignment, isFinal = false}) { let feedback = {} - if (Object.keys(state.updated.feedbackLines).length > 0) { - feedback['feedback_lines'] = state.updated.feedbackLines + feedback.is_final = isFinal + if (Object.keys(state.updatedFeedback.feedback_lines).length > 0) { + feedback['feedback_lines'] = state.updatedFeedback.feedback_lines } - if (state.orig.score === null && state.updated.score === null) { + if (state.origFeedback.score === null && state.updatedFeedback.score === null) { throw new Error('You need to give a score.') - } else if (state.updated.score !== null) { - feedback['score'] = state.updated.score + } else if (state.updatedFeedback.score !== null) { + feedback['score'] = state.updatedFeedback.score + } + if (state.isFeedbackCreation) { + return api.submitFeedbackForAssignment(feedback, assignment['pk']) + } else { + feedback.pk = state.origFeedback.pk + return api.submitUpdatedFeedback(feedback) } - return api.submitFeedbackForAssignment(feedback, assignment['pk']) } } } diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js index 00a02fcd782b03cf29f2db216498b2d96cf19ba9..6be5e3a6ba3a4dbe84bd71c2ad432630128ea002 100644 --- a/frontend/src/store/mutations.js +++ b/frontend/src/store/mutations.js @@ -6,15 +6,21 @@ export const mut = Object.freeze({ SET_ASSIGNMENT: 'SET_ASSIGNMENT', SET_SUBSCRIPTIONS: 'SET_SUBSCRIPTIONS', SET_SUBSCRIPTION: 'SET_SUBSCRIPTION', - UPDATE_SUBMISSION_TYPE: 'UPDATE_SUBMISSION_TYPE', - UPDATE_ASSIGNMENT: 'UPDATE_ASSIGNMENT', - RESET_STATE: 'RESET_STATE', SET_LAST_INTERACTION: 'SET_LAST_INTERACTION', SET_EXAM_TYPES: 'SET_EXAM_TYPES', - SET_NOTIFY_MESSAGE: 'SET_NOTIFY_MESSAGE' + SET_NOTIFY_MESSAGE: 'SET_NOTIFY_MESSAGE', + SET_STUDENTS: 'SET_STUDENTS', + SET_TUTORS: 'SET_TUTORS', + SET_SUBMISSION: 'SET_SUBMISSION', + UPDATE_SUBMISSION_TYPE: 'UPDATE_SUBMISSION_TYPE', + UPDATE_ASSIGNMENT: 'UPDATE_ASSIGNMENT', + RESET_STATE: 'RESET_STATE' }) const mutations = { + [mut.SET_EXAM_TYPES] (state, examTypes) { + state.examTypes = examTypes + }, [mut.SET_ASSIGNMENT] (state, assignment) { Vue.set(state.assignments, assignment.pk, assignment) }, @@ -27,6 +33,15 @@ const mutations = { [mut.SET_SUBSCRIPTION] (state, subscription) { Vue.set(state.subscriptions, subscription.pk, subscription) }, + [mut.SET_STUDENTS] (state, students) { + state.students = students + }, + [mut.SET_TUTORS] (state, tutors) { + state.tutors = tutors + }, + [mut.SET_SUBMISSION] (state, submission) { + Vue.set(state.submissions, submission.pk, submission) + }, [mut.UPDATE_SUBMISSION_TYPE] (state, submissionType) { const updatedSubmissionType = { ...state.submissionTypes[submissionType.pk], @@ -48,14 +63,11 @@ const mutations = { Vue.set(state.submissions, submission.pk, submission) Vue.set(state.subscriptions[subscriptionPk], key, updatedAssignment) }, - [mut.RESET_STATE] (state) { - Object.assign(state, initialState()) - }, [mut.SET_LAST_INTERACTION] (state) { state.lastAppInteraction = Date.now() }, - [mut.SET_EXAM_TYPES] (state, examTypes) { - state.examTypes = examTypes + [mut.RESET_STATE] (state) { + Object.assign(state, initialState()) } } diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 09af47903cae284c2322f2a09481df7cefe7f1cf..8165d3c897868cea95af46bb231219aad96590a2 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -22,7 +22,9 @@ export function initialState () { submissions: {}, feedback: {}, subscriptions: {}, - assignments: {} + assignments: {}, + students: [], + tutors: [] } } diff --git a/grady/urls.py b/grady/urls.py index 6d84db555ba003d80d095563ac6ab0d4fbd97129..8e651c92f337a55689fc2af3e6a79a94df55251f 100644 --- a/grady/urls.py +++ b/grady/urls.py @@ -6,9 +6,10 @@ from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('core.urls')), + path('api/get-token/', obtain_jwt_token), + path('api/refresh-token/', refresh_jwt_token), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - path('api-token-auth/', obtain_jwt_token), - path('api-token-refresh/', refresh_jwt_token), - path('', TemplateView.as_view(template_name='index.html')), + path('', TemplateView.as_view(template_name='index.html')) + ]