diff --git a/.gitignore b/.gitignore index 94f4e488ebf83d4527182546e3ee8cb3fd9af4a9..9eba662cd6b951fc8dcec32caa0c0b29f9bca3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ public/ *.sublime-* .idea/ .vscode/ +anon-export/ # node node_modules diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 76b05a06c9e304bb955c52235792595912933c54..241d7579edecffbc2434ced41aad3dc717d40eec 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -64,6 +64,7 @@ test_flake8: test_frontend: <<: *test_definition_frontend + when: manual stage: test script: - yarn install diff --git a/core/models.py b/core/models.py index 4ffd5bcf9f92c6ef6534aceaaaf1b8208b3f6749..413c51023afafc6045a4a2dbb86c79191ee29924 100644 --- a/core/models.py +++ b/core/models.py @@ -435,7 +435,7 @@ class GeneralTaskSubscription(models.Model): RANDOM: '__any', STUDENT_QUERY: 'student__student_id', EXAM_TYPE_QUERY: 'student__examtype__module_reference', - SUBMISSION_TYPE_QUERY: 'type__title', + SUBMISSION_TYPE_QUERY: 'type__name', } QUERY_CHOICE = ( diff --git a/core/serializers.py b/core/serializers.py index 5dd096095b358040d9c8d2ecf57da30de27f39a4..64c50f5a73bb1292df3055b6c31b0108797adf8f 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -3,7 +3,6 @@ import logging from django.core.exceptions import ObjectDoesNotExist from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers -from rest_framework.validators import UniqueValidator from core import models from core.models import (ExamType, Feedback, GeneralTaskSubscription, @@ -45,7 +44,7 @@ class ExamSerializer(DynamicFieldsModelSerializer): class Meta: model = ExamType - fields = ('module_reference', 'total_score', + fields = ('pk', 'module_reference', 'total_score', 'pass_score', 'pass_only',) @@ -62,16 +61,10 @@ class FeedbackForSubmissionLineSerializer(serializers.BaseSerializer): class FeedbackSerializer(DynamicFieldsModelSerializer): - assignment_id = serializers.UUIDField(write_only=True) + assignment_pk = serializers.UUIDField(write_only=True) feedback_lines = FeedbackForSubmissionLineSerializer( required=False ) - isFinal = serializers.BooleanField(source="is_final", required=False) - ofSubmission = serializers.PrimaryKeyRelatedField( - source='of_submission', - required=False, - queryset=Submission.objects.all(), - validators=[UniqueValidator(queryset=Feedback.objects.all()), ]) def create(self, validated_data) -> Feedback: feedback = Feedback.objects.create( @@ -96,13 +89,13 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): def validate(self, data): log.debug(data) - assignment_id = data.pop('assignment_id') + assignment_pk = data.pop('assignment_pk') score = data.get('score') is_final = data.get('is_final', False) try: assignment = TutorSubmissionAssignment.objects.get( - assignment_id=assignment_id) + pk=assignment_pk) except ObjectDoesNotExist as err: raise serializers.ValidationError('No assignment for given id.') @@ -133,21 +126,20 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): class Meta: model = Feedback - fields = ('assignment_id', 'isFinal', 'score', - 'ofSubmission', 'feedback_lines') + fields = ('pk', 'assignment_pk', 'is_final', 'score', + 'of_submission', 'feedback_lines') + read_only_fields = ('of_submission', ) class FeedbackCommentSerializer(serializers.ModelSerializer): - - ofTutor = serializers.StringRelatedField(source='of_tutor.username') - isFinalComment = serializers.BooleanField(source='is_final') + of_tutor = serializers.StringRelatedField(source='of_tutor.username') def to_internal_value(self, data): return data class Meta: model = models.FeedbackComment - fields = ('text', 'ofTutor', 'created', 'isFinalComment') + fields = ('text', 'of_tutor', 'created', 'is_final') read_only_fields = ('created',) @@ -155,22 +147,21 @@ class TestSerializer(DynamicFieldsModelSerializer): class Meta: model = Test - fields = ('name', 'label', 'annotation') + fields = ('pk', 'name', 'label', 'annotation') class SubmissionTypeListSerializer(DynamicFieldsModelSerializer): - fullScore = serializers.IntegerField(source='full_score') class Meta: model = SubmissionType - fields = ('id', 'name', 'fullScore') + fields = ('pk', 'name', 'full_score') class SubmissionTypeSerializer(SubmissionTypeListSerializer): class Meta: model = SubmissionType - fields = ('id', 'name', 'fullScore', 'description', 'solution') + fields = ('pk', 'name', 'full_score', 'description', 'solution') class SubmissionSerializer(DynamicFieldsModelSerializer): @@ -180,17 +171,17 @@ class SubmissionSerializer(DynamicFieldsModelSerializer): class Meta: model = Submission - fields = ('type', 'text', 'feedback', 'tests') + fields = ('pk', 'type', 'text', 'feedback', 'tests') class SubmissionListSerializer(DynamicFieldsModelSerializer): - type = SubmissionTypeListSerializer(fields=('id', 'name', 'fullScore')) + type = SubmissionTypeListSerializer(fields=('pk', 'name', 'full_score')) # TODO change this according to new feedback model feedback = FeedbackSerializer(fields=('score',)) class Meta: model = Submission - fields = ('type', 'feedback') + fields = ('pk', 'type', 'feedback') class StudentInfoSerializer(DynamicFieldsModelSerializer): @@ -201,17 +192,17 @@ class StudentInfoSerializer(DynamicFieldsModelSerializer): class Meta: model = StudentInfo - fields = ('name', 'user', 'matrikel_no', 'exam', 'submissions') + fields = ('pk', 'name', 'user', 'matrikel_no', 'exam', 'submissions') class SubmissionNoTextFieldsSerializer(DynamicFieldsModelSerializer): score = serializers.ReadOnlyField(source='feedback.score') type = serializers.ReadOnlyField(source='type.name') - fullScore = serializers.ReadOnlyField(source='type.full_score') + full_score = serializers.ReadOnlyField(source='type.full_score') class Meta: model = Submission - fields = ('type', 'score', 'fullScore') + fields = ('pk', 'type', 'score', 'full_score') class StudentInfoSerializerForListView(DynamicFieldsModelSerializer): @@ -222,7 +213,7 @@ class StudentInfoSerializerForListView(DynamicFieldsModelSerializer): class Meta: model = StudentInfo - fields = ('name', 'user', 'exam', 'submissions') + fields = ('pk', 'name', 'user', 'exam', 'submissions') class TutorSerializer(DynamicFieldsModelSerializer): @@ -236,26 +227,26 @@ class TutorSerializer(DynamicFieldsModelSerializer): class Meta: model = UserAccount - fields = ('username', 'done_assignments_count') + fields = ('pk', 'username', 'done_assignments_count') class AssignmentSerializer(DynamicFieldsModelSerializer): - submission_id = serializers.ReadOnlyField( - source='submission.submission_id') + submission_pk = serializers.ReadOnlyField( + source='submission.pk') class Meta: model = TutorSubmissionAssignment - fields = ('assignment_id', 'submission_id', 'is_done',) + fields = ('pk', 'submission_pk', 'is_done',) class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer): text = serializers.ReadOnlyField() - typeId = serializers.ReadOnlyField(source='type.id') - fullScore = serializers.ReadOnlyField(source='type.full_score') + type_pk = serializers.ReadOnlyField(source='type.pk') + full_score = serializers.ReadOnlyField(source='type.full_score') class Meta: model = Submission - fields = ('submission_id', 'typeId', 'text', 'fullScore') + fields = ('pk', 'type_pk', 'text', 'full_score') class AssignmentDetailSerializer(DynamicFieldsModelSerializer): @@ -264,7 +255,7 @@ class AssignmentDetailSerializer(DynamicFieldsModelSerializer): class Meta: model = TutorSubmissionAssignment - fields = ('assignment_id', 'feedback', 'submission', 'is_done',) + fields = ('pk', 'feedback', 'submission', 'is_done',) class SubscriptionSerializer(DynamicFieldsModelSerializer): @@ -300,7 +291,7 @@ class SubscriptionSerializer(DynamicFieldsModelSerializer): class Meta: model = GeneralTaskSubscription fields = ( - 'subscription_id', + 'pk', 'owner', 'query_type', 'query_key', diff --git a/core/tests/test_feedback.py b/core/tests/test_feedback.py index 75c22ab9b42d88106894b0eb74c64750589dcce1..2512b6bc98659484f8f16e120bdf7863551c026d 100644 --- a/core/tests/test_feedback.py +++ b/core/tests/test_feedback.py @@ -69,8 +69,8 @@ class FeedbackRetrieveTestCase(APITestCase): self.assertIn(2, self.data['feedback_lines']) def test_if_feedback_contains_final(self): - self.assertIn('isFinal', self.data) - self.assertIsNotNone(self.data['isFinal']) + self.assertIn('is_final', self.data) + self.assertIsNotNone(self.data['is_final']) def test_if_comment_contains_text(self): self.assertIn('text', self.data['feedback_lines'][1][0]) @@ -82,15 +82,15 @@ class FeedbackRetrieveTestCase(APITestCase): self.assertIsNotNone(self.data['feedback_lines'][1][0]['created']) def test_if_comment_has_tutor(self): - self.assertIn('ofTutor', self.data['feedback_lines'][1][0]) + self.assertIn('of_tutor', self.data['feedback_lines'][1][0]) self.assertEqual( self.tutor.username, - self.data['feedback_lines'][1][0]['ofTutor']) + self.data['feedback_lines'][1][0]['of_tutor']) def test_if_comment_has_final(self): - self.assertIn('isFinalComment', self.data['feedback_lines'][1][0]) + self.assertIn('is_final', self.data['feedback_lines'][1][0]) self.assertIsNotNone( - self.data['feedback_lines'][1][0]['isFinalComment']) + self.data['feedback_lines'][1][0]['is_final']) class FeedbackCreateTestCase(APITestCase): @@ -123,8 +123,8 @@ class FeedbackCreateTestCase(APITestCase): # to the max Score for this submission data = { 'score': 10, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id + 'is_final': False, + 'assignment_pk': self.assignment.pk } self.assertEqual(Feedback.objects.count(), 0) @@ -135,8 +135,8 @@ class FeedbackCreateTestCase(APITestCase): def test_cannot_create_feedback_with_score_higher_than_max(self): data = { 'score': 101, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id + 'is_final': False, + 'assignment_pk': self.assignment.pk } self.assertEqual(Feedback.objects.count(), 0) response = self.client.post(self.url, data, format='json') @@ -146,8 +146,8 @@ class FeedbackCreateTestCase(APITestCase): def test_cannot_create_feedback_with_score_less_than_zero(self): data = { 'score': -1, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id + 'is_final': False, + 'assignment_pk': self.assignment.pk } self.assertEqual(Feedback.objects.count(), 0) response = self.client.post(self.url, data, format='json') @@ -157,8 +157,8 @@ class FeedbackCreateTestCase(APITestCase): def test_check_score_is_set_accordingly(self): data = { 'score': 5, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id + 'is_final': False, + 'assignment_pk': self.assignment.pk } self.client.post(self.url, data, format='json') object_score = self.sub.feedback.score @@ -167,8 +167,8 @@ class FeedbackCreateTestCase(APITestCase): def test_can_create_feedback_with_comment(self): data = { 'score': 0, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id, + 'is_final': False, + 'assignment_pk': self.assignment.pk, 'feedback_lines': { '5': { 'text': 'Nice meth!' @@ -183,8 +183,8 @@ class FeedbackCreateTestCase(APITestCase): def test_feedback_comment_is_created_correctly(self): data = { 'score': 0, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id, + 'is_final': False, + 'assignment_pk': self.assignment.pk, 'feedback_lines': { '5': { 'text': 'Nice meth!' @@ -202,8 +202,8 @@ class FeedbackCreateTestCase(APITestCase): def test_can_create_multiple_feedback_comments(self): data = { 'score': 0, - 'isFinal': False, - 'assignment_id': self.assignment.assignment_id, + 'is_final': False, + 'assignment_pk': self.assignment.pk, 'feedback_lines': { '5': { 'text': 'Nice meth!' diff --git a/core/tests/test_student_page.py b/core/tests/test_student_page.py index a4a86a37e9f77c853b4555bedc061634002f4178..52d09daf1e1e2a6e931670c2a63951f42959b9e8 100644 --- a/core/tests/test_student_page.py +++ b/core/tests/test_student_page.py @@ -104,12 +104,12 @@ class StudentPageTests(APITestCase): def test_a_student_submissions_contains_type_id(self): self.assertEqual( - self.submission_list_first_entry['type']['id'], + self.submission_list_first_entry['type']['pk'], self.student_info.submissions.first().type.id) def test_submission_data_contains_full_score(self): self.assertEqual( - self.submission_list_first_entry['type']['fullScore'], + self.submission_list_first_entry['type']['full_score'], self.student_info.submissions.first().type.full_score) def test_submission_data_contains_feedback_score(self): @@ -182,12 +182,12 @@ class StudentSelfSubmissionsTests(APITestCase): def test_a_student_submissions_contains_type_id(self): self.assertEqual( - self.submission_list_first_entry['type']['id'], - self.student_info.submissions.first().type.id) + self.submission_list_first_entry['type']['pk'], + self.student_info.submissions.first().type.pk) def test_submission_data_contains_full_score(self): self.assertEqual( - self.submission_list_first_entry['type']['fullScore'], + self.submission_list_first_entry['type']['full_score'], self.student_info.submissions.first().type.full_score) def test_submission_data_contains_description(self): @@ -202,7 +202,7 @@ class StudentSelfSubmissionsTests(APITestCase): def test_submission_data_contains_final_status(self): self.assertEqual( - self.submission_list_first_entry['feedback']['isFinal'], + self.submission_list_first_entry['feedback']['is_final'], self.student_info.submissions.first().feedback.is_final) def test_submission_data_contains_feedback_score(self): diff --git a/core/tests/test_student_reviewer_viewset.py b/core/tests/test_student_reviewer_viewset.py index ddc317546e63aad954f6dea37a24a93c9b53a71f..9d22d7c1f24e4bc8fec393f2bdb6eb06d187ede7 100644 --- a/core/tests/test_student_reviewer_viewset.py +++ b/core/tests/test_student_reviewer_viewset.py @@ -76,4 +76,4 @@ class StudentPageTests(APITestCase): def test_submissions_full_score_is_included(self): print(self.response.data[0]['submissions'][0]) self.assertEqual(self.student.submissions.first().type.full_score, - self.response.data[0]['submissions'][0]['fullScore']) + self.response.data[0]['submissions'][0]['full_score']) diff --git a/core/tests/test_submissiontypeview.py b/core/tests/test_submissiontypeview.py index 713cdb9072c47c370c6d1bc12a3ba3c2e239fc56..cbb68f07bf4812ba9d9004dae176922ce5a9b58e 100644 --- a/core/tests/test_submissiontypeview.py +++ b/core/tests/test_submissiontypeview.py @@ -37,7 +37,7 @@ class SubmissionTypeViewTestList(APITestCase): self.assertEqual('Hard question', self.response.data[0]['name']) def test_get_full_score(self): - self.assertEqual(20, self.response.data[0]['fullScore']) + self.assertEqual(20, self.response.data[0]['full_score']) class SubmissionTypeViewTestRetrieve(APITestCase): @@ -62,13 +62,13 @@ class SubmissionTypeViewTestRetrieve(APITestCase): self.assertEqual(self.response.status_code, status.HTTP_200_OK) def test_get_id(self): - self.assertEqual(self.pk, self.response.data['id']) + self.assertEqual(self.pk, self.response.data['pk']) def test_get_sumbission_type_name(self): self.assertEqual('Hard question', self.response.data['name']) def test_get_full_score(self): - self.assertEqual(20, self.response.data['fullScore']) + self.assertEqual(20, self.response.data['full_score']) def test_get_descritpion(self): self.assertEqual('Whatever', self.response.data['description']) diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py index f11b23194a803973949878ed3f33b66c6d8e24fb..01d418e0b075fa7a14e2e100acc473d7b9a027af 100644 --- a/core/tests/test_subscription_assignment_service.py +++ b/core/tests/test_subscription_assignment_service.py @@ -160,15 +160,15 @@ class TestApiEndpoints(APITestCase): response_subs = client.post( '/api/subscription/', {'query_type': 'random'}) - subscription_id = response_subs.data['subscription_id'] - assignment_id = response_subs.data['assignments'][0]['assignment_id'] + subscription_id = response_subs.data['pk'] + assignment_pk = response_subs.data['assignments'][0]['pk'] response = client.get( f'/api/subscription/{subscription_id}/assignments/current/') self.assertEqual(1, len(response_subs.data['assignments'])) - self.assertEqual(assignment_id, - response.data['assignment_id']) + self.assertEqual(assignment_pk, + response.data['pk']) response_next = client.get( f'/api/subscription/{subscription_id}/assignments/next/') @@ -176,7 +176,7 @@ class TestApiEndpoints(APITestCase): client.get(f'/api/subscription/{subscription_id}/') self.assertEqual(2, len(response_detail_subs.data['assignments'])) - self.assertNotEqual(assignment_id, response_next.data['assignment_id']) + self.assertNotEqual(assignment_pk, response_next.data['pk']) def test_subscription_can_assign_to_student(self): client = APIClient() @@ -207,11 +207,11 @@ class TestApiEndpoints(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) - assignment_id = response.data['assignments'][0]['assignment_id'] + assignment_pk = response.data['assignments'][0]['pk'] response = client.post( f'/api/feedback/', { "score": 23, - "assignment_id": assignment_id, + "assignment_pk": assignment_pk, "feedback_lines": { 2: {"text": "< some string >"}, 3: {"text": "< some string >"} @@ -232,7 +232,7 @@ class TestApiEndpoints(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) - subscription_id = response.data['subscription_id'] + subscription_id = response.data['pk'] response = client.get( f'/api/subscription/{subscription_id}/assignments/current/') @@ -240,7 +240,7 @@ class TestApiEndpoints(APITestCase): submission_id_in_database = models.Feedback.objects.filter( is_final=False).first().of_submission.submission_id submission_id_in_response = \ - response.data['submission']['submission_id'] + response.data['submission']['pk'] self.assertEqual( str(submission_id_in_database), diff --git a/core/views.py b/core/views.py index 165498d777c6d380f8643bdf9abd64f4defdef9c..34f785ade209238416074da9ab7f7fea20cdc1c6 100644 --- a/core/views.py +++ b/core/views.py @@ -163,10 +163,17 @@ class SubscriptionApiViewSet( serializer.is_valid(raise_exception=True) subscription = serializer.save() - if subscription.query_type == GeneralTaskSubscription.STUDENT_QUERY: - subscription.reserve_all_assignments_for_a_student() - else: - subscription.get_oldest_unfinished_assignment() + try: + if subscription.query_type == \ + GeneralTaskSubscription.STUDENT_QUERY: + subscription.reserve_all_assignments_for_a_student() + else: + subscription.get_oldest_unfinished_assignment() + except models.SubscriptionEnded as err: + return Response( + {'Error': 'This subscription has no available submissions'}, + status.HTTP_410_GONE + ) headers = self.get_success_headers(serializer.data) return Response(serializer.data, diff --git a/frontend/package.json b/frontend/package.json index 03ef1b1cd9bf1edda3018db02a2008091e8256ac..fa17670cfdb2314460bb91225986a1cb35df00d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,9 +18,11 @@ "material-design-icons": "^3.0.1", "v-clipboard": "^1.0.4", "vue": "^2.5.2", + "vue-notification": "^1.3.6", "vue-router": "^3.0.1", "vuetify": "^0.17.3", - "vuex": "^3.0.1" + "vuex": "^3.0.1", + "vuex-persistedstate": "^2.4.2" }, "devDependencies": { "autoprefixer": "^7.1.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0e0910b3cbff0ccb4a7884cf6d4abd3832648c76..accd086237c26b89aece8e056ca52f046f447998 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,15 +1,93 @@ <template> <div id="app"> <v-app> + <notifications/> <router-view/> + <v-dialog + persistent + width="fit-content" + v-model="logoutDialog" + > + <v-card> + <v-card-title class="headline"> + You'll be logged out! + </v-card-title> + <v-card-text> + Due to inactivity you'll be logged out in a couple of moments.<br/> + Any unsaved work will be lost. + Click Continue to stay logged in. + </v-card-text> + <v-card-actions> + <v-btn flat color="grey lighten-0" + @click="logout" + >Logout now</v-btn> + <v-spacer/> + <v-btn flat color="blue darken-2" + @click="continueWork" + >Continue</v-btn> + </v-card-actions> + </v-card> + </v-dialog> </v-app> </div> </template> <script> + import {mapState} from 'vuex' export default { name: 'app', - components: { + data () { + return { + timer: 0, + logoutDialog: false + } + }, + computed: { + ...mapState([ + 'lastAppInteraction' + ]), + ...mapState({ + tokenCreationTime: state => state.authentication.tokenCreationTime, + refreshingToken: state => state.authentication.refreshingToken, + jwtTimeDelta: state => state.authentication.jwtTimeDelta + }) + }, + methods: { + logout () { + this.logoutDialog = false + this.$store.dispatch('logout') + }, + continueWork () { + this.$store.dispatch('refreshJWT') + this.logoutDialog = false + } + }, + watch: { + lastAppInteraction: function (val) { + const timeSinceLastRefresh = Date.now() - this.tokenCreationTime + const timeDelta = this.jwtTimeDelta + // refresh jwt if it's older than 20% of his maximum age + if (timeDelta > 0 && timeSinceLastRefresh > timeDelta * 0.2 && + !this.refreshingToken) { + this.$store.dispatch('refreshJWT') + } + } + }, + mounted () { + const oneAndHalfMinute = 90 * 1e3 + this.timer = setInterval(() => { + if (this.$route.path !== '/') { + if (Date.now() > this.tokenCreationTime + this.jwtTimeDelta) { + this.logoutDialog = false + this.$store.dispatch('logout', "You've been logged out due to inactivity.") + } else if (Date.now() + oneAndHalfMinute > this.tokenCreationTime + this.jwtTimeDelta) { + this.logoutDialog = true + } + } + }, 5 * 1e3) + }, + beforeDestroy () { + clearInterval(this.timer) } } </script> diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000000000000000000000000000000000000..90ecdd6acc45f06ee2ef0d8ac48bfd643de4e6b6 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,86 @@ +import axios from 'axios' + +// import store from '@/store/store' + +let ax = axios.create({ + baseURL: 'http://localhost:8000/', + headers: {'Authorization': 'JWT ' + sessionStorage.getItem('token')} +}) + +export async function fetchJWT (credentials) { + const token = (await ax.post('/api-token-auth/', 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 + ax.defaults.headers['Authorization'] = `JWT ${newToken}` + return token +} + +export async function fetchJWTTimeDelta () { + return (await ax.get('/api/jwt-time-delta/')).data.timeDelta +} + +export async function fetchUserRole () { + return (await ax.get('/api/user-role/')).data.role +} + +export async function fetchStudentSelfData () { + return (await ax.get('/api/student-page/')).data +} + +export async function fetchStudentSubmissions () { + return (await ax.get('/api/student-submissions/')).data +} + +export async function fetchSubscriptions () { + return (await ax.get('/api/subscription/')).data +} + +export async function fetchSubscription (subscriptionPk) { + return (await ax.get(`/api/subscription/${subscriptionPk}`)).data +} + +export async function subscribeTo (type, key, stage) { + let data = { + query_type: type + } + + if (key) { + data.query_key = key + } + if (stage) { + data.feedback_stage = 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) { + const data = { + ...feedback, + assignment_pk: assignmentPk + } + + return (await ax.post('/api/feedback/', data)).data +} + +export async function fetchSubmissionTypes (fields = []) { + let url = '/api/submissiontype/' + if (fields.length > 0) { + url += '?fields=pk,' + fields + } + return (await ax.get(url)).data +} + +export default ax diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index 3f18a9866469951978cbc9420c4b34edfd9c3184..851b49a050abd129fbdd2d15e53accaec4b82107 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -7,7 +7,7 @@ permanent :mini-variant="mini" > - <v-toolbar flat> + <v-toolbar> <v-list> <v-list-tile> <v-list-tile-action v-if="mini"> @@ -15,10 +15,12 @@ <v-icon>chevron_right</v-icon> </v-btn> </v-list-tile-action> - <v-list-tile-content class="title"> + <v-list-tile-content + class="title" + > <slot name="header"></slot> </v-list-tile-content> - <v-list-tile-action> + <v-list-tile-action v-if="!mini"> <v-btn icon @click.native.stop="mini = !mini"> <v-icon>chevron_left</v-icon> </v-btn> @@ -54,7 +56,8 @@ </template> <script> - import { mapActions, mapGetters, mapState } from 'vuex' + import { mapGetters, mapState } from 'vuex' + export default { name: 'base-layout', data () { @@ -66,15 +69,15 @@ ...mapGetters([ 'gradySpeak' ]), - ...mapState([ - 'username', - 'userRole' - ]) + ...mapState({ + username: state => state.authentication.username, + userRole: state => state.authentication.userRole + }) }, methods: { - ...mapActions([ - 'logout' - ]) + logout () { + this.$store.dispatch('logout') + } }, watch: { mini: function () { @@ -92,4 +95,8 @@ .grady-toolbar { font-weight: bold; } + + .title { + color: gray; + } </style> diff --git a/frontend/src/components/SubmissionType.vue b/frontend/src/components/SubmissionType.vue index 6cf35fce198a4f61e0369575a7669f5c7990767b..c5bd057cb08fb08f17f19f919b4519953cab9d92 100644 --- a/frontend/src/components/SubmissionType.vue +++ b/frontend/src/components/SubmissionType.vue @@ -1,19 +1,31 @@ <template> <v-container> - <h2 class="mb-2">{{ name }} - Full score: {{ fullScore }}</h2> - <v-expansion-panel expand> - <v-expansion-panel-content - v-for="(item, i) in typeItems" - :key="i" - :value="expandedByDefault[item.title]"> - <div slot="header">{{ item.title }}</div> - <v-card color="grey lighten-4"> - <v-card-text> - {{ item.text }} - </v-card-text> - </v-card> - </v-expansion-panel-content> - </v-expansion-panel> + <v-layout column> + <span class="title mb-2">{{ name }} - Full score: {{ full_score }}</span> + <v-expansion-panel expand> + <v-expansion-panel-content + v-for="(item, i) in typeItems" + :key="i" + :value="expandedByDefault[item.title]"> + <div slot="header">{{ item.title }}</div> + <v-card + v-if="item.title === 'Description'" + color="grey lighten-4"> + <v-card-text class="ml-2"> + <span + v-html="item.text" + ></span> + </v-card-text> + </v-card> + <v-flex v-else-if="item.title === 'Solution'"> + <pre + class="prettyprint elevation-2 solution-code" + :class="language" + >{{item.text}}</pre> + </v-flex> + </v-expansion-panel-content> + </v-expansion-panel> + </v-layout> </v-container> </template> @@ -34,10 +46,14 @@ type: String, required: true }, - fullScore: { + full_score: { type: Number, required: true }, + language: { + type: String, + default: 'lang-c' + }, reverse: { type: Boolean, default: false @@ -70,7 +86,17 @@ return items } } + }, + mounted () { + window.PR.prettyPrint() } } </script> +<style scoped> + .solution-code { + border-width: 0px; + white-space: pre-wrap; + } +</style> + diff --git a/frontend/src/components/student/SubmissionList.vue b/frontend/src/components/student/SubmissionList.vue index 451404f910874998df123c66d144528d2e11c4fb..916563d5d09bdbbf2a7dae9fc1fc0add52e5ec48 100644 --- a/frontend/src/components/student/SubmissionList.vue +++ b/frontend/src/components/student/SubmissionList.vue @@ -9,8 +9,8 @@ <template slot="items" slot-scope="props"> <td>{{ props.item.type.name }}</td> <td class="text-xs-right">{{ props.item.feedback.score }}</td> - <td class="text-xs-right">{{ props.item.type.fullScore }}</td> - <td class="text-xs-right"><v-btn :to="`/student/submission/${props.item.type.id}`" color="orange lighten-2"><v-icon>chevron_right</v-icon></v-btn></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> </template> </v-data-table> <v-alert color="info" value="true"> @@ -38,7 +38,7 @@ }, { text: 'Maximum Score', - value: 'type.fullScore' + value: 'type.full_score' } ] } @@ -54,10 +54,15 @@ return this.submissions.map(a => a.feedback.score).reduce((a, b) => a + b) }, sumFullScore () { - return this.submissions.map(a => a.type.fullScore).reduce((a, b) => a + b) + return this.submissions.map(a => a.type.full_score).reduce((a, b) => a + b) }, pointRatio () { return ((this.sumScore / this.sumFullScore) * 100).toFixed(2) + }, + students () { + this.items.forEach(item => { + + }) } } } diff --git a/frontend/src/components/submission_notes/AnnotatedSubmission.vue b/frontend/src/components/submission_notes/AnnotatedSubmission.vue deleted file mode 100644 index a3fdb504bf11319d0313c9858c57ec129c7231db..0000000000000000000000000000000000000000 --- a/frontend/src/components/submission_notes/AnnotatedSubmission.vue +++ /dev/null @@ -1,127 +0,0 @@ -<template> - <v-container> - <annotated-submission-top-toolbar - v-if="isTutor || isReviewer" - class="mb-1 elevation-1" - :submission="rawSubmission" - /> - <table class="elevation-1"> - <tr v-for="(code, index) in submission" :key="index"> - <td class="line-number-cell"> - <v-btn block class="line-number-btn" @click="toggleEditorOnLine(index)">{{ index }}</v-btn> - </td> - <td> - <pre class="prettyprint"><code class="lang-c"> {{ code }}</code></pre> - <feedback-comment - v-if="feedback[index] && !showEditorOnLine[index]" - @click.native="toggleEditorOnLine(index)">{{ feedback[index] }} - </feedback-comment> - <comment-form - v-if="showEditorOnLine[index] && editable" - @collapseFeedbackForm="showEditorOnLine[index] = false" - :feedback="feedback[index]" - :index="index"> - </comment-form> - </td> - </tr> - </table> - <annotated-submission-bottom-toolbar - v-if="isTutor || isReviewer" - class="mt-1 elevation-1" - /> - </v-container> -</template> - - -<script> - import { mapGetters } from 'vuex' - import CommentForm from '@/components/submission_notes/FeedbackForm.vue' - import FeedbackComment from '@/components/submission_notes/FeedbackComment.vue' - import AnnotatedSubmissionTopToolbar from '@/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar' - import AnnotatedSubmissionBottomToolbar from '@/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar' - - export default { - components: { - AnnotatedSubmissionBottomToolbar, - AnnotatedSubmissionTopToolbar, - FeedbackComment, - CommentForm}, - name: 'annotated-submission', - props: { - rawSubmission: { - type: String, - required: true - }, - score: { - type: Number, - required: true - }, - feedback: { - type: Object, - required: true - }, - editable: { - type: Boolean, - default: false - } - }, - data: function () { - return { - showEditorOnLine: {} - } - }, - computed: { - submission () { - return this.rawSubmission.split('\n').reduce((acc, cur, index) => { - acc[index + 1] = cur - return acc - }, {}) - }, - ...mapGetters([ - 'isStudent', - 'isTutor', - 'isReviewer' - ]) - }, - methods: { - toggleEditorOnLine (lineIndex) { - this.$set(this.showEditorOnLine, lineIndex, !this.showEditorOnLine[lineIndex]) - } - }, - mounted () { - window.PR.prettyPrint() - } - } -</script> - - -<style scoped> - - table { - table-layout: auto; - border-collapse: collapse; - width: 100%; - } - - - .line-number-cell { - vertical-align: top; - } - - pre.prettyprint { - padding: 0; - border: 0; - } - - code { - width: 100%; - box-shadow: None; - } - - - .line-number-btn { - height: fit-content; - min-width: fit-content; - margin: 0; - } -</style> diff --git a/frontend/src/components/submission_notes/FeedbackComment.vue b/frontend/src/components/submission_notes/FeedbackComment.vue deleted file mode 100644 index 8af24d6e8b8d011967f105b11f2e69d8f15511ed..0000000000000000000000000000000000000000 --- a/frontend/src/components/submission_notes/FeedbackComment.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> - <div class="dialogbox"> - <div class="body"> - <span class="tip tip-up"></span> - <div class="message"> - <slot></slot> - </div> - </div> - </div> -</template> - - -<script> - export default { - name: 'feedback-comment' - } -</script> - - -<style scoped> - .tip { - width: 0px; - height: 0px; - position: absolute; - background: transparent; - border: 10px solid #3D8FC1; - } - - .tip-up { - top: -22px; /* Same as body margin top + border */ - left: 10px; - border-right-color: transparent; - border-left-color: transparent; - border-top-color: transparent; - } - - .dialogbox .body { - position: relative; - height: auto; - margin: 20px 10px 10px 10px; - padding: 5px; - background-color: #F3F3F3; - border-radius: 0px; - border: 2px solid #3D8FC1; - } - - .body .message { - min-height: 30px; - border-radius: 3px; - font-size: 14px; - line-height: 1.5; - } -</style> diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue new file mode 100644 index 0000000000000000000000000000000000000000..a7ba1a5fdc5f438f552d7aabe1ceda6c1ebdd981 --- /dev/null +++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue @@ -0,0 +1,152 @@ +<template> + <v-container> + <base-annotated-submission> + <annotated-submission-top-toolbar + class="mb-1 elevation-1" + slot="header" + /> + <template slot="table-content"> + <tr v-for="(code, lineNo) in submission" :key="lineNo"> + <submission-line + :code="code" + :line-no="lineNo" + @toggleEditor="toggleEditorOnLine(lineNo)" + > + <template> + <feedback-comment + v-if="origFeedback[lineNo]" + v-for="(comment, index) in origFeedback[lineNo]" + v-bind="comment" + :key="index" + @click.native="toggleEditorOnLine(lineNo, comment)" + /> + </template> + <feedback-comment + v-if="updatedFeedback[lineNo]" + borderColor="orange" + v-bind="updatedFeedback[lineNo]" + :deletable="true" + @click.native="toggleEditorOnLine(lineNo, updatedFeedback[lineNo])" + @delete="deleteFeedback(lineNo)" + /> + <comment-form + v-if="showEditorOnLine[lineNo]" + :feedback="selectedComment[lineNo].text" + :lineNo="lineNo" + @collapseFeedbackForm="toggleEditorOnLine(lineNo)" + @submitFeedback="" + > + </comment-form> + </submission-line> + </tr> + </template> + <annotated-submission-bottom-toolbar + class="mt-1 elevation-1" + slot="footer" + :loading="loading" + :fullScore="submissionObj['full_score']" + @submitFeedback="submitFeedback" + /> + </base-annotated-submission> + </v-container> +</template> + + +<script> + import { mapState, mapGetters } from 'vuex' + import CommentForm from '@/components/submission_notes/base/CommentForm.vue' + import FeedbackComment from '@/components/submission_notes/base/FeedbackComment.vue' + import AnnotatedSubmissionTopToolbar from '@/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar' + import AnnotatedSubmissionBottomToolbar from '@/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar' + import BaseAnnotatedSubmission from '@/components/submission_notes/base/BaseAnnotatedSubmission' + import SubmissionLine from '@/components/submission_notes/base/SubmissionLine' + + export default { + components: { + SubmissionLine, + BaseAnnotatedSubmission, + AnnotatedSubmissionBottomToolbar, + AnnotatedSubmissionTopToolbar, + FeedbackComment, + CommentForm}, + name: 'submission-correction', + data () { + return { + loading: false + } + }, + props: { + assignment: { + type: Object + }, + submissionWithoutAssignment: { + type: Object + }, + feedback: { + type: Object + } + }, + computed: { + ...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 + }), + ...mapGetters([ + 'isStudent', + 'isTutor', + 'isReviewer', + 'getSubmission', + 'getFeedback', + 'getSubmissionType' + ]), + submission () { + return this.$store.getters['submissionNotes/submission'] + }, + submissionObj () { + return this.assignment ? this.assignment.submission : this.submissionWithoutAssignment + }, + feedbackObj () { + return this.assignment ? this.assignment.feedback : this.feedback + } + }, + methods: { + deleteFeedback (lineNo) { + this.$store.commit('submissionNotes/DELETE_FEEDBACK_LINE', lineNo) + }, + toggleEditorOnLine (lineNo, comment = '') { + this.$store.commit('submissionNotes/TOGGLE_EDITOR_ON_LINE', {lineNo, comment}) + }, + submitFeedback () { + this.loading = true + this.$store.dispatch('submissionNotes/submitFeedback', this.assignment).then(() => { + this.$store.commit('submissionNotes/RESET_STATE') + this.$emit('feedbackCreated') + }).catch(err => { + this.$notify({ + title: 'Feedback creation Error!', + text: err.message, + type: 'error' + }) + }).finally(() => { + this.loading = false + }) + }, + init () { + this.$store.commit('submissionNotes/RESET_STATE') + this.$store.commit('submissionNotes/SET_RAW_SUBMISSION', this.submissionObj.text) + this.$store.commit('submissionNotes/SET_ORIG_FEEDBACK', this.feedbackObj) + window.PR.prettyPrint() + } + }, + mounted () { + this.init() + } + } +</script> + + +<style scoped> + +</style> diff --git a/frontend/src/components/submission_notes/base/BaseAnnotatedSubmission.vue b/frontend/src/components/submission_notes/base/BaseAnnotatedSubmission.vue new file mode 100644 index 0000000000000000000000000000000000000000..c79b1e55847613c52c3a2967e7831e65a5b92943 --- /dev/null +++ b/frontend/src/components/submission_notes/base/BaseAnnotatedSubmission.vue @@ -0,0 +1,23 @@ +<template> + <div> + <slot name="header"/> + <table class="submission-table elevation-1"> + <slot name="table-content"/> + </table> + <slot name="footer"/> + </div> +</template> + +<script> + export default { + name: 'base-annotated-submission' + } +</script> + +<style scoped> + .submission-table { + table-layout: auto; + border-collapse: collapse; + width: 100%; + } +</style> diff --git a/frontend/src/components/submission_notes/FeedbackForm.vue b/frontend/src/components/submission_notes/base/CommentForm.vue similarity index 78% rename from frontend/src/components/submission_notes/FeedbackForm.vue rename to frontend/src/components/submission_notes/base/CommentForm.vue index 403d001547cdbb3476ab9402bb90f92b6602f4d7..73c3aff18fda4c410253709752098f777834f47a 100644 --- a/frontend/src/components/submission_notes/FeedbackForm.vue +++ b/frontend/src/components/submission_notes/base/CommentForm.vue @@ -23,8 +23,14 @@ export default { name: 'comment-form', props: { - feedback: String, - index: String + feedback: { + type: String, + default: '' + }, + lineNo: { + type: String, + required: true + } }, data () { return { @@ -41,9 +47,11 @@ this.$emit('collapseFeedbackForm') }, submitFeedback () { - this.$store.dispatch('updateFeedback', { - lineIndex: this.index, - content: this.currentFeedback + this.$store.commit('submissionNotes/UPDATE_FEEDBACK_LINE', { + lineNo: this.lineNo, + comment: { + text: this.currentFeedback + } }) this.collapseTextField() }, diff --git a/frontend/src/components/submission_notes/base/FeedbackComment.vue b/frontend/src/components/submission_notes/base/FeedbackComment.vue new file mode 100644 index 0000000000000000000000000000000000000000..c467023f210d27da8fb0274bec64a4d1da87afc9 --- /dev/null +++ b/frontend/src/components/submission_notes/base/FeedbackComment.vue @@ -0,0 +1,105 @@ +<template> + <div class="dialog-box"> + <div class="body elevation-1" :style="{borderColor: borderColor}"> + <span class="tip tip-up" :style="{borderBottomColor: borderColor}"></span> + <span v-if="of_tutor" class="of-tutor">Of tutor: {{of_tutor}}</span> + <span class="comment-created">{{parsedCreated}}</span> + <div class="message">{{text}}</div> + <v-btn + flat icon + class="delete-button" + v-if="deletable" + @click.stop="$emit('delete')" + ><v-icon color="grey darken-1">delete_forever</v-icon></v-btn> + </div> + </div> +</template> + + +<script> + export default { + name: 'feedback-comment', + props: { + text: { + type: String, + required: true + }, + created: { + type: String, + required: false + }, + of_tutor: { + type: String, + required: false + }, + deletable: { + type: Boolean, + default: false + }, + borderColor: { + type: String, + default: '#3D8FC1' + } + }, + computed: { + parsedCreated () { + if (this.created) { + return new Date(this.created).toLocaleString() + } else { + return 'Just now' + } + } + } + } +</script> + + +<style scoped> + .tip { + width: 0px; + height: 0px; + position: absolute; + background: transparent; + border: 10px solid; + } + .tip-up { + top: -22px; /* Same as body margin top + border */ + left: 10px; + border-right-color: transparent; + border-left-color: transparent; + border-top-color: transparent; + } + .dialog-box .body { + position: relative; + height: auto; + margin: 20px 10px 10px 10px; + padding: 5px; + background-color: #F3F3F3; + border-radius: 0px; + border: 2px solid; + } + .body .message { + min-height: 30px; + border-radius: 3px; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + } + .delete-button { + position: absolute; + bottom: -10px; + right: 0px; + } + .comment-created { + position: absolute; + font-size: 10px; + right: 4px; + top: -20px; + } + .of-tutor { + position: absolute; + font-size: 13px; + top: -20px; + left: 50px; + } +</style> diff --git a/frontend/src/components/submission_notes/base/SubmissionLine.vue b/frontend/src/components/submission_notes/base/SubmissionLine.vue new file mode 100644 index 0000000000000000000000000000000000000000..78f5dd15dd212bf3fb4e066861bd687f819ab408 --- /dev/null +++ b/frontend/src/components/submission_notes/base/SubmissionLine.vue @@ -0,0 +1,73 @@ +<template> + <div> + <td class="line-number-cell"> + <v-btn + block + class="line-number-btn" + @click="toggleEditor" + > + {{ lineNo }} + </v-btn> + </td> + <td class="code-cell-content pl-2"> + <pre class="prettyprint" :class="codeLanguage">{{ code }}</pre> + <slot/> + </td> + </div> +</template> + +<script> + export default { + name: 'submission-line', + props: { + lineNo: { + type: String, + required: true + }, + code: { + type: String, + required: true + }, + codeLanguage: { + type: String, + default: 'lang-c' + } + }, + methods: { + toggleEditor () { + this.$emit('toggleEditor') + } + }, + mounted () { + window.PR.prettyPrint() + } + } +</script> + +<style scoped> + .line-number-cell { + vertical-align: top; + } + + pre.prettyprint { + padding: 0; + border: 0; + white-space: pre-wrap; + } + + .code-cell-content { + width: 100%; + } + + code { + width: 100%; + box-shadow: None; + } + + + .line-number-btn { + height: fit-content; + min-width: 50px; + margin: 0; + } +</style> diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue index 887661e20f30dba96dd787bdd441f2a21225cec7..eb81ab6007d64456db8f9b5081112928da2347a8 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue @@ -15,8 +15,24 @@ @input="validateScore" @change="validateScore" /> + <span> / {{fullScore}}</span> + <v-btn + outline round flat + @click="score = 0" + color="red lighten-1" + class="score-button">0</v-btn> + <v-btn + outline round flat + @click="score = fullScore" + color="blue darken-3" + class="score-button">{{fullScore}}</v-btn> <v-tooltip top> - <v-btn color="success" slot="activator">Submit<v-icon>chevron_right</v-icon></v-btn> + <v-btn + color="success" + slot="activator" + :loading="loading" + @click="submit" + >Submit<v-icon>chevron_right</v-icon></v-btn> <span>Submit and continue</span> </v-tooltip> </v-toolbar> @@ -27,21 +43,48 @@ name: 'annotated-submission-bottom-toolbar', data () { return { - score: 42, - mockMax: 50, scoreError: '' - + } + }, + props: { + fullScore: { + type: Number, + required: true + }, + loading: { + type: Boolean, + required: true + } + }, + computed: { + score: { + get: function () { + return this.$store.getters['submissionNotes/score'] + }, + set: function (score) { + this.$store.commit('submissionNotes/UPDATE_FEEDBACK_SCORE', Number(score)) + } } }, methods: { + emitScoreError (error, duration) { + this.scoreError = error + setTimeout(() => { this.scoreError = '' }, duration) + }, validateScore () { if (this.score < 0) { this.score = 0 - this.scoreError = 'Score must be 0 or greater.' - } else if (this.score > this.mockMax) { - this.score = this.mockMax - this.scoreError = `Score must be less or equal to ${this.mockMax}` + this.emitScoreError('Score must be 0 or greater.', 2000) + } else if (this.score > this.fullScore) { + this.score = this.fullScore + this.emitScoreError(`Score must be less or equal to ${this.fullScore}`, 2000) + } else { + return true } + return false + }, + submit () { + this.$emit('submitFeedback') } } } @@ -61,4 +104,7 @@ .score-alert { max-height: 40px; } + .score-button { + min-width: 0px; + } </style> diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue index 845c66087bfc655afda1515b039fe2d2b99b05c3..fc0ed47213b594e229235a9dbf16cf88c260ce71 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionTopToolbar.vue @@ -9,31 +9,45 @@ max-width="fit-content" v-model="helpDialog" > - <correction-help-card></correction-help-card> + <correction-help-card/> </v-dialog> - <v-spacer></v-spacer> + <span class="title">Student submission</span> + <v-spacer/> <v-tooltip top> - <v-btn icon slot="activator" v-clipboard="submission"><v-icon>content_copy</v-icon></v-btn> - <span>Copy to clipboard</span> + <v-btn + icon slot="activator" + @click="copyToClipboard" + ><v-icon>content_copy</v-icon></v-btn> + <span>{{copyMessage}}</span> </v-tooltip> </v-toolbar> </template> <script> import CorrectionHelpCard from '@/components/submission_notes/CorrectionHelpCard' + import { mapState } from 'vuex' export default { components: {CorrectionHelpCard}, name: 'annotated-submission-top-toolbar', - props: { - submission: { - type: String, - required: true - } - }, data () { return { - helpDialog: false + helpDialog: false, + copyMessage: 'Copy to clipboard' + } + }, + computed: { + ...mapState({ + submission: state => state.submissionNotes.orig.rawSubmission + }) + }, + methods: { + copyToClipboard () { + this.$clipboard(this.submission) + this.copyMessage = 'Copied!' + setTimeout(() => { + this.copyMessage = 'Copy to clipboard' + }, 2500) } } } diff --git a/frontend/src/components/subscriptions/SubscriptionCreation.vue b/frontend/src/components/subscriptions/SubscriptionCreation.vue new file mode 100644 index 0000000000000000000000000000000000000000..acd8d3c48d24345fd23074d4f4d1b964eae5ab7c --- /dev/null +++ b/frontend/src/components/subscriptions/SubscriptionCreation.vue @@ -0,0 +1,93 @@ +<template> + <v-card> + <v-card-text> + <v-card-title> + <h3>Subscribe to {{ title }}</h3> + </v-card-title> + <v-select + v-if="keyItems" + v-model="key" + :items="keyItems" + :label="`Select your desired type of ${title}`" + /> + <v-select + v-model="stage" + return-object + :items="possibleStages" + label="Select your desired feedback stage" + /> + <v-card-actions> + <v-spacer/> + <v-btn + flat + @click="subscribe" + :loading="loading" + >Subscribe</v-btn> + </v-card-actions> + </v-card-text> + </v-card> +</template> + +<script> + export default { + name: 'subscription-creation', + data () { + return { + key: '', + stage: '', + loading: false + } + }, + props: { + title: { + type: String, + required: true + }, + type: { + type: String, + required: true + }, + keyItems: { + type: Array + } + }, + computed: { + possibleStages () { + let stages = [ + { + text: 'Initial Feedback', + type: 'feedback-creation' + }, + { + text: 'Feedback validation', + type: 'feedback-validation' + } + ] + if (this.$store.getters.isReviewer) { + stages.push({ + text: 'Conflict resolution', + type: 'feedback-conflict-resolution' + }) + } + return stages + } + }, + methods: { + subscribe () { + this.loading = true + console.log(this.stage.type) + this.$store.dispatch('subscribeTo', { + type: this.type, + key: this.key.text, + stage: this.stage.type + }).then(() => { + this.loading = false + }) + } + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/components/subscriptions/SubscriptionList.vue b/frontend/src/components/subscriptions/SubscriptionList.vue new file mode 100644 index 0000000000000000000000000000000000000000..55ee2cb14aeca24d99d3432a1c5991b540b1b23c --- /dev/null +++ b/frontend/src/components/subscriptions/SubscriptionList.vue @@ -0,0 +1,139 @@ +<template> + <v-card> + <v-toolbar color="teal"> + <v-toolbar-title> + Your subscriptions + </v-toolbar-title> + </v-toolbar> + <v-list> + <div v-for="item in subscriptionTypes" :key="item.type"> + <v-list-tile> + <v-list-tile-content> + <v-list-tile-title> + {{ item.name }} + </v-list-tile-title> + <v-list-tile-sub-title> + {{ item.description }} + </v-list-tile-sub-title> + </v-list-tile-content> + <v-list-tile-action v-if="subscriptions[item.type].length > 0"> + <v-btn icon @click="item.expanded = !item.expanded"> + <v-icon v-if="item.expanded">keyboard_arrow_up</v-icon> + <v-icon v-else>keyboard_arrow_down</v-icon> + </v-btn> + </v-list-tile-action> + <v-list-tile-action + v-if="!item.hasOwnProperty('permission') || item.permission()" + > + <v-menu + offset-x + :min-width="500" + :close-on-content-click="false" + :nudge-width="200" + v-model="subscriptionCreateMenu[item.type]" + > + <v-btn small flat icon slot="activator"> + <v-icon>add</v-icon> + </v-btn> + <subscription-creation + :title="item.name" + :type="item.type" + :keyItems="possibleKeys[item.type]" + /> + </v-menu> + </v-list-tile-action> + </v-list-tile> + <v-list-tile + v-if="subscriptions[item.type].length > 0 && item.expanded" + v-for="subscription in subscriptions[item.type]" + :key="subscription.pk" + @click="workOnSubscription(subscription)" + > + <v-list-tile-content class="ml-3"> + {{subscription.query_key ? subscription.query_key : 'Active'}} + </v-list-tile-content> + </v-list-tile> + </div> + </v-list> + </v-card> +</template> + +<script> + import {mapGetters, mapActions} from 'vuex' + import SubscriptionCreation from '@/components/subscriptions/SubscriptionCreation' + export default { + components: {SubscriptionCreation}, + name: 'subscription-list', + data () { + return { + subscriptionCreateMenu: {}, + + subscriptionTypes: [ + { + name: 'Random', + type: 'random', + description: 'Random submissions of all types.', + expanded: true + }, + { + name: 'Exam', + type: 'exam', + description: 'Just submissions for the specified exam.', + expanded: true + }, + { + name: 'Submission Type', + type: 'submission_type', + description: 'Just submissions for the specified type.', + expanded: true + }, + { + name: 'Student', + type: 'student', + description: 'The submissions of a student.', + expanded: true, + permission: () => { + return this.$store.getters.isReviewer + } + } + ] + } + }, + computed: { + ...mapGetters({ + subscriptions: 'getSubscriptionsGroupedByType' + }), + possibleKeys () { + const submissionTypes = Object.entries(this.$store.state.submissionTypes).map(([id, type]) => { + return {text: type.name} + }) + return { + submission_type: submissionTypes + } + } + }, + methods: { + ...mapActions([ + 'getSubscriptions', + 'updateSubmissionTypes', + 'getCurrentAssignment' + ]), + workOnSubscription (subscription) { + this.$router.push(`tutor/subscription/${subscription['pk']}`) + } + }, + created () { + if (Object.keys(this.$store.state.subscriptions).length === 0) { + this.getSubscriptions() + } + if (Object.keys(this.$store.state.submissionTypes).length === 0) { + this.updateSubmissionTypes(['name']) + } + } + } +</script> + + +<style scoped> + +</style> diff --git a/frontend/src/main.js b/frontend/src/main.js index a92f0660684df1256f3e73c16d89dddb59e9d021..5cdc9a464716e5fb36589c76c6bb6aa114bd2113 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -5,6 +5,7 @@ import App from './App' import router from './router' import store from './store/store' import Vuetify from 'vuetify' +import Notifications from 'vue-notification' import Cliboard from 'v-clipboard' import 'vuetify/dist/vuetify.min.css' @@ -14,6 +15,7 @@ import 'google-code-prettify/bin/prettify.min.css' Vue.use(Vuetify) Vue.use(Cliboard) +Vue.use(Notifications) Vue.config.productionTip = false diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index a5b5d1b40e1d1d9fa53a1814a9396b15dc060e47..3b7b36216a3b7729ab8b31c493e233554daaa289 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -1,16 +1,16 @@ <template> <v-container fill-height> <v-layout align-center justify-center> - <v-flex text-xs-center md4 lg2> + <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="error" + v-if="msg" color="error" :value="true" transition="fade-transition" - >{{ error }}</v-alert> + >{{ msg }}</v-alert> <p v-else>But I corrected them, sir.</p> <v-form @submit="submit"> @@ -48,21 +48,21 @@ } }, computed: { - ...mapState([ - 'error', - 'userRole' - ]) + ...mapState({ + msg: state => state.authentication.message, + userRole: state => state.authentication.userRole + }) }, methods: { ...mapActions([ - 'getJWTToken', + 'getJWT', 'getExamModule', 'getUserRole', 'getJWTTimeDelta' ]), submit () { this.loading = true - this.getJWTToken(this.credentials).then(() => { + this.getJWT(this.credentials).then(() => { this.getUserRole().then(() => { switch (this.userRole) { case 'Student': this.$router.push('/student') diff --git a/frontend/src/pages/SubmissionCorrectionPage.vue b/frontend/src/pages/SubmissionCorrectionPage.vue deleted file mode 100644 index 386e82100baf48e383030bc1995cc8f491974fc7..0000000000000000000000000000000000000000 --- a/frontend/src/pages/SubmissionCorrectionPage.vue +++ /dev/null @@ -1,81 +0,0 @@ -<template> - <v-layout row wrap> - <v-flex xs12 md6> - <annotated-submission - :rawSubmission="mockSubmission" - :feedback="mockFeedback" - :score="mockScore" - :editable="true" - class="ma-4 autofocus" - /> - </v-flex> - - <v-flex md6> - <submission-type - v-bind="mockSubType" - :reverse="true" - :expandedByDefault="{ Description: false, Solution: true }" - /> - </v-flex> - </v-layout> -</template> - -<script> - import AnnotatedSubmission from '@/components/submission_notes/AnnotatedSubmission' - import SubmissionType from '@/components/SubmissionType' - - export default { - components: { - SubmissionType, - AnnotatedSubmission}, - name: 'submission-correction-page', - data () { - return { - mockSubmission: '//Procedural Programming technique shows creation of Pascal\'s Triangl\n' + - '#include <iostream>\n' + - '#include <iomanip>\n' + - '\n' + - 'using namespace std;\n' + - '\n' + - '\n' + - 'int** comb(int** a , int row , int col)\n' + - '{\n' + - ' int mid = col/2;\n' + - ' //clear matrix\n' + - ' for( int i = 0 ; i < row ; i++)\n' + - ' for( int j = 0 ; j < col ; j++)\n' + - ' a[i][j] = 0;\n' + - ' a[0][mid] = 1; //put 1 in the middle of first row\n' + - ' //build up Pascal\'s Triangle matrix\n' + - ' for( int i = 1 ; i < row ; i++)\n' + - ' {\n' + - ' for( int j = 1 ; j < col - 1 ; j++)\n' + - ' a[i][j] = a[i-1][j-1] + a[i-1][j+1];\n' + - ' }\n' + - ' return a;\n' + - '}\n' + - 'void disp(int** ptr, int row, int col)\n' + - '{\n' + - ' cout << endl << endl;\n' + - ' for ( int i = 0 ; i < row ; i++)\n' + - ' {\n' + - ' for ( int j = 0 ; j < col ; j++)\n', - mockFeedback: { - 1: 'Youre STUPID', - 4: 'Very much so' - }, - mockScore: 42, - mockSubType: { - description: 'Space suits meet with devastation! The vogon dies disconnection like an intelligent dosi.', - solution: 'The volume is a remarkable sinner.', - name: 'Seas stutter from graces like wet clouds.', - fullScore: 42 - } - } - } - } -</script> - -<style scoped> - -</style> diff --git a/frontend/src/pages/SubscriptionWorkPage.vue b/frontend/src/pages/SubscriptionWorkPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..77d7e7635667a4a07efd4c68f8bbad18d1b5491b --- /dev/null +++ b/frontend/src/pages/SubscriptionWorkPage.vue @@ -0,0 +1,90 @@ +<template> + <v-layout + v-if="loaded" + row wrap + > + <v-flex xs12 md6> + <submission-correction + :assignment="currentAssignment" + @feedbackCreated="startWorkOnNextAssignment" + class="ma-4 autofocus" + /> + </v-flex> + + <v-flex md6> + <submission-type + v-bind="submissionType" + :reverse="true" + :expandedByDefault="{ Description: false, Solution: true }" + /> + </v-flex> + </v-layout> +</template> + +<script> + import SubmissionCorrection from '@/components/submission_notes/SubmissionCorrection' + import SubmissionType from '@/components/SubmissionType' + + export default { + components: { + SubmissionType, + SubmissionCorrection}, + name: 'subscription-work-page', + data () { + return { + currentAssignment: {}, + nextAssignment: {}, + loaded: false + } + }, + computed: { + subscription () { + return this.$store.state.subscriptions[this.$route.params['pk']] + }, + submission () { + return this.loaded ? this.currentAssignment.submission : {} + }, + submissionType () { + return this.loaded ? this.$store.state.submissionTypes[this.submission['type_pk']] : {} + } + }, + methods: { + prefetchAssignment () { + this.$store.dispatch('getNextAssignment', this.subscription['pk']).then(assignment => { + this.nextAssignment = assignment + }).catch(err => { + this.nextAssignment = null + if (err.statusCode === 410) { + this.$notify({ + title: 'Last submission here!', + text: 'This will be your last submission to correct for this subscription.', + type: 'warning' + }) + } + }) + }, + startWorkOnNextAssignment () { + this.currentAssignment = this.nextAssignment + this.prefetchAssignment() + } + }, + created () { + if (!this.subscription.currentAssignment) { + this.$store.dispatch('getCurrentAssignment', this.subscription['pk']).then(assignment => { + this.currentAssignment = assignment + this.loaded = true + }).catch(err => { + console.log('Unable to fetch current Assignment. Err:' + err) + }) + this.prefetchAssignment() + } else { + this.currentAssignment = this.subscription.currentAssignment + this.loaded = true + } + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/student/StudentLayout.vue b/frontend/src/pages/student/StudentLayout.vue index c20f0943815c5c3298e3a5e86d6fb7a2655b0902..825661306946e09427019a3ca6c583d8754e93a2 100644 --- a/frontend/src/pages/student/StudentLayout.vue +++ b/frontend/src/pages/student/StudentLayout.vue @@ -20,11 +20,11 @@ <v-divider></v-divider> - <v-card color="grey lighten-2" v-if="!mini"> - <v-card-title primary-title> - <exam-information :exam="exam"></exam-information> - </v-card-title> - </v-card> + <exam-information + :exam="exam" + 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-action> <v-icon v-if="!visited[item.id]">assignment</v-icon> @@ -75,11 +75,15 @@ return this.submissions.map((sub, index) => { return { name: sub.type.name, - id: sub.type.id, - route: `/student/submission/${sub.type.id}` + id: sub.type.pk, + route: `/student/submission/${sub.type.pk}` } }) } } } </script> + +<style scoped> + +</style> diff --git a/frontend/src/pages/student/StudentSubmissionPage.vue b/frontend/src/pages/student/StudentSubmissionPage.vue index 1d13fc4ccad88fb4198b5e72e052678827878f75..45d46bcda72ed80d6eb74bc0466b6c4f0757948a 100644 --- a/frontend/src/pages/student/StudentSubmissionPage.vue +++ b/frontend/src/pages/student/StudentSubmissionPage.vue @@ -1,14 +1,36 @@ <template> <v-container flex> - <v-layout> - <v-flex xs-12 sm-6 md-6 ma-5> - <annotated-submission - :rawSubmission="rawSubmission" - :score="score" - :feedback="{}"> - </annotated-submission> + <v-layout row wrap> + <v-flex lg6 md12 mt-5> + <base-annotated-submission> + <v-toolbar + dense + slot="header" + class="mb-1 elevation-1" + > + <v-btn flat color="info" @click="showFeedback = !showFeedback"> + <div v-if="showFeedback"> Hide Feedback</div> + <div v-else> Show Feedback</div> + </v-btn> + + <v-spacer/> + + <h2>Score: {{score}} / {{submissionType.full_score}}</h2> + </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" + /> + </tr> + </template> + </base-annotated-submission> </v-flex> - <v-flex xs-12 sm-6 md-6> + <v-flex lg6 md12> <submission-type v-bind="submissionType"> </submission-type> @@ -19,30 +41,53 @@ <script> - import { mapState } from 'vuex' - import AnnotatedSubmission from '@/components/submission_notes/AnnotatedSubmission' + import { mapState, mapGetters } from 'vuex' + import AnnotatedSubmission from '@/components/submission_notes/SubmissionCorrection' import SubmissionType from '@/components/SubmissionType' + import BaseAnnotatedSubmission from '@/components/submission_notes/base/BaseAnnotatedSubmission' + import SubmissionLine from '@/components/submission_notes/base/SubmissionLine' + import FeedbackComment from '@/components/submission_notes/base/FeedbackComment' export default { name: 'student-submission-page', - components: {AnnotatedSubmission, SubmissionType}, + components: { + FeedbackComment, + SubmissionLine, + BaseAnnotatedSubmission, + AnnotatedSubmission, + SubmissionType}, + data () { + return { + showFeedback: true + } + }, computed: { id: function () { return this.$route.params.id }, + ...mapGetters([ + 'submission' + ]), ...mapState({ - rawSubmission: function (state) { return state.studentPage.submissionData[this.id].text }, score: function (state) { return state.studentPage.submissionData[this.id].feedback.score }, - submissionType: function (state) { return state.studentPage.submissionData[this.id].type } - // feedback: function (state) { return state.studentPage.submissionData[this.$route.params.id].feedback.text } + submissionType: function (state) { return state.studentPage.submissionData[this.id].type }, + feedback: function (state) { + return state.studentPage.submissionData[this.$route.params.id].feedback.feedback_lines + } }) }, - mounted: function () { - this.$store.commit('SET_VISITED', { index: this.id, visited: true }) - }, - updated: function () { - if (this.id) { - this.$store.commit('SET_VISITED', { index: this.id, visited: true }) + methods: { + onRouteMountOrUpdate (routeId) { + this.$store.commit('SET_VISITED', { index: routeId, visited: true }) + this.$store.commit('SET_RAW_SUBMISSION', + this.$store.state.studentPage.submissionData[this.id].text) } + }, + mounted () { + this.onRouteMountOrUpdate(this.id) + }, + beforeRouteUpdate (to, from, next) { + this.onRouteMountOrUpdate(to.params.id) + next() } } </script> diff --git a/frontend/src/pages/tutor/TutorLayout.vue b/frontend/src/pages/tutor/TutorLayout.vue index b5641050361515af4df61a078b8510628a3521d7..3966da439a62c1e13949700a570cc93ac4b15da8 100644 --- a/frontend/src/pages/tutor/TutorLayout.vue +++ b/frontend/src/pages/tutor/TutorLayout.vue @@ -2,7 +2,7 @@ <base-layout @sidebarMini="mini = $event"> <template slot="header"> - Collapse + Grady </template> <v-list dense slot="sidebar-content"> diff --git a/frontend/src/pages/tutor/TutorStartPage.vue b/frontend/src/pages/tutor/TutorStartPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a98f924261130743f62ecc22cf241c28f96e462 --- /dev/null +++ b/frontend/src/pages/tutor/TutorStartPage.vue @@ -0,0 +1,21 @@ +<template> + <v-flex lg3> + <subscription-list/> + </v-flex> +</template> + +<script> + import SubscriptionList from '@/components/subscriptions/SubscriptionList' + + export default { + components: {SubscriptionList}, + name: 'tutor-start-page', + mounted () { + this.$store.dispatch('updateSubmissionTypes') + } + } +</script> + +<style scoped> + +</style> diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index babc257c19f2cb17155ebb0f15c1f313335216dd..b3769bf03fc43cf44f9c2e52b7ece97b51eea7fa 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,12 +1,13 @@ import Vue from 'vue' import Router from 'vue-router' -import store from '../store/store' +// import store from '@/store/store' import Login from '@/pages/Login' import TutorLayout from '@/pages/tutor/TutorLayout' +import TutorStartPage from '@/pages/tutor/TutorStartPage' import StudentPage from '@/pages/student/StudentPage' import StudentLayout from '@/pages/student/StudentLayout' import StudentSubmissionPage from '@/pages/student/StudentSubmissionPage' -import SubmissionCorrectionPage from '@/pages/SubmissionCorrectionPage' +import SubscriptionWorkPage from '@/pages/SubscriptionWorkPage' import ReviewerPage from '@/pages/reviewer/ReviewerPage' import StudentListOverview from '@/pages/reviewer/StudentListOverview' @@ -38,8 +39,12 @@ const router = new Router({ component: TutorLayout, children: [ { - path: 'assignment/', - component: SubmissionCorrectionPage + path: '', + component: TutorStartPage + }, + { + path: 'subscription/:pk', + component: SubscriptionWorkPage } ] }, @@ -56,21 +61,4 @@ const router = new Router({ ] }) -router.beforeEach((to, from, next) => { - if (to.path === '/' || from.path === '/') { - next() - } else { - const now = Date.now() - if (now - store.state.logInTime > store.state.jwtTimeDelta * 1000) { - store.dispatch('logout').then(() => { - store.commit('API_FAIL', 'You\'ve been logged out due to inactivity') - next('/') - }) - } else { - store.dispatch('refreshJWTToken') - next() - } - } -}) - export default router diff --git a/frontend/src/store/actions.js b/frontend/src/store/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..f162396762c2c7245a36331eca6c1e9ad0192192 --- /dev/null +++ b/frontend/src/store/actions.js @@ -0,0 +1,65 @@ +import {types} from './mutations' +import * as api from '@/api' +import router from '@/router/index' + +const actions = { + async getSubscriptions ({ commit }) { + try { + const subscriptions = await api.fetchSubscriptions() + commit(types.SET_SUBSCRIPTIONS, subscriptions) + } catch (e) { + console.log(e) + } + }, + async subscribeTo ({ commit }, {type, key, stage}) { + try { + const subscription = await api.subscribeTo(type, key, stage) + commit(types.SET_SUBSCRIPTION, subscription) + } catch (e) { + console.log(e) + } + }, + async updateSubmissionTypes ({ commit }, fields) { + try { + const submissionTypes = await api.fetchSubmissionTypes(fields) + submissionTypes.forEach(type => { + commit(types.UPDATE_SUBMISSION_TYPE, type) + }) + } catch (e) { + console.log(e) + } + }, + async getCurrentAssignment ({ commit }, subscriptionPk) { + try { + const assignment = await api.fetchCurrentAssignment(subscriptionPk) + commit(types.UPDATE_ASSIGNMENT, { + assignment, + subscriptionPk, + key: 'currentAssignment' + }) + return assignment + } catch (e) { + console.log(e) + } + }, + async getNextAssignment ({ commit }, subscriptionPk) { + try { + const assignment = await api.fetchNextAssignment(subscriptionPk) + commit(types.UPDATE_ASSIGNMENT, { + assignment, + subscriptionPk, + key: 'nextAssignment' + }) + return assignment + } catch (e) { + console.log(e) + } + }, + logout ({ commit }, message = '') { + commit(types.RESET_STATE) + commit('SET_MESSAGE', message) + router.push('/') + } +} + +export default actions diff --git a/frontend/src/store/api.js b/frontend/src/store/api.js deleted file mode 100644 index c1e523645706a4f2c07c2282906e138186c67d4f..0000000000000000000000000000000000000000 --- a/frontend/src/store/api.js +++ /dev/null @@ -1,8 +0,0 @@ -import axios from 'axios' - -let ax = axios.create({ - baseURL: 'http://localhost:8000/', - headers: {'Authorization': 'JWT ' + sessionStorage.getItem('jwtToken')} -}) - -export default ax diff --git a/frontend/src/store/getters.js b/frontend/src/store/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..e00a18775e08e17dcc568f6b0ab9cf1f59554876 --- /dev/null +++ b/frontend/src/store/getters.js @@ -0,0 +1,25 @@ +const getters = { + getSubscriptionsGroupedByType (state) { + let subscriptions = { + 'random': [], + 'student': [], + 'exam': [], + 'submission_type': [] + } + Object.entries(state.subscriptions).forEach(([id, submission]) => { + subscriptions[submission.query_type].push(submission) + }) + return subscriptions + }, + getSubmission: state => pk => { + return state.submissions[pk] + }, + getFeedback: state => pk => { + return state.feedback[pk] + }, + getSubmissionType: state => pk => { + return state.submissionTypes[pk] + } +} + +export default getters diff --git a/frontend/src/store/lastInteractionPlugin.js b/frontend/src/store/lastInteractionPlugin.js new file mode 100644 index 0000000000000000000000000000000000000000..766a0db22e1920b4e3b02372ceb6f1d04e9416d2 --- /dev/null +++ b/frontend/src/store/lastInteractionPlugin.js @@ -0,0 +1,9 @@ +import {types} from '@/store/mutations' + +export function lastInteraction (store) { + store.subscribe((mutation, state) => { + if (mutation.type !== types.SET_LAST_INTERACTION) { + store.commit(types.SET_LAST_INTERACTION) + } + }) +} diff --git a/frontend/src/store/modules/authentication.js b/frontend/src/store/modules/authentication.js new file mode 100644 index 0000000000000000000000000000000000000000..2b5137251eef0e11bc9376e91ebd25d757ee6ec8 --- /dev/null +++ b/frontend/src/store/modules/authentication.js @@ -0,0 +1,110 @@ +import {fetchJWT, fetchJWTTimeDelta, fetchUserRole, refreshJWT} from '@/api' +import gradySays from '../grady_speak' + +function initialState () { + return { + token: sessionStorage.getItem('token'), + tokenCreationTime: 0, + refreshingToken: false, + username: '', + jwtTimeDelta: 0, + userRole: '', + message: '' + } +} + +const authentication = { + state: { + ...initialState() + }, + getters: { + gradySpeak: () => { + return gradySays[Math.floor(Math.random() * gradySays.length)] + }, + isStudent: state => { + return state.userRole === 'Student' + }, + isTutor: state => { + return state.userRole === 'Tutor' + }, + isReviewer: state => { + return state.userRole === 'Reviewer' + } + }, + mutations: { + 'SET_MESSAGE': function (state, message) { + state.message = message + }, + 'SET_JWT_TOKEN': function (state, token) { + sessionStorage.setItem('token', token) + state.token = token + state.tokenCreationTime = Date.now() + }, + 'SET_JWT_TIME_DELTA': function (state, timeDelta) { + state.jwtTimeDelta = timeDelta + }, + 'SET_USERNAME': function (state, username) { + state.username = username + }, + 'SET_USER_ROLE': function (state, userRole) { + state.userRole = userRole + }, + 'RESET_STATE': function (state) { + sessionStorage.setItem('token', '') + Object.assign(state, initialState()) + }, + 'SET_REFRESHING_TOKEN': function (state, refreshing) { + state.refreshingToken = refreshing + } + }, + actions: { + async getJWT (context, credentials) { + try { + const token = await fetchJWT(credentials) + context.commit('SET_USERNAME', credentials.username) + context.commit('SET_JWT_TOKEN', token) + } catch (error) { + console.log(error) + if (error.response) { + const errorMsg = 'Unable to log in with provided credentials.' + context.commit('SET_MESSAGE', errorMsg) + throw errorMsg + } else { + const errorMsg = 'Cannot reach server.' + context.commit('SET_MESSAGE', errorMsg) + throw errorMsg + } + } + }, + async refreshJWT ({state, commit, dispatch}) { + commit('SET_REFRESHING_TOKEN', true) + try { + const token = await refreshJWT(state.token) + commit('SET_JWT_TOKEN', token) + } catch (err) { + dispatch('logout') + } finally { + commit('SET_REFRESHING_TOKEN', false) + } + }, + async getUserRole ({commit}) { + try { + const userRole = await fetchUserRole() + commit('SET_USER_ROLE', userRole) + } catch (err) { + commit('SET_MESSAGE', "You've been logged out.") + } + }, + async getJWTTimeDelta ({commit}) { + try { + const delta = await fetchJWTTimeDelta() + // multiply by 1000 to convert to ms + commit('SET_JWT_TIME_DELTA', delta * 1000) + } catch (err) { + console.log(err) + } + } + } +} + +export default authentication diff --git a/frontend/src/store/modules/student-page.js b/frontend/src/store/modules/student-page.js index 45a5b34f3d678182a596e039f83d48aaf83d98bd..01da389ac82ba48731525c618183eb459e96af8a 100644 --- a/frontend/src/store/modules/student-page.js +++ b/frontend/src/store/modules/student-page.js @@ -1,4 +1,4 @@ -import ax from '../api' +import {fetchStudentSelfData, fetchStudentSubmissions} from '../../api' const studentPage = { state: { @@ -30,7 +30,7 @@ const studentPage = { */ 'SET_FULL_SUBMISSION_DATA': function (state, submissionData) { state.submissionData = submissionData.reduce((acc, cur, index) => { - acc[cur.type.id] = cur + acc[cur.type.pk] = cur return acc }, {}) }, @@ -43,57 +43,36 @@ const studentPage = { }, actions: { - getStudentData (context) { - context.commit('SET_LOADED', false) - ax.get('api/student-page/').then(response => { - const data = response.data - context.commit('SET_STUDENT_NAME', data.name) - context.commit('SET_EXAM', data.exam) - context.commit('SET_SUBMISSIONS_FOR_LIST', data.submissions) + async getStudentData (context) { + try { + const studentData = await fetchStudentSelfData() + context.commit('SET_STUDENT_NAME', studentData.name) + context.commit('SET_EXAM', studentData.exam) + context.commit('SET_SUBMISSIONS_FOR_LIST', studentData.submissions) context.commit('SET_LOADED', true) - }) + } catch (e) { + this.$notify({ + title: 'API Fail', + text: 'Unable to fetch student data', + type: 'error' + }) + console.log(e) + } }, async getStudentSubmissions (context) { - const response = await ax.get('/api/student-submissions') - context.commit('SET_FULL_SUBMISSION_DATA', response.data) + try { + const submissions = await fetchStudentSubmissions() + context.commit('SET_FULL_SUBMISSION_DATA', submissions) + } catch (e) { + this.$notify({ + title: 'API Fail', + text: 'Unable to fetch student submissions', + type: 'error' + }) + } } } } -// const mockSubmission = '//Procedural Programming technique shows creation of Pascal\'s Triangl\n' + -// '#include <iostream>\n' + -// '#include <iomanip>\n' + -// '\n' + -// 'using namespace std;\n' + -// '\n' + -// '\n' + -// 'int** comb(int** a , int row , int col)\n' + -// '{\n' + -// ' int mid = col/2;\n' + -// ' //clear matrix\n' + -// ' for( int i = 0 ; i < row ; i++)\n' + -// ' for( int j = 0 ; j < col ; j++)\n' + -// ' a[i][j] = 0;\n' + -// ' a[0][mid] = 1; //put 1 in the middle of first row\n' + -// ' //build up Pascal\'s Triangle matrix\n' + -// ' for( int i = 1 ; i < row ; i++)\n' + -// ' {\n' + -// ' for( int j = 1 ; j < col - 1 ; j++)\n' + -// ' a[i][j] = a[i-1][j-1] + a[i-1][j+1];\n' + -// ' }\n' + -// ' return a;\n' + -// '}\n' + -// 'void disp(int** ptr, int row, int col)\n' + -// '{\n' + -// ' cout << endl << endl;\n' + -// ' for ( int i = 0 ; i < row ; i++)\n' + -// ' {\n' + -// ' for ( int j = 0 ; j < col ; j++)\n' - -// const mockFeedback = { -// '1': 'Youre STUPID', -// '4': 'Very much so' -// } - export default studentPage diff --git a/frontend/src/store/modules/submission-notes.js b/frontend/src/store/modules/submission-notes.js index 10a47e24d2471d5d27e39900a0d8002c228f8007..e80dbb7d8389a7328056dfb4788baf58afa43e09 100644 --- a/frontend/src/store/modules/submission-notes.js +++ b/frontend/src/store/modules/submission-notes.js @@ -1,44 +1,85 @@ -// import Vue from 'vue' +import Vue from 'vue' +import * as api from '@/api' -// const submissionNotes = { -// state: { -// rawSubmission: '', -// feedback: {} -// }, -// getters: { -// // reduce the string rawSubmission 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) => { -// acc[index + 1] = cur -// return acc -// }, {}) -// } -// }, -// mutations: { -// 'SET_RAW_SUBMISSION': function (state, submission) { -// state.rawSubmission = mockSubmission -// }, -// 'SET_FEEDBACK': function (state, feedback) { -// state.feedback = feedback -// }, -// 'UPDATE_FEEDBACK': function (state, feedback) { -// Vue.set(state.feedback, feedback.lineIndex, feedback.content) -// } -// }, -// actions: { -// // TODO remove mock data -// getSubmission (context, submissionId) { -// context.commit('SET_RAW_SUBMISSION', mockSubmission) -// }, -// getFeedback (context, feedbackId) { -// context.commit('SET_FEEDBACK', mockFeedback) -// }, -// updateFeedback (context, lineIndex, feedbackContent) { -// context.commit('UPDATE_FEEDBACK', lineIndex, feedbackContent) -// } -// } -// } +function initialState () { + return { + assignment: '', + ui: { + showEditorOnLine: {}, + selectedCommentOnLine: {} + }, + orig: { + rawSubmission: '', + score: null, + feedbackLines: {} + }, + updated: { + score: null, + feedbackLines: {} + } + } +} -// export default submissionNotes +const submissionNotes = { + namespaced: true, + state: { + ...initialState() + }, + getters: { + // reduce the string rawSubmission 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.orig.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 + } + }, + mutations: { + 'SET_RAW_SUBMISSION': function (state, submission) { + state.orig.rawSubmission = submission + }, + 'SET_ORIG_FEEDBACK': function (state, feedback) { + if (feedback) { + state.orig.feedbackLines = feedback['feedback_lines'] ? feedback['feedback_lines'] : {} + state.orig.score = feedback.score + } + }, + 'UPDATE_FEEDBACK_LINE': function (state, feedback) { + Vue.set(state.updated.feedbackLines, feedback.lineNo, feedback.comment) + }, + 'UPDATE_FEEDBACK_SCORE': function (state, score) { + state.updated.score = score + }, + 'DELETE_FEEDBACK_LINE': function (state, lineNo) { + Vue.delete(state.updated.feedbackLines, lineNo) + }, + 'TOGGLE_EDITOR_ON_LINE': function (state, {lineNo, comment}) { + Vue.set(state.ui.selectedCommentOnLine, lineNo, comment) + Vue.set(state.ui.showEditorOnLine, lineNo, !state.ui.showEditorOnLine[lineNo]) + }, + 'RESET_STATE': function (state) { + Object.assign(state, initialState()) + } + }, + actions: { + 'submitFeedback': async function ({state}, assignment) { + let feedback = {} + if (Object.keys(state.updated.feedbackLines).length > 0) { + feedback['feedback_lines'] = state.updated.feedbackLines + } + if (state.orig.score === null && state.updated.score === null) { + throw new Error('You need to give a score.') + } else if (state.updated.score !== null) { + feedback['score'] = state.updated.score + } + return api.submitFeedbackForAssignment(feedback, assignment['pk']) + } + } +} + +export default submissionNotes diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..c7f1c544d327455b6c4509f58b3effc4b319e424 --- /dev/null +++ b/frontend/src/store/mutations.js @@ -0,0 +1,58 @@ +import Vue from 'vue' + +import {initialState} from '@/store/store' + +export const types = { + SET_ASSIGNMENT: 'SET_ASSIGNMENT', + SET_SUBSCRIPTIONS: 'SET_SUBSCRIPTIONS', + SET_SUBSCRIPTION: 'SET_SUBSCRIPTION', + UPDATE_SUBMISSION_TYPE: 'UPDATE_SUBMISSION_TYPE', + UPDATE_ASSIGNMENT: 'UPDATE_ASSIGNMENT', + UPDATE_NEXT_ASSIGNMENT: 'UPDATE_NEXT_ASSIGNMENT', + RESET_STATE: 'RESET_STATE', + SET_LAST_INTERACTION: 'SET_LAST_INTERACTION' +} + +const mutations = { + [types.SET_ASSIGNMENT] (state, assignment) { + Vue.set(state.assignments, assignment.pk, assignment) + }, + [types.SET_SUBSCRIPTIONS] (state, subscriptions) { + state.subscriptions = subscriptions.reduce((acc, curr) => { + acc[curr['pk']] = curr + return acc + }, {}) + }, + [types.SET_SUBSCRIPTION] (state, subscription) { + Vue.set(state.subscriptions, subscription.pk, subscription) + }, + [types.UPDATE_SUBMISSION_TYPE] (state, submissionType) { + const updatedSubmissionType = { + ...state.submissionTypes[submissionType.pk], + ...submissionType + } + Vue.set(state.submissionTypes, submissionType.pk, updatedSubmissionType) + }, + [types.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) + }, + [types.RESET_STATE] (state) { + Object.assign(state, initialState()) + }, + [types.SET_LAST_INTERACTION] (state) { + state.lastAppInteraction = Date.now() + } +} + +export default mutations diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 5b60e7706279674a107aa04d7e88472cb855661f..4e1d15819682fb5acb109cbbc95fc79fe47691c0 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -1,103 +1,53 @@ import Vuex from 'vuex' import Vue from 'vue' -import ax from './api' +import createPersistedState from 'vuex-persistedstate' -import gradySays from './grady_speak' import studentPage from './modules/student-page' +import submissionNotes from './modules/submission-notes' +import authentication from './modules/authentication' + +import actions from './actions' +import getters from './getters' +import mutations from '@/store/mutations' +import {lastInteraction} from '@/store/lastInteractionPlugin' Vue.use(Vuex) +export function initialState () { + return { + lastAppInteraction: Date.now(), + currentTime: Date.now(), + examTypes: {}, + submissionTypes: {}, + submissions: {}, + feedback: {}, + subscriptions: {}, + assignments: {} + } +} + const store = new Vuex.Store({ + // TODO only enable this in dev and not in deployment (use env variable) + strict: true, modules: { - studentPage + authentication, + studentPage, + submissionNotes }, + plugins: [createPersistedState({ + storage: window.sessionStorage, + // authentication.token is manually saved since using it with this plugin caused issues + // when manually reloading the page + paths: Object.keys(initialState()).concat( + ['studentPage', 'submissionNotes', 'authentication.username', 'authentication.userRole', + 'authentication.jwtTimeDelta']) + }), + lastInteraction], + actions, + getters, + mutations, state: { - token: sessionStorage.getItem('jwtToken'), - loggedIn: !!sessionStorage.getItem('jwtToken'), - logInTime: Number(sessionStorage.getItem('logInTime')), - username: sessionStorage.getItem('username'), - jwtTimeDelta: Number(sessionStorage.getItem('jwtTimeDelta')), - userRole: sessionStorage.getItem('userRole'), - error: '' - }, - getters: { - gradySpeak: () => { - return gradySays[Math.floor(Math.random() * gradySays.length)] - }, - isStudent: state => { - return state.userRole === 'Student' - }, - isTutor: state => { - return state.userRole === 'Tutor' - }, - isReviewer: state => { - return state.userRole === 'Reviewer' - } - }, - mutations: { - 'API_FAIL': function (state, error) { - state.error = error - }, - 'SET_JWT_TOKEN': function (state, token) { - state.token = token - state.logInTime = Date.now() - ax.defaults.headers['Authorization'] = 'JWT ' + token - sessionStorage.setItem('jwtToken', token) - sessionStorage.setItem('logInTime', String(state.logInTime)) - }, - 'SET_JWT_TIME_DELTA': function (state, timeDelta) { - state.jwtTimeDelta = timeDelta - sessionStorage.setItem('jwtTimeDelta', timeDelta) - }, - 'LOGIN': function (state, username) { - state.loggedIn = true - state.username = username - sessionStorage.setItem('username', username) - }, - 'LOGOUT': function (state) { - state.loggedIn = false - }, - 'SET_USER_ROLE': function (state, userRole) { - state.userRole = userRole - sessionStorage.setItem('userRole', userRole) - } - }, - actions: { - async getJWTToken (context, credentials) { - try { - const response = await ax.post('api-token-auth/', credentials) - context.commit('LOGIN', credentials.username) - context.commit('SET_JWT_TOKEN', response.data.token) - } catch (error) { - if (error.response) { - const errorMsg = 'Unable to log in with provided credentials.' - context.commit('API_FAIL', errorMsg) - throw errorMsg - } else { - const errorMsg = 'Cannot reach server.' - context.commit('API_FAIL', errorMsg) - throw errorMsg - } - } - }, - refreshJWTToken (context) { - ax.post('/api-token-refresh/', {token: context.state.token}).then(response => { - context.commit('SET_JWT_TOKEN', response.data.token) - }) - }, - getJWTTimeDelta (context) { - ax.get('api/jwt-time-delta/').then(response => { - context.commit('SET_JWT_TIME_DELTA', response.data.timeDelta) - }) - }, - async getUserRole (context) { - const response = await ax.get('api/user-role/') - context.commit('SET_USER_ROLE', response.data.role) - }, - logout (store) { - store.commit('LOGOUT') - store.commit('SET_JWT_TOKEN', '') - } + ...initialState() } }) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1098822b3ab11afca7cf48c304034ec91d5014fb..326369ad893c3735fe86e1c252134901371610c0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1711,6 +1711,10 @@ deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" +deepmerge@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.0.1.tgz#25c1c24f110fb914f80001b925264dd77f3f4312" + defined@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" @@ -5214,6 +5218,10 @@ shelljs@^0.7.5, shelljs@^0.7.6: interpret "^1.0.0" rechoir "^0.6.2" +shvl@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shvl/-/shvl-1.2.0.tgz#5e2de474c68b8430602689a7d35100ad2cb33fec" + signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" @@ -5797,6 +5805,10 @@ vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" +velocity-animate@^1.5.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/velocity-animate/-/velocity-animate-1.5.1.tgz#606837047bab8fbfb59a636d1d82ecc3f7bd71a6" + vendors@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22" @@ -5841,6 +5853,12 @@ vue-loader@^13.3.0: vue-style-loader "^3.0.0" vue-template-es2015-compiler "^1.6.0" +vue-notification@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/vue-notification/-/vue-notification-1.3.6.tgz#f11f825a3d9858ef17f22d4a72e9e6d383d97bbf" + dependencies: + velocity-animate "^1.5.0" + vue-router@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9" @@ -5871,6 +5889,13 @@ vuetify@^0.17.3: version "0.17.3" resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-0.17.3.tgz#66280c5532b12d80c0ce75f4574d1d5a8c2955b9" +vuex-persistedstate@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/vuex-persistedstate/-/vuex-persistedstate-2.4.2.tgz#a8caf63b07ce4bdff6d82b29634c051ead382bf3" + dependencies: + deepmerge "^2.0.1" + shvl "^1.1.1" + vuex@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.0.1.tgz#e761352ebe0af537d4bb755a9b9dc4be3df7efd2" diff --git a/grady/settings/default.py b/grady/settings/default.py index 3be7ad7365ab77bb0dcb8c5aa7095b1a0f4c4e72..47b0df73549d71f62dd11179b167962d25f308d2 100644 --- a/grady/settings/default.py +++ b/grady/settings/default.py @@ -143,7 +143,7 @@ REST_FRAMEWORK = { } JWT_AUTH = { - 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=600), + 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=6000), 'JWT_ALLOW_REFRESH': True, } diff --git a/util/factories.py b/util/factories.py index 5582b6dd15945294218c1e518daad07e121c27c2..655f9720dd57c54380de01d662b59e4c2505b7c5 100644 --- a/util/factories.py +++ b/util/factories.py @@ -76,7 +76,7 @@ class GradyUserFactory: role=role, defaults=kwargs) - if created: + if created or password is not None: password = self.make_password() if password is None else password user.set_password(password) user.save() @@ -128,7 +128,8 @@ def make_students(students=[], **kwargs): return [GradyUserFactory().make_student( username=student['username'], exam=ExamType.objects.get( - module_reference=student['exam']) if 'exam' in student else None + module_reference=student['exam']) if 'exam' in student else None, + password=student.get('password') ) for student in students] @@ -232,6 +233,31 @@ def init_test_instance(): 'exam': 'Test Exam 01', 'password': 'p' }, + { + 'username': 'student03', + 'exam': 'Test Exam 01', + 'password': 'p' + }, + { + 'username': 'student04', + 'exam': 'Test Exam 01', + 'password': 'p' + }, + { + 'username': 'student05', + 'exam': 'Test Exam 01', + 'password': 'p' + }, + { + 'username': 'student06', + 'exam': 'Test Exam 01', + 'password': 'p' + }, + { + 'username': 'student07', + 'exam': 'Test Exam 01', + 'password': 'p' + }, ], 'tutors': [ { @@ -344,5 +370,128 @@ def init_test_instance(): } } }, + + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '01. Sort this or that', + 'user': 'student02', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '02. Merge this or that or maybe even this', + 'user': 'student02', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '03. This one exists for the sole purpose to test', + 'user': 'student02', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '01. Sort this or that', + 'user': 'student03', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '02. Merge this or that or maybe even this', + 'user': 'student03', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '03. This one exists for the sole purpose to test', + 'user': 'student03', + }, + + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '01. Sort this or that', + 'user': 'student04', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '02. Merge this or that or maybe even this', + 'user': 'student04', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '03. This one exists for the sole purpose to test', + 'user': 'student04', + }, + + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '01. Sort this or that', + 'user': 'student05', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '02. Merge this or that or maybe even this', + 'user': 'student05', + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' arrrgh\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '03. This one exists for the sole purpose to test', + 'user': 'student05', + }, ]} )