diff --git a/core/serializers/common_serializers.py b/core/serializers/common_serializers.py index 52e22e7fc59d23e8cbba0680d455f3aa70cebcb1..5729947a4da711ed6964f78268144b5fab4a2170 100644 --- a/core/serializers/common_serializers.py +++ b/core/serializers/common_serializers.py @@ -53,12 +53,12 @@ class TutorSerializer(DynamicFieldsModelSerializer): def get_feedback_created(self, obj): return self._get_completed_assignments(obj).filter( - subscription__feedback_stage=models.GeneralTaskSubscription.FEEDBACK_CREATION # noqa + subscription__feedback_stage=models.SubmissionSubscription.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 + subscription__feedback_stage=models.SubmissionSubscription.FEEDBACK_VALIDATION # noqa ).count() def create(self, validated_data) -> models.UserAccount: diff --git a/core/serializers/subscription.py b/core/serializers/subscription.py index ea87cb07371b96835cf1a91dbb904bfea1bc4e96..90ae0e2ef6d4d95b4f04a13a3e0f8383c54f7c40 100644 --- a/core/serializers/subscription.py +++ b/core/serializers/subscription.py @@ -31,9 +31,6 @@ class AssignmentDetailSerializer(AssignmentSerializer): model = TutorSubmissionAssignment fields = ('pk', 'submission', 'feedback', 'is_done', 'subscription') read_only_fields = ('is_done', 'submission', 'feedback') - extra_kwargs = { - 'subscription': {'write_only': True}, - } def create(self, validated_data): subscription = validated_data.get('subscription') @@ -43,10 +40,18 @@ class AssignmentDetailSerializer(AssignmentSerializer): class SubscriptionSerializer(DynamicFieldsModelSerializer): owner = serializers.ReadOnlyField(source='owner.username') query_key = serializers.UUIDField(required=False) - assignments = AssignmentSerializer(read_only=True, many=True) + assignments = serializers.SerializerMethodField() remaining = serializers.SerializerMethodField() available = serializers.SerializerMethodField() + def get_assignments(self, subscription): + queryset = TutorSubmissionAssignment.objects.filter( + subscription=subscription, + is_done=False + ) + serializer = AssignmentDetailSerializer(queryset, many=True) + return serializer.data + def get_remaining(self, subscription): return subscription.get_remaining_not_final() diff --git a/core/tests/test_tutor_api_endpoints.py b/core/tests/test_tutor_api_endpoints.py index adbed0b9001e95f016cbcf3706d9c53e019955f6..0e9a5773a8785dbcf67de3fad2dcb4625ef6ec18 100644 --- a/core/tests/test_tutor_api_endpoints.py +++ b/core/tests/test_tutor_api_endpoints.py @@ -10,7 +10,7 @@ from rest_framework.reverse import reverse from rest_framework.test import (APIClient, APIRequestFactory, APITestCase, force_authenticate) -from core.models import TutorSubmissionAssignment +from core.models import TutorSubmissionAssignment, SubmissionSubscription from core.views import TutorApiViewSet from util.factories import GradyUserFactory @@ -66,17 +66,33 @@ class TutorListTests(APITestCase): def test_get_a_list_of_all_tutors(self): self.assertEqual(len(self.response.data), NUMBER_OF_TUTORS) - def test_feedback_count_matches_database(self): + def test_feedback_created_count_matches_database(self): def verify_fields(tutor_obj): t = get_user_model().objects.get(username=tutor_obj['username']) - return t.done_assignments_count() == \ - tutor_obj['done_assignments_count'] + feedback_created_count = TutorSubmissionAssignment.objects.filter( + is_done=True, + subscription__feedback_stage=SubmissionSubscription.FEEDBACK_CREATION, # noqa + subscription__owner=t + ).count() + return feedback_created_count == tutor_obj['feedback_created'] + + self.assertTrue(all(map(verify_fields, self.response.data))) + + def test_feedback_validated_count_matches_database(self): + def verify_fields(tutor_obj): + t = get_user_model().objects.get(username=tutor_obj['username']) + feedback_validated_cnt = TutorSubmissionAssignment.objects.filter( + is_done=True, + subscription__feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION, # noqa + subscription__owner=t + ).count() + return feedback_validated_cnt == tutor_obj['feedback_created'] self.assertTrue(all(map(verify_fields, self.response.data))) def test_sum_of_done_assignments(self): self.assertEqual( - sum(obj['done_assignments_count'] + sum(obj['feedback_created'] + obj['feedback_validated'] for obj in self.response.data), TutorSubmissionAssignment.objects.filter(is_done=True).count() ) diff --git a/core/views/subscription.py b/core/views/subscription.py index d806747f9c30ebdde03e2ee5170d392dad06f0d5..2e00c5cac40596eeb3359b42dc159e70639e90bf 100644 --- a/core/views/subscription.py +++ b/core/views/subscription.py @@ -23,7 +23,9 @@ class SubscriptionApiViewSet( def get_queryset(self): return models.SubmissionSubscription.objects.filter( - owner=self.request.user) + owner=self.request.user, + deactivated=False + ) def _get_subscription_if_type_exists(self, data): try: @@ -92,7 +94,7 @@ class AssignmentApiViewSet( """ Stop working on the assignment before it is finished """ instance = self.get_object() - if instance.is_done: + if instance.is_done or instance.subscription.owner != request.user: return Response(status=status.HTTP_403_FORBIDDEN) instance.delete() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 681f5c0a671aeb44b8365fb992d746e5050be24d..01a48583c61d1a3668596ca78a162e388c639271 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -48,7 +48,7 @@ 'lastAppInteraction' ]), ...mapState({ - tokenCreationTime: state => state.authentication.tokenCreationTime, + lastTokenRefreshTry: state => state.authentication.lastTokenRefreshTry, refreshingToken: state => state.authentication.refreshingToken, jwtTimeDelta: state => state.authentication.jwtTimeDelta }) @@ -65,10 +65,10 @@ }, watch: { lastAppInteraction: function (val) { - const timeSinceLastRefresh = Date.now() - this.tokenCreationTime + const timeSinceLastRefresh = Date.now() - this.lastTokenRefreshTry const timeDelta = this.jwtTimeDelta // refresh jwt if it's older than 20% of his maximum age - if (timeDelta > 0 && timeSinceLastRefresh > timeDelta * 0.2 && + if (this.$route.name !== 'login' && timeSinceLastRefresh > timeDelta * 0.2 && !this.refreshingToken) { this.$store.dispatch('refreshJWT') } diff --git a/frontend/src/api.js b/frontend/src/api.js index bbb375b5b58ea53474bfd129b99bb4f49153d8df..292afb200428eb9eb41dace3a180a6b1a16d40dc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -71,8 +71,13 @@ export async function fetchSubscriptions () { return (await ax.get('/api/subscription/')).data } +export async function deactivateSubscription ({pk}) { + const url = `/api/subscription/${pk}/` + return (await ax.delete(url)).data +} + export async function fetchSubscription (subscriptionPk) { - return (await ax.get(`/api/subscription/${subscriptionPk}`)).data + return (await ax.get(`/api/subscription/${subscriptionPk}/`)).data } export async function fetchExamType ({examPk, fields = []}) { @@ -97,25 +102,19 @@ export async function subscribeTo (type, key, stage) { return (await ax.post('/api/subscription/', data)).data } -export async function fetchCurrentAssignment (subscriptionPk) { - return (await ax.get(`/api/subscription/${subscriptionPk}/assignments/current/`)).data -} - -export async function fetchNextAssignment (subscriptionPk) { - return (await ax.get(`/api/subscription/${subscriptionPk}/assignments/next/`)).data -} - -export async function submitFeedbackForAssignment (feedback, assignmentPk) { +export async function createAssignment ({subscription}) { const data = { - ...feedback, - assignment_pk: assignmentPk + subscription: subscription.pk } + return (await ax.post(`/api/assignment/`, data)).data +} - return (await ax.post('/api/feedback/', data)).data +export async function submitFeedbackForAssignment ({feedback}) { + return (await ax.post('/api/feedback/', feedback)).data } -export async function submitUpdatedFeedback (feedback) { - return (await ax.patch(`/api/feedback/${feedback.pk}/`, feedback)).data +export async function submitUpdatedFeedback ({feedback}) { + return (await ax.patch(`/api/feedback/${feedback.of_submission}/`, feedback)).data } export async function fetchSubmissionTypes (fields = []) { @@ -126,4 +125,9 @@ export async function fetchSubmissionTypes (fields = []) { return (await ax.get(url)).data } +export async function deleteAssignment ({assignment}) { + const url = `/api/assignment/${assignment.pk}` + return (await ax.delete(url)).data +} + export default ax diff --git a/frontend/src/components/student_list/StudentListHelpCard.vue b/frontend/src/components/student_list/StudentListHelpCard.vue index d5099096cdf3cd10fd2c509eb12db484b89731de..517bfc0acb410d44cbe773c09f4e9800e81a25eb 100644 --- a/frontend/src/components/student_list/StudentListHelpCard.vue +++ b/frontend/src/components/student_list/StudentListHelpCard.vue @@ -1,23 +1,21 @@ <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> + <v-layout justify-center> + <v-card class="mt-5"> + <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> </template> <script> diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue index e1232a605bd9bdd8852034247dbe3fa73dc5a119..54a18b04ca76549a284052a2d6ab10d756579914 100644 --- a/frontend/src/components/submission_notes/SubmissionCorrection.vue +++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue @@ -45,7 +45,9 @@ slot="footer" :loading="loading" :fullScore="submissionObj['full_score']" + :skippable="assignment !== undefined" @submitFeedback="submitFeedback" + @skip="$emit('skip')" /> </base-annotated-submission> </v-container> @@ -80,7 +82,6 @@ assignment: { type: Object }, - // either pass in an assignment or a submission and feedback submissionWithoutAssignment: { type: Object @@ -124,7 +125,6 @@ submitFeedback ({isFinal}) { this.loading = true this.$store.dispatch(subNotesNamespace('submitFeedback'), { - assignment: this.assignment, isFinal: isFinal }).then(() => { this.$store.commit(subNotesNamespace(subNotesMut.RESET_STATE)) @@ -141,7 +141,7 @@ }, init () { this.$store.commit(subNotesNamespace(subNotesMut.RESET_STATE)) - this.$store.commit(subNotesNamespace(subNotesMut.SET_RAW_SUBMISSION), this.submissionObj.text) + this.$store.commit(subNotesNamespace(subNotesMut.SET_SUBMISSION), this.submissionObj) this.$store.commit(subNotesNamespace(subNotesMut.SET_ORIG_FEEDBACK), this.feedbackObj) } }, diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue index 180d6f9231b35c981ac3c15abfcb8c8fa26cf662..a723a5bc405a498c78d03836a3559e3529196318 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue @@ -1,5 +1,13 @@ <template> <v-toolbar dense class="bottom-toolbar"> + <v-tooltip top v-if="skippable"> + <v-btn + slot="activator" + outline round color="grey darken-2" + @click="$emit('skip')" + >Skip</v-btn> + <span>Skip this submission</span> + </v-tooltip> <v-spacer/> <v-alert class="score-alert ma-3" @@ -64,6 +72,10 @@ loading: { type: Boolean, required: true + }, + skippable: { + type: Boolean, + default: false } }, computed: { diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue index fc0ed47213b594e229235a9dbf16cf88c260ce71..84b36e423a07ba2eab620c736b54fc1d752fcd92 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue @@ -38,7 +38,7 @@ }, computed: { ...mapState({ - submission: state => state.submissionNotes.orig.rawSubmission + submission: state => state.submissionNotes.submission.text }) }, methods: { diff --git a/frontend/src/components/subscriptions/Subscription.vue b/frontend/src/components/subscriptions/Subscription.vue deleted file mode 100644 index dd396ca1750e546281163c23f21ab41e4a427c7a..0000000000000000000000000000000000000000 --- a/frontend/src/components/subscriptions/Subscription.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> - <v-list-tile - exact - :to="{name: 'subscription', params: {pk: subscriptionPk}}" - > - <v-list-tile-content class="ml-3"> - {{query_key ? query_key : 'Active'}} - </v-list-tile-content> - <v-list-tile-action-text> - {{stageMap[feedback_stage]}} - </v-list-tile-action-text> - </v-list-tile> -</template> - -<script> - export default { - name: 'subscription', - props: { - subscriptionPk: { - types: String, - required: true - }, - feedback_stage: { - type: String, - required: true - }, - query_key: { - type: String - } - }, - data () { - return { - stageMap: { - 'feedback-creation': 'create', - 'feedback-validation': 'validate', - 'feedback-conflict-resolution': 'conflict' - } - } - } - } -</script> - -<style scoped> - -</style> diff --git a/frontend/src/components/subscriptions/SubscriptionCreation.vue b/frontend/src/components/subscriptions/SubscriptionCreation.vue index a9919b4fa2a405535adc9d288d318c4c88f82d90..7463a3d8203a70c94ece3f1ef3c49eb944d6044b 100644 --- a/frontend/src/components/subscriptions/SubscriptionCreation.vue +++ b/frontend/src/components/subscriptions/SubscriptionCreation.vue @@ -33,8 +33,14 @@ name: 'subscription-creation', data () { return { - key: '', - stage: '', + key: { + text: '', + key: '' + }, + stage: { + text: '', + stage: '' + }, loading: false } }, @@ -56,17 +62,17 @@ let stages = [ { text: 'Initial Feedback', - type: 'feedback-creation' + stage: 'feedback-creation' }, { text: 'Feedback validation', - type: 'feedback-validation' + stage: 'feedback-validation' } ] if (this.$store.getters.isReviewer) { stages.push({ text: 'Conflict resolution', - type: 'feedback-conflict-resolution' + stage: 'feedback-conflict-resolution' }) } return stages @@ -77,8 +83,8 @@ this.loading = true this.$store.dispatch('subscribeTo', { type: this.type, - key: this.key.text, - stage: this.stage.type + key: this.key.key, + stage: this.stage.stage }).catch(err => { if (err.response && err.response.data['non_field_errors']) { this.$notify({ diff --git a/frontend/src/components/subscriptions/SubscriptionForList.vue b/frontend/src/components/subscriptions/SubscriptionForList.vue new file mode 100644 index 0000000000000000000000000000000000000000..c02e9275426fcd872893d00276c30a8f47506376 --- /dev/null +++ b/frontend/src/components/subscriptions/SubscriptionForList.vue @@ -0,0 +1,83 @@ +<template> + <v-layout row> + <v-list-tile + exact + :to="{name: 'subscription', params: {pk: subscriptionPk}}" + style="width: 100%" + > + <v-list-tile-content class="ml-3"> + {{name}} + </v-list-tile-content> + <v-list-tile-action-text> + {{stageMap[feedback_stage]}} + </v-list-tile-action-text> + </v-list-tile> + <v-btn + icon + @click="deactivate" + > + <v-icon + color="grey" + style="font-size: 20px" + > + delete + </v-icon> + </v-btn> + </v-layout> +</template> + +<script> + export default { + name: 'subscription-for-list', + props: { + subscriptionPk: { + types: String, + required: true + }, + feedback_stage: { + type: String, + required: true + }, + query_type: { + type: String, + required: true + }, + query_key: { + type: String + } + }, + data () { + return { + stageMap: { + 'feedback-creation': 'create', + 'feedback-validation': 'validate', + 'feedback-conflict-resolution': 'conflict' + } + } + }, + computed: { + name () { + return this.$store.getters.resolveSubscriptionKeyToName( + {query_key: this.query_key, query_type: this.query_type}) + } + }, + methods: { + deactivate () { + this.$store.dispatch('deactivateSubscription', {pk: this.subscriptionPk}).then(() => { + if (this.$route.params.pk === this.subscriptionPk) { + this.$router.push('/') + } + }).catch(err => { + this.$notify({ + title: `Unable to deactivate subscription ${this.name}`, + text: err.msg, + type: 'error' + }) + }) + } + } + } +</script> + +<style scoped> +</style> diff --git a/frontend/src/components/subscriptions/SubscriptionList.vue b/frontend/src/components/subscriptions/SubscriptionList.vue index f3e44747bd523c4b10d54a06cab3ed303b4b75f5..1c9864a8630481af3d835b29898a2d0f1dcee341 100644 --- a/frontend/src/components/subscriptions/SubscriptionList.vue +++ b/frontend/src/components/subscriptions/SubscriptionList.vue @@ -10,19 +10,23 @@ <div v-for="item in subscriptionTypes" :key="item.type"> <subscription-type v-bind="item" - :empty-subscription-type="subscriptions[item.type].length === 0" + :is-empty-subscription-type="subscriptions[item.type].length === 0" :possible-subscription-keys="possibleKeys[item.type]" @toggleExpand="item.expanded = !item.expanded" > - <subscription - v-for="subscription in subscriptions[item.type]" - :key="subscription.pk" - :subscription-pk="subscription.pk" - :feedback_stage="subscription.feedback_stage" - :query_key="subscription.query_key" - > + <div v-if="examTypesLoaded && submissionTypesLoaded"> + <subscription-for-list + v-for="subscription in subscriptions[item.type]" + v-if="subscription.available > 0 && !subscription.deactivated" + :key="subscription.pk" + :subscription-pk="subscription.pk" + :feedback_stage="subscription.feedback_stage" + :query_key="subscription.query_key" + :query_type="subscription.query_type" + > - </subscription> + </subscription-for-list> + </div> </subscription-type> </div> </v-list> @@ -33,11 +37,11 @@ import {mapGetters, mapActions, mapState} from 'vuex' import SubscriptionCreation from '@/components/subscriptions/SubscriptionCreation' import SubscriptionType from '@/components/subscriptions/SubscriptionType' - import Subscription from '@/components/subscriptions/Subscription' + import SubscriptionForList from '@/components/subscriptions/SubscriptionForList' export default { components: { - Subscription, + SubscriptionForList, SubscriptionType, SubscriptionCreation}, name: 'subscription-list', @@ -50,6 +54,8 @@ data () { return { subscriptionCreateMenu: {}, + submissionTypesLoaded: false, + examTypesLoaded: false, subscriptionTypes: [ { name: 'Random', @@ -74,18 +80,6 @@ type: 'submission_type', description: 'Just submissions for the specified type.', expanded: true - }, - { - name: 'Student', - type: 'student', - description: 'The submissions of a student.', - expanded: true, - createPermission: () => { - return this.$store.getters.isReviewer - }, - viewPermission: () => { - return this.$store.getters.isReviewer - } } ] } @@ -99,10 +93,10 @@ }), possibleKeys () { const submissionTypes = Object.entries(this.$store.state.submissionTypes).map(([id, type]) => { - return {text: type.name} + return {text: type.name, key: type.pk} }) const examTypes = Object.entries(this.$store.state.examTypes).map(([id, type]) => { - return {text: type['module_reference']} + return {text: type['module_reference'], key: type.pk} }) return { submission_type: submissionTypes, @@ -123,11 +117,15 @@ this.getSubscriptions() } if (Object.keys(this.$store.state.submissionTypes).length === 0) { - this.updateSubmissionTypes(['name']) + this.updateSubmissionTypes().then(() => { this.submissionTypesLoaded = true }) + } else { + this.submissionTypesLoaded = true } if (Object.keys(this.$store.state.examTypes).length === 0 && this.$store.getters.isReviewer) { - this.getExamTypes() + this.getExamTypes().then(() => { this.examTypesLoaded = true }) + } else { + this.examTypesLoaded = true } } } diff --git a/frontend/src/components/subscriptions/SubscriptionType.vue b/frontend/src/components/subscriptions/SubscriptionType.vue index 0b59b87bdbb40294b36fc4c93f05c09d324efbec..cb735799047521f2a59b9f43fbe97118a029fad8 100644 --- a/frontend/src/components/subscriptions/SubscriptionType.vue +++ b/frontend/src/components/subscriptions/SubscriptionType.vue @@ -9,7 +9,7 @@ {{ description }} </v-list-tile-sub-title> </v-list-tile-content> - <v-list-tile-action v-if="!emptySubscriptionType"> + <v-list-tile-action v-if="!isEmptySubscriptionType"> <v-btn icon @click="$emit('toggleExpand')"> <v-icon v-if="expanded">keyboard_arrow_up</v-icon> <v-icon v-else>keyboard_arrow_down</v-icon> @@ -62,7 +62,7 @@ type: Boolean, default: true }, - emptySubscriptionType: { + isEmptySubscriptionType: { type: Boolean, required: true }, diff --git a/frontend/src/pages/LayoutSelector.vue b/frontend/src/pages/LayoutSelector.vue index 5adadda974c3dfea271671f4a808cda5c1e19b75..df530c96e2fe025bff44ab28c6a2186dc67fa22c 100644 --- a/frontend/src/pages/LayoutSelector.vue +++ b/frontend/src/pages/LayoutSelector.vue @@ -1,5 +1,5 @@ <template> - <div> + <div style="height: 100%;"> <component :is="layout"></component> <v-content> <router-view></router-view> diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index 3b1eb02e6c8b2baf0253a280a01896a57a426b33..0035b112db987b31df6cab7e66f6c2136f7a831e 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -1,36 +1,36 @@ <template> - <v-container fill-height> - <v-layout align-center justify-center> - <v-flex text-xs-center xs8 sm6 md4 lg2> - <img src="../assets/brand.png"/> - <h3 class="pt-3">Log in</h3> - <v-alert - outline - v-if="msg" - color="error" - :value="true" - transition="fade-transition" - >{{ msg }}</v-alert> - <p v-else>But I corrected them, sir.</p> - <v-form - @submit.prevent="submit"> - <v-text-field - label="Username" - v-model="credentials.username" - required - autofocus - /> - <v-text-field - label="Password" - v-model="credentials.password" - type="password" - required - /> - <v-btn :loading="loading" type="submit" color="primary">Access</v-btn> - </v-form> - </v-flex> - </v-layout> - </v-container> + <v-container fill-height> + <v-layout align-center justify-center> + <v-flex text-xs-center xs8 sm6 md4 lg2> + <img src="../assets/brand.png"/> + <h3 class="pt-3">Log in</h3> + <v-alert + outline + v-if="msg" + color="error" + :value="true" + transition="fade-transition" + >{{ msg }}</v-alert> + <p v-else>But I corrected them, sir.</p> + <v-form + @submit.prevent="submit"> + <v-text-field + label="Username" + v-model="credentials.username" + required + autofocus + /> + <v-text-field + label="Password" + v-model="credentials.password" + type="password" + required + /> + <v-btn :loading="loading" type="submit" color="primary">Access</v-btn> + </v-form> + </v-flex> + </v-layout> + </v-container> </template> diff --git a/frontend/src/pages/SubscriptionWorkPage.vue b/frontend/src/pages/SubscriptionWorkPage.vue index 6f1094d0b879b114096b424ab362df2013116ed1..62b0f4d332370ebd1f0fc696c9666198d4169c9d 100644 --- a/frontend/src/pages/SubscriptionWorkPage.vue +++ b/frontend/src/pages/SubscriptionWorkPage.vue @@ -7,6 +7,7 @@ :assignment="currentAssignment" :key="subscription.pk" @feedbackCreated="startWorkOnNextAssignment" + @skip="skipAssignment" class="ma-4 autofocus" /> </v-flex> @@ -31,11 +32,11 @@ function onRouteEnterOrUpdate (to, from, next) { if (to.name === 'subscription') { let subscription = store.state.subscriptions[to.params['pk']] - if (!subscription.currentAssignment) { - store.dispatch('getCurrentAssignment', subscription['pk']).then(() => { + if (subscription['assignments'].length === 0) { + store.dispatch('getAssignmentForSubscription', {subscription}).then(() => { next() + store.dispatch('getAssignmentForSubscription', {subscription}) }) - store.dispatch('getNextAssignment', subscription['pk']) } else { next() } @@ -53,7 +54,7 @@ return this.$store.state.subscriptions[this.$route.params['pk']] }, currentAssignment () { - return this.subscription.currentAssignment + return this.subscription['assignments'][0] }, submission () { return this.currentAssignment.submission @@ -70,31 +71,30 @@ }, methods: { prefetchAssignment () { - this.$store.dispatch('getNextAssignment', this.subscription['pk']).catch(err => { - if (err.statusCode === 410) { - this.$notify({ - title: 'Last submission here!', - text: 'This will be your last submission to correct for this subscription.', - type: 'warning' - }) - } + this.$store.dispatch('getAssignmentForSubscription', {subscription: this.subscription}).catch(() => { + this.$notify({ + title: 'Last submission here!', + text: 'This will be your last submission to correct for this subscription.', + type: 'warning' + }) }) }, startWorkOnNextAssignment () { - if (this.subscription.nextAssignment) { - this.$store.commit(mut.UPDATE_ASSIGNMENT, { - key: 'currentAssignment', - assignment: this.subscription.nextAssignment, - subscriptionPk: this.subscription['pk'] - }) - this.prefetchAssignment() - } else { + this.$store.commit(mut.DELETE_ASSIGNMENT_FROM_SUBSCRIPTION_QUEUE, { + subscription: this.subscription + }) + this.prefetchAssignment() + }, + skipAssignment () { + this.$store.dispatch('deleteAssignment', {assignment: this.currentAssignment}).then(() => { + this.startWorkOnNextAssignment() + }).catch(err => { this.$notify({ - title: 'This subscription has ended', - text: 'This subscription has ended', - type: 'info' + title: "Couldn't skip this submission", + text: err.msg, + type: 'error' }) - } + }) } } } diff --git a/frontend/src/pages/reviewer/ReviewerStartPage.vue b/frontend/src/pages/reviewer/ReviewerStartPage.vue index 52f15ad28926621142cf802fed317b7eeb9d3630..71f055328bf1e32b349119f6c56fc594251070d0 100644 --- a/frontend/src/pages/reviewer/ReviewerStartPage.vue +++ b/frontend/src/pages/reviewer/ReviewerStartPage.vue @@ -1,7 +1,7 @@ <template> <v-container fill-height> - <v-layout> - <h1 align-center justify-center>You are reviewer!</h1> + <v-layout align-center justify-center> + <h1>You are reviewer!</h1> </v-layout> </v-container> </template> diff --git a/frontend/src/pages/reviewer/StudentOverviewPage.vue b/frontend/src/pages/reviewer/StudentOverviewPage.vue index 28c5a26416d3012599a71f93ddbb715d26e8ef77..8b0936fbe696d53de6966347775cdd8ff38c4bb1 100644 --- a/frontend/src/pages/reviewer/StudentOverviewPage.vue +++ b/frontend/src/pages/reviewer/StudentOverviewPage.vue @@ -1,12 +1,12 @@ <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> + <v-layout> + <v-flex xs6> + <student-list class="ma-1"></student-list> + </v-flex> + <v-flex xs6 style="height: 100%;"> + <router-view></router-view> + </v-flex> + </v-layout> </template> <script> diff --git a/frontend/src/pages/student/StudentSubmissionPage.vue b/frontend/src/pages/student/StudentSubmissionPage.vue index e7946ff806e9e72e7977c321703e00e3ff385e9f..b751e545159d569d1159d8fc583864b5c7e5adbf 100644 --- a/frontend/src/pages/student/StudentSubmissionPage.vue +++ b/frontend/src/pages/student/StudentSubmissionPage.vue @@ -81,8 +81,8 @@ methods: { onRouteMountOrUpdate (routeId) { this.$store.commit(studentPageMut.SET_VISITED, { index: routeId, visited: true }) - this.$store.commit('submissionNotes/' + subNotesMut.SET_RAW_SUBMISSION, - this.$store.state.studentPage.submissionData[this.id].text) + this.$store.commit('submissionNotes/' + subNotesMut.SET_SUBMISSION, + this.$store.state.studentPage.submissionData[this.id]) } }, mounted () { diff --git a/frontend/src/pages/tutor/TutorStartPage.vue b/frontend/src/pages/tutor/TutorStartPage.vue index 1a98f924261130743f62ecc22cf241c28f96e462..1971e2679b7456779196ffed93b99fa700540905 100644 --- a/frontend/src/pages/tutor/TutorStartPage.vue +++ b/frontend/src/pages/tutor/TutorStartPage.vue @@ -1,6 +1,5 @@ <template> <v-flex lg3> - <subscription-list/> </v-flex> </template> @@ -9,10 +8,7 @@ export default { components: {SubscriptionList}, - name: 'tutor-start-page', - mounted () { - this.$store.dispatch('updateSubmissionTypes') - } + name: 'tutor-start-page' } </script> diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index b186a6305c74020fe3ddd338c284892be90c2138..d2d4cb5144fcc8e3845cb1e8a5e78b287f70dd9b 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -82,12 +82,12 @@ const router = new Router({ }, { path: 'student-overview', - name: 'student-overview', beforeEnter: reviewerOnly, component: StudentOverviewPage, children: [ { path: '', + name: 'student-overview', component: StudentListHelpCard }, { diff --git a/frontend/src/store/actions.js b/frontend/src/store/actions.js index 116df39eaae892c67e34eb19e3ef9dcc9d8a27f2..5cf10e161ba9017478768f7b76f7e3dec29b01f7 100644 --- a/frontend/src/store/actions.js +++ b/frontend/src/store/actions.js @@ -39,6 +39,15 @@ const actions = { passErrorIfNoResponse(err, 'Subscribing unsuccessful') } }, + async deactivateSubscription ({commit}, {subscription, pk}) { + try { + const subscriptionPk = subscription ? subscription.pk : pk + await api.deactivateSubscription({pk: subscriptionPk}) + commit(mut.SET_SUBSCRIPTION_DEACTIVATED, {pk: subscriptionPk}) + } catch (err) { + passErrorIfNoResponse(err, 'Unable to deactivate subscription') + } + }, async updateSubmissionTypes ({ commit }, fields) { try { const submissionTypes = await api.fetchSubmissionTypes(fields) @@ -49,30 +58,24 @@ const actions = { passErrorIfNoResponse(err) } }, - async getCurrentAssignment ({ commit }, subscriptionPk) { - try { - const assignment = await api.fetchCurrentAssignment(subscriptionPk) - commit(mut.UPDATE_ASSIGNMENT, { - assignment, - subscriptionPk, - key: 'currentAssignment' - }) - return assignment - } catch (err) { - passErrorIfNoResponse(err, "Couldn't fetch assignment") + async getAssignmentForSubscription ({commit, state}, {subscription}) { + if (subscription.assignments.length < 2) { + try { + const assignment = await api.createAssignment({subscription}) + commit(mut.ADD_ASSIGNMENT_TO_SUBSCRIPTION_QUEUE, {assignment}) + return assignment + } catch (err) { + passErrorIfNoResponse(err, "Couldn't fetch assignment") + } + } else { + return subscription.assignments[0] } }, - async getNextAssignment ({ commit }, subscriptionPk) { + async deleteAssignment ({commit, state}, {assignment}) { try { - const assignment = await api.fetchNextAssignment(subscriptionPk) - commit(mut.UPDATE_ASSIGNMENT, { - assignment, - subscriptionPk, - key: 'nextAssignment' - }) - return assignment + return await api.deleteAssignment({assignment}) } catch (err) { - passErrorIfNoResponse(err, "Couldn't fetch assignment") + passErrorIfNoResponse(err, 'Unable to delete assignment') } }, async getStudents ({commit}) { diff --git a/frontend/src/store/getters.js b/frontend/src/store/getters.js index 2f6c3e32e941c1732b25460f6a87177791740f1b..b5dfb59231748b191912c9cb2ad21038f325413e 100644 --- a/frontend/src/store/getters.js +++ b/frontend/src/store/getters.js @@ -1,5 +1,5 @@ const getters = { - getSubscriptionsGroupedByType (state) { + getSubscriptionsGroupedByType (state, getters) { let subscriptions = { 'random': [], 'student': [], @@ -12,12 +12,12 @@ const getters = { // sort the resulting arrays in subscriptions lexicographically by their query_keys Object.entries(subscriptions).forEach(([id, arr]) => { if (arr.length > 1 && arr[0].hasOwnProperty('query_key')) { - arr.sort((a, b) => { - const aLower = a['query_key'].toLowerCase() - const bLower = b['query_key'].toLowerCase() - if (aLower < bLower) { + arr.sort((subA, subB) => { + const subALower = getters.resolveSubscriptionKeyToName(subA).toLowerCase() + const subBLower = getters.resolveSubscriptionKeyToName(subB).toLowerCase() + if (subALower < subBLower) { return -1 - } else if (aLower > bLower) { + } else if (subALower > subBLower) { return 1 } else { return 0 @@ -27,6 +27,16 @@ const getters = { }) return subscriptions }, + + resolveSubscriptionKeyToName: state => subscription => { + if (subscription.query_type === 'random') { + return 'Active' + } else if (subscription.query_type === 'exam') { + return state.examTypes[subscription.query_key].module_reference + } else if (subscription.query_type === 'submission_type') { + return state.submissionTypes[subscription.query_key].name + } + }, getSubmission: state => pk => { return state.submissions[pk] }, diff --git a/frontend/src/store/modules/authentication.js b/frontend/src/store/modules/authentication.js index a4972a487a2d1cf7a31ae9ab615331e6a5398d01..43b860652ad37db65db9397817ef50f96ad24ee6 100644 --- a/frontend/src/store/modules/authentication.js +++ b/frontend/src/store/modules/authentication.js @@ -4,7 +4,7 @@ import gradySays from '../grady_speak' function initialState () { return { token: sessionStorage.getItem('token'), - tokenCreationTime: 0, + lastTokenRefreshTry: 0, refreshingToken: false, username: '', jwtTimeDelta: 0, @@ -19,6 +19,7 @@ export const authMut = Object.freeze({ SET_JWT_TIME_DELTA: 'SET_JWT_TIME_DELTA', SET_USERNAME: 'SET_USERNAME', SET_USER_ROLE: 'SET_USER_ROLE', + SET_LAST_TOKEN_REFRESH_TRY: 'SET_LAST_TOKEN_REFRESH_TRY', RESET_STATE: 'RESET_STATE', SET_REFRESHING_TOKEN: 'SET_REFRESHING_TOKEN' }) @@ -50,7 +51,6 @@ const authentication = { [authMut.SET_JWT_TOKEN]: function (state, token) { sessionStorage.setItem('token', token) state.token = token - state.tokenCreationTime = Date.now() }, [authMut.SET_JWT_TIME_DELTA]: function (state, timeDelta) { state.jwtTimeDelta = timeDelta @@ -61,12 +61,15 @@ const authentication = { [authMut.SET_USER_ROLE]: function (state, userRole) { state.userRole = userRole }, + [authMut.SET_REFRESHING_TOKEN]: function (state, refreshing) { + state.refreshingToken = refreshing + }, + [authMut.SET_LAST_TOKEN_REFRESH_TRY]: function (state) { + state.lastTokenRefreshTry = Date.now() + }, [authMut.RESET_STATE]: function (state) { sessionStorage.setItem('token', '') Object.assign(state, initialState()) - }, - [authMut.SET_REFRESHING_TOKEN]: function (state, refreshing) { - state.refreshingToken = refreshing } }, actions: { @@ -95,6 +98,7 @@ const authentication = { commit(authMut.SET_JWT_TOKEN, token) } finally { commit(authMut.SET_REFRESHING_TOKEN, false) + commit(authMut.SET_LAST_TOKEN_REFRESH_TRY) } }, async getUserRole ({commit}) { diff --git a/frontend/src/store/modules/submission-notes.js b/frontend/src/store/modules/submission-notes.js index dde42b674a2bd2825696c29d6df5b78b9bad1f38..2bb4106491a834115fd227dfcdc2acb526ce6b11 100644 --- a/frontend/src/store/modules/submission-notes.js +++ b/frontend/src/store/modules/submission-notes.js @@ -5,7 +5,7 @@ import {nameSpacer} from '@/store/util/helpers' export const subNotesNamespace = nameSpacer('submissionNotes/') export const subNotesMut = Object.freeze({ - SET_RAW_SUBMISSION: 'SET_RAW_SUBMISSION', + SET_SUBMISSION: 'SET_SUBMISSION', SET_ORIG_FEEDBACK: 'SET_ORIG_FEEDBACK', UPDATE_FEEDBACK_LINE: 'UPDATE_FEEDBACK_LINE', UPDATE_FEEDBACK_SCORE: 'UPDATE_FEEDBACK_SCORE', @@ -18,7 +18,9 @@ function initialState () { return { assignment: '', isFeedbackCreation: false, - rawSubmission: '', + submission: { + text: '' + }, ui: { showEditorOnLine: {}, selectedCommentOnLine: {} @@ -38,11 +40,11 @@ const submissionNotes = { namespaced: true, state: initialState(), getters: { - // reduce the string rawSubmission into an object where the keys are 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 submission: state => { - return state.rawSubmission.split('\n').reduce((acc, cur, index) => { + return state.submission.text.split('\n').reduce((acc, cur, index) => { acc[index + 1] = cur return acc }, {}) @@ -57,8 +59,8 @@ const submissionNotes = { } }, mutations: { - [subNotesMut.SET_RAW_SUBMISSION]: function (state, submission) { - state.rawSubmission = submission + [subNotesMut.SET_SUBMISSION]: function (state, submission) { + state.submission = submission }, [subNotesMut.SET_ORIG_FEEDBACK]: function (state, feedback) { if (feedback) { @@ -85,9 +87,11 @@ const submissionNotes = { } }, actions: { - submitFeedback: async function ({state}, {assignment, isFinal = false}) { - let feedback = {} - feedback.is_final = isFinal + submitFeedback: async function ({state}, {isFinal = false}) { + let feedback = { + is_final: isFinal, + of_submission: state.submission.pk + } if (Object.keys(state.updatedFeedback.feedback_lines).length > 0) { feedback['feedback_lines'] = state.updatedFeedback.feedback_lines } @@ -97,10 +101,10 @@ const submissionNotes = { feedback['score'] = state.updatedFeedback.score } if (state.isFeedbackCreation) { - return api.submitFeedbackForAssignment(feedback, assignment['pk']) + return api.submitFeedbackForAssignment({feedback}) } else { feedback.pk = state.origFeedback.pk - return api.submitUpdatedFeedback(feedback) + return api.submitUpdatedFeedback({feedback}) } } } diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js index 6be5e3a6ba3a4dbe84bd71c2ad432630128ea002..48c583ce7b0b7412d1389d5ab5d5d9155bf91e0b 100644 --- a/frontend/src/store/mutations.js +++ b/frontend/src/store/mutations.js @@ -3,17 +3,18 @@ import Vue from 'vue' import {initialState} from '@/store/store' export const mut = Object.freeze({ + ADD_ASSIGNMENT_TO_SUBSCRIPTION_QUEUE: 'ADD_ASSIGNMENT_TO_SUBSCRIPTION_QUEUE', + DELETE_ASSIGNMENT_FROM_SUBSCRIPTION_QUEUE: 'DELETE_ASSIGNMENT_FROM_SUBSCRIPTION_QUEUE', SET_ASSIGNMENT: 'SET_ASSIGNMENT', SET_SUBSCRIPTIONS: 'SET_SUBSCRIPTIONS', SET_SUBSCRIPTION: 'SET_SUBSCRIPTION', + SET_SUBSCRIPTION_DEACTIVATED: 'SET_SUBSCRIPTION_DEACTIVATED', SET_LAST_INTERACTION: 'SET_LAST_INTERACTION', SET_EXAM_TYPES: 'SET_EXAM_TYPES', - 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' }) @@ -33,6 +34,9 @@ const mutations = { [mut.SET_SUBSCRIPTION] (state, subscription) { Vue.set(state.subscriptions, subscription.pk, subscription) }, + [mut.SET_SUBSCRIPTION_DEACTIVATED] (state, {pk}) { + state.subscriptions[pk].deactivated = true + }, [mut.SET_STUDENTS] (state, students) { state.students = students }, @@ -49,20 +53,14 @@ const mutations = { } Vue.set(state.submissionTypes, submissionType.pk, updatedSubmissionType) }, - [mut.UPDATE_ASSIGNMENT] (state, {key, assignment, subscriptionPk}) { - const submission = assignment.submission - const feedback = assignment.feedback - let updatedAssignment = { - ...state.assignments[assignment.pk], - ...assignment - } - if (feedback) { - Vue.set(state.feedback, feedback.pk, feedback) - } - Vue.set(state.assignments, assignment.pk, updatedAssignment) - Vue.set(state.submissions, submission.pk, submission) - Vue.set(state.subscriptions[subscriptionPk], key, updatedAssignment) + [mut.ADD_ASSIGNMENT_TO_SUBSCRIPTION_QUEUE] (state, {assignment}) { + let subscription = state.subscriptions[assignment.subscription] + subscription['assignments'].push(assignment) + }, + [mut.DELETE_ASSIGNMENT_FROM_SUBSCRIPTION_QUEUE] (state, {subscription}) { + subscription.assignments.shift() }, + [mut.SET_LAST_INTERACTION] (state) { state.lastAppInteraction = Date.now() }, diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 8165d3c897868cea95af46bb231219aad96590a2..55416db15cf0392e5119fd5a8744533af7a52b5c 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -46,8 +46,9 @@ const store = new Vuex.Store({ // authentication.token is manually saved since using it with this plugin caused issues // when manually reloading the page paths: Object.keys(initialState()).concat( - ['ui', 'studentPage', 'submissionNotes', 'authentication.username', 'authentication.userRole', - 'authentication.jwtTimeDelta']) + ['ui', 'studentPage', 'submissionNotes', 'authentication.username', + 'authentication.userRole', 'authentication.jwtTimeDelta', + 'authentication.tokenCreationTime']) }), lastInteraction], actions, diff --git a/requirements.txt b/requirements.txt index 9881225c11c7103a2c39218cbeb3b085134e7520..9f22e122200338b0f281b62485df5bdaa91b3a90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ gunicorn~=19.7.0 psycopg2-binary~=2.7.4 whitenoise~=3.3.1 xlrd~=1.0.0 +tqdm~=4.19.5