diff --git a/core/tests/test_access_rights.py b/core/tests/test_access_rights.py index 6e172626ff78f4374547336ebd2399e9c0461b09..9c22248b907e106d7a78448fb011655a04b5bb45 100644 --- a/core/tests/test_access_rights.py +++ b/core/tests/test_access_rights.py @@ -4,7 +4,7 @@ from rest_framework.test import (APIRequestFactory, APITestCase, force_authenticate) from core.views import (ExamApiViewSet, StudentReviewerApiViewSet, - StudentSelfApiViewSet, TutorApiViewSet) + StudentSelfApiView, TutorApiViewSet) from util.factories import GradyUserFactory @@ -21,8 +21,8 @@ class AccessRightsOfStudentAPIViewTests(APITestCase): self.student = self.user_factory.make_student() self.tutor = self.user_factory.make_tutor() self.reviewer = self.user_factory.make_reviewer() - self.request = self.factory.get(reverse('student_page-list')) - self.view = StudentSelfApiViewSet.as_view({'get': 'retrieve'}) + self.request = self.factory.get(reverse('student-page')) + self.view = StudentSelfApiView.as_view() def test_unauthenticated_access_denied(self): response = self.view(self.request) diff --git a/core/tests/test_functional_views.py b/core/tests/test_functional_views.py new file mode 100644 index 0000000000000000000000000000000000000000..f48645b23b3ba0f542a86ba517c1b133b08f1eda --- /dev/null +++ b/core/tests/test_functional_views.py @@ -0,0 +1,33 @@ +from django.urls import reverse +from rest_framework.test import (APIRequestFactory, APITestCase, + force_authenticate) +from core.views import get_user_role +from util.factories import GradyUserFactory + + +class GetUserRoleTest(APITestCase): + @classmethod + def setUpTestData(cls): + cls.factory = APIRequestFactory() + cls.user_factory = GradyUserFactory() + cls.student = cls.user_factory.make_student() + cls.tutor = cls.user_factory.make_tutor() + cls.reviewer = cls.user_factory.make_reviewer() + + def setUp(self): + self.request = self.factory.get(reverse('user-role')) + + def test_get_user_model_returns_student(self): + force_authenticate(self.request, user=self.student.user) + response = get_user_role(self.request) + self.assertEqual(response.data['role'], 'Student') + + def test_get_user_model_returns_tutor(self): + force_authenticate(self.request, user=self.tutor.user) + response = get_user_role(self.request) + self.assertEqual(response.data['role'], 'Tutor') + + def test_get_user_model_returns_reviewer(self): + force_authenticate(self.request, user=self.reviewer.user) + response = get_user_role(self.request) + self.assertEqual(response.data['role'], 'Reviewer') diff --git a/core/tests/test_student_page.py b/core/tests/test_student_page.py index 930163ad8c94ebb906210976e06f6de8253c3b14..099c54cd6d54d4bb4b910d4feee6180395274433 100644 --- a/core/tests/test_student_page.py +++ b/core/tests/test_student_page.py @@ -4,7 +4,7 @@ from rest_framework.test import (APIRequestFactory, APITestCase, from core.models import Reviewer, SubmissionType from core.tests import data_factories -from core.views import StudentSelfApiViewSet +from core.views import StudentSelfApiView class StudentPageTests(APITestCase): @@ -19,8 +19,8 @@ class StudentPageTests(APITestCase): self.student = self.submission.student self.reviewer = Reviewer.objects.create( user=data_factories.make_user(username='reviewer')) - self.request = self.factory.get(reverse('student_page-list')) - self.view = StudentSelfApiViewSet.as_view({'get': 'retrieve'}) + self.request = self.factory.get(reverse('student-page')) + self.view = StudentSelfApiView.as_view() force_authenticate(self.request, user=self.student.user) self.response = self.view(self.request) diff --git a/core/urls.py b/core/urls.py index 845578227821504d6eb67111ecf6ea7c33593140..d0e251b70cbc95adbd26041e484dcc9b643c1c4e 100644 --- a/core/urls.py +++ b/core/urls.py @@ -12,11 +12,17 @@ router.register(r'student', views.StudentReviewerApiViewSet) router.register(r'examtype', views.ExamApiViewSet) router.register(r'submissiontype', views.SubmissionTypeApiView) router.register(r'tutor', views.TutorApiViewSet) -router.register(r'student-page', views.StudentSelfApiViewSet, - base_name='student_page') + +# regular views that are not viewsets +regular_views_urlpatterns = [ + url(r'student-page', views.StudentSelfApiView.as_view(), name='student-page'), + url(r'user-role', views.get_user_role, name='user-role'), + url(r'jwt-time-delta', views.get_jwt_expiration_delta, name='jwt-time-delta') +] urlpatterns = [ url(r'^api/', include(router.urls)), + url(r'^api/', include(regular_views_urlpatterns)), url(r'^api-token-auth/', obtain_jwt_token), url(r'^api-token-refresh', refresh_jwt_token), url(r'^$', TemplateView.as_view(template_name='index.html')), diff --git a/core/views.py b/core/views.py index 563331875203a5312d98bbc74e4e5e5afa15ee40..32bd7f1263e3e4bedb6719e171846e7b356d1da5 100644 --- a/core/views.py +++ b/core/views.py @@ -1,7 +1,10 @@ """ All API views that are used to retrieve data from the database. They can be categorized by the permissions they require. All views require a user to be authenticated and most are only accessible by one user group """ -from rest_framework import mixins, viewsets +from django.conf import settings +from rest_framework import mixins, viewsets, generics +from rest_framework.decorators import api_view +from rest_framework.response import Response from core.models import ExamType, Student, SubmissionType, Tutor from core.permissions import IsReviewer, IsStudent @@ -10,10 +13,19 @@ from core.serializers import (ExamSerializer, StudentSerializer, SubmissionTypeSerializer, TutorSerializer) -class StudentSelfApiViewSet(viewsets.ReadOnlyModelViewSet): +@api_view() +def get_jwt_expiration_delta(request): + return Response({'timeDelta': settings.JWT_AUTH['JWT_EXPIRATION_DELTA']}) + + +@api_view() +def get_user_role(request): + return Response({'role': type(request.user.get_associated_user()).__name__}) + + +class StudentSelfApiView(generics.RetrieveAPIView): """ Gets all data that belongs to one student """ permission_classes = (IsStudent,) - queryset = Student.objects.all() serializer_class = StudentSerializer def get_object(self) -> Student: diff --git a/frontend/config/index.js b/frontend/config/index.js index 2c458f66802f5f8d206f4f136a0b5d52b59b4c21..91cda89ee4dffe53254ba613294b6df8b9d1bc4b 100644 --- a/frontend/config/index.js +++ b/frontend/config/index.js @@ -17,7 +17,7 @@ module.exports = { // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin - productionGzip: false, + productionGzip: true, productionGzipExtensions: ['js', 'css'], // Run the build command with an extra argument to // View the bundle analyzer report after build finishes: diff --git a/frontend/index.html b/frontend/index.html index fb63c5378340aee23aac55a9168ec7067cb63066..c2b9135d7c4243df5b2e5ae26ecb8616aeae41f8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ <html> <head> <meta charset="utf-8"> - <title>frontend</title> + <title>Grady</title> </head> <body> <div id="app"></div> diff --git a/frontend/package.json b/frontend/package.json index 9a8b2be5838dca92a043717b4fb8e678d35a8756..9f4245d5f207a1c6fca82b536c426d6f8460c366 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "axios": "^0.17.0", + "google-code-prettify": "^1.0.5", + "material-design-icons": "^3.0.1", "vue": "^2.5.2", "vue-router": "^3.0.1", "vuetify": "^0.17.3", @@ -31,6 +33,7 @@ "babel-register": "^6.22.0", "chai": "^4.1.2", "chalk": "^2.0.1", + "compression-webpack-plugin": "^1.0.1", "connect-history-api-fallback": "^1.3.0", "copy-webpack-plugin": "^4.0.1", "cross-env": "^5.0.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 64fcd76cf6a10610db451677e80912d837ffaeae..47893229c88166fe70838840516122f9687ae87a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,11 +11,8 @@ name: 'app', components: { } -} + } </script> <style> -#app { - -} </style> diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png deleted file mode 100644 index f3d2503fc2a44b5053b0837ebea6e87a2d339a43..0000000000000000000000000000000000000000 Binary files a/frontend/src/assets/logo.png and /dev/null differ diff --git a/frontend/src/components/Login.vue b/frontend/src/components/Login.vue index fddc17eecc103aa5bac87342fbd1e78fb165eca7..3d5f2336a0788e1554d7bf76c28ec7c1a727f335 100644 --- a/frontend/src/components/Login.vue +++ b/frontend/src/components/Login.vue @@ -26,7 +26,7 @@ type="password" required ></v-text-field> - <v-btn type="submit" color="primary">Access</v-btn> + <v-btn :loading="loading" type="submit" color="primary">Access</v-btn> </v-form> </v-flex> </v-layout> @@ -35,6 +35,7 @@ <script> + import {mapActions, mapState} from 'vuex' export default { name: 'grady-login', data () { @@ -43,16 +44,30 @@ username: '', password: '' }, - error: '' + loading: false } }, + computed: { + ...mapState([ + 'error' + ]) + }, methods: { + ...mapActions([ + 'getJWTToken', + 'getExamModule', + 'getUserRole', + 'getJWTTimeDelta' + ]), submit () { - this.$store.dispatch('getJWTToken', this.credentials).then(response => { - this.$router.push('/reviewer/') - }).catch(_ => { - this.error = this.$store.state.error - }) + this.loading = true + this.getJWTToken(this.credentials).then(() => { + this.loading = false + this.getExamModule() + this.getUserRole() + this.getJWTTimeDelta() + this.$router.push('/student/') + }).catch(() => { this.loading = false }) } } } diff --git a/frontend/src/components/base/BaseLayout.vue b/frontend/src/components/base/BaseLayout.vue new file mode 100644 index 0000000000000000000000000000000000000000..b3c9950c5ca538b33868c9895386df4fbfb0ae64 --- /dev/null +++ b/frontend/src/components/base/BaseLayout.vue @@ -0,0 +1,91 @@ +<template> + <div> + <v-navigation-drawer + fixed + clipped + app + permanent + :mini-variant.sync="mini" + > + <v-toolbar flat> + <v-list> + <v-list-tile> + <v-list-tile-action v-if="mini"> + <v-btn icon @click.native.stop="mini = !mini"> + <v-icon>chevron_right</v-icon> + </v-btn> + </v-list-tile-action> + <v-list-tile-content class="title"> + {{ examInstance }} + </v-list-tile-content> + <v-list-tile-action> + <v-btn icon @click.native.stop="mini = !mini"> + <v-icon>chevron_left</v-icon> + </v-btn> + </v-list-tile-action> + </v-list-tile> + </v-list> + </v-toolbar> + <slot name="navigation"></slot> + </v-navigation-drawer> + <v-toolbar + app + clipped-left + fixed + dark + color="indigo darken-4" + class="grady-toolbar" + > + <v-toolbar-title> + <v-avatar> + <img src="../../assets/brand.png"> + </v-avatar> + </v-toolbar-title> + <span class="pl-2 grady-speak">{{ gradySpeak }}</span> + <div class="toolbar-content"> + <span>{{ userRole }} | {{ username }}</span> + </div> + <v-btn color="blue darken-1" to="/" @click.native="logout">Logout</v-btn> + </v-toolbar> + <v-content> + <slot></slot> + </v-content> + </div> +</template> + +<script> + import { mapActions, mapGetters, mapState } from 'vuex' + export default { + name: 'base-layout', + data () { + return { + mini: false + } + }, + computed: { + ...mapGetters([ + 'gradySpeak' + ]), + ...mapState([ + 'examInstance', + 'username', + 'userRole' + ]) + }, + methods: { + ...mapActions([ + 'logout' + ]) + } + } +</script> + +<style scoped> + .toolbar-content { + margin-left: auto; + } + + .grady-toolbar { + font-weight: bold; + } +</style> diff --git a/frontend/src/components/student/StudentLayout.vue b/frontend/src/components/student/StudentLayout.vue new file mode 100644 index 0000000000000000000000000000000000000000..bc313774717da683f1ba8a3fdac6eda1107546ab --- /dev/null +++ b/frontend/src/components/student/StudentLayout.vue @@ -0,0 +1,62 @@ +<template> + <base-layout> + <v-list dense slot="navigation"> + <v-list-tile exact v-for="(item, i) in generalNavItems" :key="i" :to="item.route"> + <v-list-tile-action> + <v-icon>{{ item.icon }}</v-icon> + </v-list-tile-action> + <v-list-tile-content> + <v-list-tile-title> + {{ item.name }} + </v-list-tile-title> + </v-list-tile-content> + </v-list-tile> + <v-divider></v-divider> + <v-list-tile exact v-for="(item, i) in submissionNavItems" :key="i" :to="item.route"> + <v-list-tile-action> + <v-icon>assignment</v-icon> + </v-list-tile-action> + <v-list-tile-content> + <v-list-tile-title> + {{ item.name }} + </v-list-tile-title> + </v-list-tile-content> + </v-list-tile> + </v-list> + <router-view></router-view> + </base-layout> +</template> + +<script> + import BaseLayout from '../base/BaseLayout' + export default { + components: {BaseLayout}, + name: 'student-layout', + data () { + return { + generalNavItems: [ + { + name: 'Overview', + icon: 'home', + route: '/student/' + }, + { + name: 'Statistics', + icon: 'show_chart', + route: '/student/' + } + ] + } + }, + computed: { + submissionNavItems: function () { + return this.$store.state.studentPage.submissions.map((sub, index) => { + return { + name: sub.type, + route: `/student/submission/${index}` + } + }) + } + } + } +</script> diff --git a/frontend/src/components/student/StudentNav.vue b/frontend/src/components/student/StudentNav.vue deleted file mode 100644 index 3b2484e9d207c8f3e87b8ff9d67eaaec93a1e4d9..0000000000000000000000000000000000000000 --- a/frontend/src/components/student/StudentNav.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> - <v-navbar toggleable="md" type="light" variant="light"> - <v-navbar-toggle target="nav_collapse"></v-navbar-toggle> - - <v-navbar-brand> - <img src="../../assets/brand.png" width="30" class="d-inline-block align-top"> - Grady - </v-navbar-brand> - - <v-collapse is-nav id="nav_collapse"> - - <v-navbar-nav id="nav-left"> - <v-nav-item class="active" href="#">Results</v-nav-item> - <v-nav-item href="#">Statistics</v-nav-item> - </v-navbar-nav> - - <!-- Right aligned nav items --> - <v-navbar-nav class="ml-auto"> - <v-nav-item>{{ this.$store.state.username }}</v-nav-item> - <router-link to="/"> - <v-button class="btn-dark" @click="logout()" >Signout</v-button> - </router-link> - </v-navbar-nav> - </v-collapse> - </v-navbar> -</template> - - -<script> - export default { - name: 'grady-nav', - methods: { - logout () { - this.$store.dispatch('logout') - } - } - } -</script> diff --git a/frontend/src/components/student/StudentPage.vue b/frontend/src/components/student/StudentPage.vue index b1e9249692fde918bbbfb6f48106301ed3fb62ef..eb1d85adf06ff1d24c008cbc2e7e8a905cc88761 100644 --- a/frontend/src/components/student/StudentPage.vue +++ b/frontend/src/components/student/StudentPage.vue @@ -1,25 +1,22 @@ <template> - <div> - <grady-nav></grady-nav> - <div class="container-fluid"> - <div class="row justify-content-center my-3"> - <div class="col-md-3"> - <h2 class="my-5">Exam Overview</h2> + <v-container fluid> + <v-layout justify center> + <v-flex md3> + <h2>Exam Overview</h2> <exam-information v-if="doneLoading" :exam="exam"></exam-information> - </div> - <div class="col-md-6 offset-md-1" v-if="doneLoading"> - <h2 class="my-5">Submissions of {{ this.studentData.name }}</h2> + </v-flex> + <v-flex md7 offset-md1 v-if="doneLoading"> + <h2>Submissions of {{ studentName }}</h2> <submission-list :submissions="submissions"></submission-list> - </div> - </div> - </div> - </div> + </v-flex> + </v-layout> + </v-container> </template> <script> - import ax from '@/store/api' - import GradyNav from './StudentNav.vue' + import {mapState} from 'vuex' + import StudentLayout from './StudentLayout.vue' import SubmissionList from './SubmissionList.vue' import ExamInformation from './ExamInformation.vue' @@ -27,28 +24,22 @@ components: { ExamInformation, SubmissionList, - GradyNav}, + StudentLayout}, name: 'student-page', data () { return { - studentData: {}, doneLoading: false } }, created: function () { - this.doneLoading = false - ax.get('api/student/').then(response => { - this.studentData = response.data - this.doneLoading = true - }) + this.$store.dispatch('getStudentData').then(() => { this.doneLoading = true }) }, computed: { - submissions () { - return this.studentData.submissions - }, - exam () { - return this.studentData.exam - } + ...mapState({ + studentName: state => state.studentPage.studentName, + exam: state => state.studentPage.exam, + submissions: state => state.studentPage.submissions + }) } } </script> diff --git a/frontend/src/components/student/SubmissionDetail.vue b/frontend/src/components/student/SubmissionDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..156a973761b05d60fe61d8cd87be824f04e84153 --- /dev/null +++ b/frontend/src/components/student/SubmissionDetail.vue @@ -0,0 +1,17 @@ +<template> + <v-layout> + + <annotated-submission class="ma-3"></annotated-submission> + </v-layout> +</template> + + +<script> + import AnnotatedSubmission from '../submission_notes/AnnotatedSubmission' + export default { + components: { + AnnotatedSubmission + }, + name: 'submission-detail' + } +</script> diff --git a/frontend/src/components/student/SubmissionList.vue b/frontend/src/components/student/SubmissionList.vue index c39e745fc152cca06a50428cc0f9fe595b858230..4db72e636c0a40abe094db94accb9b64ab4bfb9e 100644 --- a/frontend/src/components/student/SubmissionList.vue +++ b/frontend/src/components/student/SubmissionList.vue @@ -1,9 +1,20 @@ <template> <div class="row my-2 justify-content-center"> - <b-table hover :items="submissions" :fields="fields"></b-table> - <div class="alert alert-info"> - You reached <b>{{ sumScore }}</b> of <b>{{ sumFullScore }}</b> possible points( {{ pointRatio }}% ). - </div> + <v-data-table + hide-actions + :headers="headers" + :items="submissions" + > + <template slot="items" slot-scope="props"> + <td>{{ props.item.type }}</td> + <td class="text-xs-right">{{ props.item.score }}</td> + <td class="text-xs-right">{{ props.item.full_score }}</td> + <td class="text-xs-right"><v-btn :to="`submission/${props.index}`" color="red">View</v-btn></td> + </template> + </v-data-table> + <v-alert color="info" value="true"> + You reached <b>{{ sumScore }}</b> of <b>{{ sumFullScore }}</b> possible points ( {{ pointRatio }}% ). + </v-alert> </div> </template> @@ -13,6 +24,22 @@ name: 'submission-list', data () { return { + headers: [ + { + text: 'Task', + align: 'left', + value: 'type' + }, + { + text: 'Score', + value: 'score' + }, + { + text: 'Maximum Score', + value: 'full_score' + } + ], + fields: [ { key: 'type', sortable: true }, { key: 'score', label: 'Score', sortable: true }, diff --git a/frontend/src/components/submission_notes/AnnotatedSubmission.vue b/frontend/src/components/submission_notes/AnnotatedSubmission.vue new file mode 100644 index 0000000000000000000000000000000000000000..973078dab349056080ba95636747214a77b66799 --- /dev/null +++ b/frontend/src/components/submission_notes/AnnotatedSubmission.vue @@ -0,0 +1,103 @@ +<template> + <table> + <tr v-for="(code, index) in submission" :key="index"> + <td class="line-number-cell"> + <!--<v-tooltip left close-delay="20" color="transparent" content-class="comment-icon">--> + <v-btn block class="line-number-btn" slot="activator" @click="toggleEditorOnLine(index)">{{ index }}</v-btn> + <!--<v-icon small color="indigo accent-3" class="comment-icon">comment</v-icon>--> + <!--</v-tooltip>--> + </td> + <td> + <pre class="prettyprint"><code class="lang-c"> {{ code }}</code></pre> + <feedback-comment + v-if="feedback[index] && !showEditorOnLine[index]" + @click="toggleEditorOnLine(index)">{{ feedback[index] }} + </feedback-comment> + <comment-form + v-if="showEditorOnLine[index]" + @collapseFeedbackForm="showEditorOnLine[index] = false" + :feedback="feedback[index]" + :index="index"> + </comment-form> + </td> + </tr> + </table> +</template> + + +<script> + import {mapGetters, mapState} from 'vuex' + import CommentForm from '@/components/submission_notes/FeedbackForm.vue' + import FeedbackComment from '@/components/submission_notes/FeedbackComment.vue' + + export default { + components: { + FeedbackComment, + CommentForm}, + name: 'annotated-submission', + beforeCreate () { + this.$store.dispatch('getFeedback', 0) + this.$store.dispatch('getSubmission', 0) + }, + computed: { + ...mapState({ + feedback: state => state.submissionNotes.feedback + }), + ...mapGetters(['submission']) + }, + data: function () { + return { + showEditorOnLine: { } + } + }, + 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; + } + + td { + /*white-space: nowrap;*/ + /*border: 1px solid green;*/ + } + + .line-number-cell { + /*padding-left: 50px;*/ + 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; + } + + .comment-icon { + border: 0; + } + +</style> diff --git a/frontend/src/components/submission_notes/FeedbackComment.vue b/frontend/src/components/submission_notes/FeedbackComment.vue new file mode 100644 index 0000000000000000000000000000000000000000..a63b4de6a32267608763b9f999f5088adf462e71 --- /dev/null +++ b/frontend/src/components/submission_notes/FeedbackComment.vue @@ -0,0 +1,54 @@ +<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: -25px; /* 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: 5px; + border: 5px solid #3D8FC1; + } + + .body .message { + font-family: Roboto, sans-serif; + min-height: 30px; + border-radius: 3px; + font-size: 14px; + line-height: 1.5; + } +</style> diff --git a/frontend/src/components/submission_notes/FeedbackForm.vue b/frontend/src/components/submission_notes/FeedbackForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..7813c0be9323b110ad7a19518de65c1f35a7fa60 --- /dev/null +++ b/frontend/src/components/submission_notes/FeedbackForm.vue @@ -0,0 +1,54 @@ +<template> + <div> + <v-text-field + name="feedback-input" + label="Please provide your feedback here" + v-model="current_feedback" + @keyup.enter.ctrl.exact="submitFeedback" + @keyup.esc="collapseTextField" + rows="2" + textarea + autofocus + auto-grow + hide-details + ></v-text-field> + <v-btn color="success" @click="submitFeedback">Submit</v-btn> + <v-btn @click="discardFeedback">Discard changes</v-btn> + </div> +</template> + + +<script> + export default { + name: 'comment-form', + props: ['feedback', 'index'], + data () { + return { + current_feedback: this.feedback + } + }, + methods: { + + collapseTextField () { + this.$emit('collapseFeedbackForm') + }, + submitFeedback () { + this.$store.dispatch('updateFeedback', { + lineIndex: this.index, + content: this.current_feedback + }) + this.collapseTextField() + }, + discardFeedback () { + this.current_feedback = this.feedback + } + } + } +</script> + + +<style scoped> + v-text-field { + padding-top: 0px; + } +</style> diff --git a/frontend/src/main.js b/frontend/src/main.js index 997422b82770d3b595a39127ea98f36215f9c98c..d7cf28b7ac93b39520f3c04233df92b88fc04f8e 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -7,6 +7,9 @@ import store from './store/store' import Vuetify from 'vuetify' import 'vuetify/dist/vuetify.min.css' +import 'material-design-icons/iconfont/material-icons.css' +import 'google-code-prettify/bin/prettify.min' +import 'google-code-prettify/bin/prettify.min.css' Vue.use(Vuetify) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index efb8236906d71e2dc99b1f2f5453851413f6988a..849fbf66c4d47a38992f6ea97e5dcf25776d7966 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,13 +1,18 @@ import Vue from 'vue' import Router from 'vue-router' +import store from '../store/store' import Login from '@/components/Login' import StudentPage from '@/components/student/StudentPage' +import StudentLayout from '@/components/student/StudentLayout' +import SubmissionDetail from '@/components/student/SubmissionDetail' import ReviewerPage from '@/components/reviewer/ReviewerPage' import StudentListOverview from '@/components/reviewer/StudentListOverview' +import BaseLayout from '@/components/base/BaseLayout' +import AnnotatedSubmission from '@/components/submission_notes/AnnotatedSubmission' Vue.use(Router) -export default new Router({ +const router = new Router({ routes: [ { path: '/', @@ -16,8 +21,18 @@ export default new Router({ }, { path: '/student/', - name: 'student-page', - component: StudentPage + component: StudentLayout, + children: [ + { + path: '', + component: StudentPage + }, + { + path: 'submission/:id', + component: SubmissionDetail + } + ] + }, { path: '/reviewer/', @@ -28,6 +43,37 @@ export default new Router({ path: 'reviewer/student-overview/', name: 'student-overview', component: StudentListOverview + }, + { + path: '/base/', + name: 'base-layout', + component: BaseLayout + }, + { + path: '/notes/', + name: 'annotated-submission', + component: AnnotatedSubmission } ] }) + +router.beforeEach((to, from, next) => { + if (to.path === '/') { + next() + } else { + const now = new Date() + if (now - store.state.logInTime > store.state.jwtTimeDelta * 1000) { + console.log(now) + console.log(store.state.logInTime) + 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/api.js b/frontend/src/store/api.js index 368c09c86195ded2f5c97b1199a26e37c4184190..c1e523645706a4f2c07c2282906e138186c67d4f 100644 --- a/frontend/src/store/api.js +++ b/frontend/src/store/api.js @@ -1,7 +1,8 @@ import axios from 'axios' -var ax = axios.create({ - baseURL: 'http://localhost:8000/' +let ax = axios.create({ + baseURL: 'http://localhost:8000/', + headers: {'Authorization': 'JWT ' + sessionStorage.getItem('jwtToken')} }) export default ax diff --git a/frontend/src/store/grady_speak.js b/frontend/src/store/grady_speak.js new file mode 100644 index 0000000000000000000000000000000000000000..6ae7abba064d4c3f07922ad004073bea8baacbfc --- /dev/null +++ b/frontend/src/store/grady_speak.js @@ -0,0 +1,25 @@ +const gradySays = [ + 'Now let\'s see if we can improve this with a little water, sir.', + 'Won\'t keep you a moment, sir.', + 'Grady, sir. Delbert Grady.', + 'Yes, sir.', + 'That\'s right, sir.', + 'Why no, sir. I don\'t believe so.', + 'Ah ha, it\'s coming off now, sir.', + 'Why no, sir. I don\'t believe so.', + 'Yes, sir. I have a wife and two daughters, sir.', + 'Oh, they\'re somewhere around. I\'m not quite sure at the moment, sir.', + 'That\'s strange, sir. I don\'t have any recollection of that at all.', + 'I\'m sorry to differ with you, sir, but you are the caretaker.', + 'You have always been the caretaker, I should know, sir.', + 'I\'ve always been here.', + 'Indeed, he is, Mr. Torrance. A very willful boy. ', + 'A rather naughty boy, if I may be so bold, sir.', + 'Perhaps they need a good talking to, if you don\'t mind my saying so. Perhaps a bit more.', + 'My girls, sir, they didn\'t care for the Overlook at first.', + 'One of them actually stole a packet of matches and tried to burn it down.', + 'But I corrected them, sir.', + 'And when my wife tried to prevent me from doing my duty... I corrected her.' +] + +export default gradySays diff --git a/frontend/src/store/modules/student-page.js b/frontend/src/store/modules/student-page.js new file mode 100644 index 0000000000000000000000000000000000000000..8d593808f718bc8b4814d0789546439c7950619c --- /dev/null +++ b/frontend/src/store/modules/student-page.js @@ -0,0 +1,36 @@ +import ax from '../api' + +const studentPage = { + state: { + studentName: '', + exam: {}, + submissionTypes: [], + submissions: [] + }, + mutations: { + 'SET_STUDENT_NAME': function (state, name) { + state.studentName = name + }, + 'SET_EXAM': function (state, exam) { + state.exam = exam + }, + 'SET_SUBMISSION_TYPES': function (state, submissionTypes) { + state.submissionTypes = submissionTypes + }, + 'SET_SUBMISSIONS': function (state, submissions) { + state.submissions = submissions + } + }, + actions: { + getStudentData (context) { + 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', data.submissions) + }) + } + } +} + +export default studentPage diff --git a/frontend/src/store/modules/submission-notes.js b/frontend/src/store/modules/submission-notes.js new file mode 100644 index 0000000000000000000000000000000000000000..cde09fa0c39e2f4a38869dc8746aee0c3be86628 --- /dev/null +++ b/frontend/src/store/modules/submission-notes.js @@ -0,0 +1,76 @@ +import Vue from 'vue' + +const mockSubmission = '//Procedural Programming technique shows creation of Pascal\'s Triangl\n' + + '#include <iostream>\n' + + '#include <iomanip>\n' + + 'using namespace std;\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' +} + +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) + } + } +} + +export default submissionNotes diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 3eb032bf8effaf4fb5e4e2e69237d1c74fb588b7..7d99d56b21ba722b6ed7343127b593cc7d88c29a 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -2,37 +2,69 @@ import Vuex from 'vuex' import Vue from 'vue' import ax from './api' +import gradySays from './grady_speak' +import submissionNotes from './modules/submission-notes' +import studentPage from './modules/student-page' + Vue.use(Vuex) const store = new Vuex.Store({ + modules: { + submissionNotes, + studentPage + }, state: { - token: '', - loggedIn: false, - username: '', - error: '' + token: sessionStorage.getItem('jwtToken'), + loggedIn: !!sessionStorage.getItem('jwtToken'), + logInTime: sessionStorage.getItem('logInTime'), + username: sessionStorage.getItem('username'), + jwtTimeDelta: sessionStorage.getItem('jwtTimeDelta'), + userRole: sessionStorage.getItem('userRole'), + error: '', + examInstance: '' + }, + getters: { + gradySpeak: () => { + return gradySays[Math.floor(Math.random() * gradySays.length)] + } }, mutations: { 'API_FAIL': function (state, error) { state.error = error }, - 'LOGIN': function (state, creds) { - state.token = creds.token + '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', 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 = creds.username + state.username = username + sessionStorage.setItem('username', username) }, 'LOGOUT': function (state) { - state.token = '' state.loggedIn = false + }, + 'SET_USER_ROLE': function (state, userRole) { + state.userRole = userRole + sessionStorage.setItem('userRole', userRole) + }, + 'SET_EXAM_INSTANCE': function (state, examInstance) { + state.examInstance = examInstance } }, actions: { async getJWTToken (context, credentials) { try { const response = await ax.post('api-token-auth/', credentials) - context.commit('LOGIN', { - token: response.data.token, - username: credentials.username - }) + 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.' @@ -45,8 +77,25 @@ const store = new Vuex.Store({ } } }, + 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) + }) + }, + getUserRole (context) { + ax.get('api/user-role/').then(response => context.commit('SET_USER_ROLE', response.data.role)) + }, + getExamModule (context) { + ax.get('api/exam-module/').then(response => context.commit('SET_EXAM_INSTANCE', response.data.exam)) + }, logout (store) { store.commit('LOGOUT') + store.commit('SET_JWT_TOKEN', '') } } }) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7fac4471d3aef38b2a829c2ace79c724663298f8..1e43569c96644d2375e6b23a0c94ead389528e24 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -228,6 +228,12 @@ async@1.x, async@^1.4.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" +async@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/async/-/async-2.4.1.tgz#62a56b279c98a11d0987096a01cc3eeb8eb7bbd7" + dependencies: + lodash "^4.14.0" + async@^2.1.2, async@^2.4.1: version "2.6.0" resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4" @@ -1355,6 +1361,13 @@ component-inherit@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" +compression-webpack-plugin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-1.0.1.tgz#7f0a2af9f642b4f87b5989516a3b9e9b41bb4b3f" + dependencies: + async "2.4.1" + webpack-sources "^1.0.1" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2682,6 +2695,10 @@ globby@^5.0.0: pify "^2.0.0" pinkie-promise "^2.0.0" +google-code-prettify@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/google-code-prettify/-/google-code-prettify-1.0.5.tgz#9f477f224dbfa62372e5ef803a7e157410400084" + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -3704,6 +3721,10 @@ map-obj@^1.0.0, map-obj@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" +material-design-icons@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/material-design-icons/-/material-design-icons-3.0.1.tgz#9a71c48747218ebca51e51a66da682038cdcb7bf" + math-expression-evaluator@^1.2.14: version "1.2.17" resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"