Skip to content
Snippets Groups Projects
Commit 956b75df authored by robinwilliam.hundt's avatar robinwilliam.hundt
Browse files

Tutor Layout and SubmissioCorrectionPage

Restructured front end code into components and pages.
Components should be as dumb and generic as possible. Pages should dispatch actions, pass props to components etc,.
Added more serializers and views for student-page
Added information which submissions have been viewed

Added v-clipboard dependency run yarn install
parent 15b16f82
No related branches found
No related tags found
1 merge request!34Resolve "submission-correction-page-tutor"
Pipeline #
Showing
with 491 additions and 119 deletions
......@@ -22,7 +22,7 @@ class IsUserGenericPermission(permissions.BasePermission):
)
user = request.user
is_authorized = user.is_authenticated() and any(isinstance(
is_authorized = user.is_authenticated and any(isinstance(
user.get_associated_user(), models) for models in self.models)
if not is_authorized:
......
......@@ -4,7 +4,7 @@ from drf_dynamic_fields import DynamicFieldsMixin
from rest_framework import serializers
from core.models import (ExamType, Feedback, Student, Submission,
SubmissionType, Tutor)
SubmissionType, Test, Tutor)
from util.factories import GradyUserFactory
log = logging.getLogger(__name__)
......@@ -13,7 +13,19 @@ user_factory = GradyUserFactory()
class DynamicFieldsModelSerializer(DynamicFieldsMixin,
serializers.ModelSerializer):
pass
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop('fields', None)
# Instantiate the superclass normally
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields.keys())
for field_name in existing - allowed:
self.fields.pop(field_name)
class ExamSerializer(DynamicFieldsModelSerializer):
......@@ -31,35 +43,57 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
fields = ('text', 'score')
class SubmissionTypeSerializer(DynamicFieldsModelSerializer):
class TestSerializer(DynamicFieldsModelSerializer):
class Meta:
model = Test
fields = ('name', 'label', 'annotation')
class SubmissionTypeListSerializer(DynamicFieldsModelSerializer):
fullScore = serializers.IntegerField(source='full_score')
class Meta:
model = SubmissionType
fields = ('id', 'name', 'fullScore')
class SubmissionTypeSerializer(SubmissionTypeListSerializer):
class Meta:
model = SubmissionType
fields = ('name', 'full_score', 'description', 'solution')
fields = ('id', 'name', 'fullScore', 'description', 'solution')
class SubmissionSerializer(DynamicFieldsModelSerializer):
feedback = serializers.ReadOnlyField(source='feedback.text')
score = serializers.ReadOnlyField(source='feedback.score')
type_id = serializers.ReadOnlyField(source='type.id')
type_name = serializers.ReadOnlyField(source='type.name')
full_score = serializers.ReadOnlyField(source='type.full_score')
type = SubmissionTypeSerializer()
feedback = FeedbackSerializer()
tests = TestSerializer(many=True)
class Meta:
model = Submission
fields = ('type', 'text', 'feedback', 'tests')
class SubmissionListSerializer(DynamicFieldsModelSerializer):
type = SubmissionTypeListSerializer()
# TODO change this according to new feedback model
feedback = FeedbackSerializer(fields=('score',))
class Meta:
model = Submission
fields = ('type_id', 'type_name', 'text',
'feedback', 'score', 'full_score')
fields = ('type', 'feedback')
class StudentSerializer(DynamicFieldsModelSerializer):
name = serializers.ReadOnlyField(source='user.fullname')
user = serializers.ReadOnlyField(source='user.username')
matrikel_no = serializers.ReadOnlyField(source='user.matrikel_no')
exam = ExamSerializer()
submissions = SubmissionSerializer(many=True)
submissions = SubmissionListSerializer(many=True)
class Meta:
model = Student
fields = ('name', 'user', 'exam', 'submissions')
fields = ('name', 'user', 'matrikel_no', 'exam', 'submissions')
class SubmissionNoTextFieldsSerializer(DynamicFieldsModelSerializer):
......
......@@ -17,6 +17,8 @@ router.register(r'tutor', views.TutorApiViewSet)
regular_views_urlpatterns = [
url(r'student-page', views.StudentSelfApiView.as_view(),
name='student-page'),
url(r'student-submissions', views.StudentSelfSubmissionsApiView.as_view(),
name='student-submissions'),
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')
......
......@@ -10,7 +10,8 @@ from core.models import ExamType, Student, SubmissionType, Tutor
from core.permissions import IsReviewer, IsStudent
from core.serializers import (ExamSerializer, StudentSerializer,
StudentSerializerForListView,
SubmissionTypeSerializer, TutorSerializer)
SubmissionSerializer, SubmissionTypeSerializer,
SubmissionTypeListSerializer, TutorSerializer)
@api_view()
......@@ -35,6 +36,14 @@ class StudentSelfApiView(generics.RetrieveAPIView):
return self.request.user.student
class StudentSelfSubmissionsApiView(generics.ListAPIView):
permission_classes = (IsStudent, )
serializer_class = SubmissionSerializer
def get_queryset(self):
return self.request.user.student.submissions
class ExamApiViewSet(viewsets.ReadOnlyModelViewSet):
""" Gets a list of an individual exam by Id if provided """
permission_classes = (IsReviewer,)
......@@ -69,4 +78,12 @@ class StudentReviewerApiViewSet(viewsets.ReadOnlyModelViewSet):
class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet):
""" Gets a list or a detail view of a single SubmissionType """
queryset = SubmissionType.objects.all()
serializer_class = SubmissionTypeSerializer
def get_serializer_class(self):
if self.action == 'retrieve':
return SubmissionTypeSerializer
elif self.action == 'list':
return SubmissionTypeListSerializer
else:
raise NotImplementedError('SubmissionTypeViewSet only offers' +
'list and retrieve action.')
......@@ -16,6 +16,7 @@
"axios": "^0.17.0",
"google-code-prettify": "^1.0.5",
"material-design-icons": "^3.0.1",
"v-clipboard": "^1.0.4",
"vue": "^2.5.2",
"vue-router": "^3.0.1",
"vuetify": "^0.17.3",
......
......@@ -15,4 +15,7 @@
</script>
<style>
#app {
font-family: Roboto, sans-serif;
}
</style>
......@@ -5,7 +5,7 @@
clipped
app
permanent
:mini-variant.sync="mini"
:mini-variant="mini"
>
<v-toolbar flat>
<v-list>
......@@ -26,7 +26,7 @@
</v-list-tile>
</v-list>
</v-toolbar>
<slot name="navigation"></slot>
<slot name="sidebar-content"></slot>
</v-navigation-drawer>
<v-toolbar
app
......@@ -38,7 +38,7 @@
>
<v-toolbar-title>
<v-avatar>
<img src="../../assets/brand.png">
<img src="../assets/brand.png">
</v-avatar>
</v-toolbar-title>
<span class="pl-2 grady-speak">{{ gradySpeak }}</span>
......@@ -48,7 +48,7 @@
<v-btn color="blue darken-1" to="/" @click.native="logout">Logout</v-btn>
</v-toolbar>
<v-content>
<slot></slot>
<router-view></router-view>
</v-content>
</div>
</template>
......@@ -67,7 +67,6 @@
'gradySpeak'
]),
...mapState([
'examInstance',
'username',
'userRole'
])
......@@ -76,6 +75,11 @@
...mapActions([
'logout'
])
},
watch: {
mini: function () {
this.$emit('sidebarMini', this.mini)
}
}
}
</script>
......
<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-container>
</template>
<script>
export default {
name: 'submission-type',
props: {
name: {
type: String,
required: true
},
description: {
type: String,
required: true
},
solution: {
type: String,
required: true
},
fullScore: {
type: Number,
required: true
},
reverse: {
type: Boolean,
default: false
},
expandedByDefault: {
type: Object,
default: function () {
return {
Description: true,
Solution: true
}
}
}
},
computed: {
typeItems () {
let items = [
{
title: 'Description',
text: this.description
},
{
title: 'Solution',
text: this.solution
}
]
if (this.reverse) {
return items.reverse()
} else {
return items
}
}
}
}
</script>
......@@ -5,12 +5,12 @@
<th>Modul</th>
<td>{{ exam.module_reference }}</td>
</tr>
<tr v-if="!exam.pass_only">
<tr>
<th>Pass score</th>
<td>{{ exam.pass_score }}</td>
</tr>
<tr v-else>
<th>Pass only!</th>
<tr v-if="exam.passOnly">
<th>Pass only exam!</th>
</tr>
<tr>
<th>Total score</th>
......
<template>
<v-layout>
<annotated-submission class="ma-3" :editable="false"></annotated-submission>
</v-layout>
</template>
<script>
import AnnotatedSubmission from '../submission_notes/AnnotatedSubmission'
export default {
components: {
AnnotatedSubmission
},
name: 'submission-detail'
}
</script>
......@@ -7,10 +7,10 @@
item-key="type"
>
<template slot="items" slot-scope="props">
<td>{{ props.item.type_name }}</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.item.type_id}`" color="orange lighten-2">View</v-btn></td>
<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>
</template>
</v-data-table>
<v-alert color="info" value="true">
......@@ -29,22 +29,17 @@
{
text: 'Task',
align: 'left',
value: 'type'
value: 'type',
sortable: false
},
{
text: 'Score',
value: 'score'
value: 'feedback.score'
},
{
text: 'Maximum Score',
value: 'full_score'
value: 'type.fullScore'
}
],
fields: [
{ key: 'type', sortable: true },
{ key: 'score', label: 'Score', sortable: true },
{ key: 'full_score', sortable: true }
]
}
},
......@@ -56,11 +51,10 @@
},
computed: {
sumScore () {
console.log(this.submissions)
return this.submissions.map(a => a.score).reduce((a, b) => a + b)
return this.submissions.map(a => a.feedback.score).reduce((a, b) => a + b)
},
sumFullScore () {
return this.submissions.map(a => a.full_score).reduce((a, b) => a + b)
return this.submissions.map(a => a.type.fullScore).reduce((a, b) => a + b)
},
pointRatio () {
return ((this.sumScore / this.sumFullScore) * 100).toFixed(2)
......
<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] && editable"
@collapseFeedbackForm="showEditorOnLine[index] = false"
:feedback="feedback[index]"
:index="index">
</comment-form>
</td>
</tr>
</table>
<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, mapState} from 'vuex'
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
}
},
beforeCreate () {
this.$store.dispatch('getFeedback', 0)
this.$store.dispatch('getSubmission', 0)
},
computed: {
...mapState({
feedback: state => state.submissionNotes.feedback
}),
...mapGetters(['submission'])
},
data: function () {
return {
showEditorOnLine: { }
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])
......@@ -73,15 +100,11 @@
table {
table-layout: auto;
border-collapse: collapse;
width: 100%;
}
td {
/*white-space: nowrap;*/
/*border: 1px solid green;*/
}
.line-number-cell {
/*padding-left: 50px;*/
vertical-align: top;
}
......@@ -101,9 +124,4 @@
min-width: fit-content;
margin: 0;
}
.comment-icon {
border: 0;
}
</style>
<template>
<v-card class="help-card">
<v-card-title>
<v-icon>help_outline</v-icon>
<h3>Tips on using the correction interface</h3>
</v-card-title>
<v-card-text>
Never trade an ale.
The sea-dog leads with yellow fever, crush the captain's quarters until it waves.<br>
Ho-ho-ho! malaria of life.<br>
Halitosis, adventure, and yellow fever.<br>
The girl drinks with halitosis, pull the galley before it laughs.<br>
The moon fires with life, vandalize the bikini atoll before it travels.<br>
The tuna blows with fight, haul the freighter before it whines.<br>
The cannibal robs with hunger, fire the lighthouse until it whines.<br>
The captain loves with death, vandalize the lighthouse before it whines.<br>
The anchor loots with treasure, raid the freighter before it grows.<br>
The reef commands with endurance, view the quarter-deck until it whines.<br>
The scallywag loots with passion, crush the bikini atoll before it falls.<br>
The sea leads with treasure, ransack the brig until it dies.<br>
The parrot robs with desolation, view the seychelles before it screams.<br>
The warm anchor quirky blows the landlubber.<br>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'correction-help-card'
}
</script>
<style scoped>
.help-card {
width: fit-content;
}
</style>
......@@ -27,7 +27,7 @@
}
.tip-up {
top: -25px; /* Same as body margin top + border */
top: -22px; /* Same as body margin top + border */
left: 10px;
border-right-color: transparent;
border-left-color: transparent;
......@@ -40,12 +40,11 @@
margin: 20px 10px 10px 10px;
padding: 5px;
background-color: #F3F3F3;
border-radius: 5px;
border: 5px solid #3D8FC1;
border-radius: 0px;
border: 2px solid #3D8FC1;
}
.body .message {
font-family: Roboto, sans-serif;
min-height: 30px;
border-radius: 3px;
font-size: 14px;
......
......@@ -3,15 +3,16 @@
<v-text-field
name="feedback-input"
label="Please provide your feedback here"
v-model="current_feedback"
v-model="currentFeedback"
@keyup.enter.ctrl.exact="submitFeedback"
@keyup.esc="collapseTextField"
@focus="selectInput($event)"
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>
......@@ -23,27 +24,31 @@
name: 'comment-form',
props: {
feedback: String,
index: Number
index: String
},
data () {
return {
current_feedback: this.feedback
currentFeedback: this.feedback
}
},
methods: {
selectInput (event) {
if (event) {
event.target.select()
}
},
collapseTextField () {
this.$emit('collapseFeedbackForm')
},
submitFeedback () {
this.$store.dispatch('updateFeedback', {
lineIndex: this.index,
content: this.current_feedback
content: this.currentFeedback
})
this.collapseTextField()
},
discardFeedback () {
this.current_feedback = this.feedback
this.currentFeedback = this.feedback
}
}
}
......
<template>
<v-toolbar dense class="bottom-toolbar">
<v-spacer/>
<v-alert
class="score-alert ma-3"
color="error"
icon="warning"
:value="scoreError"
>{{ scoreError }}</v-alert>
<span class="mr-2">Score:</span>
<input
class="score-text-field"
type="number"
v-model="score"
@input="validateScore"
@change="validateScore"
/>
<v-tooltip top>
<v-btn color="success" slot="activator">Submit<v-icon>chevron_right</v-icon></v-btn>
<span>Submit and continue</span>
</v-tooltip>
</v-toolbar>
</template>
<script>
export default {
name: 'annotated-submission-bottom-toolbar',
data () {
return {
score: 42,
mockMax: 50,
scoreError: ''
}
},
methods: {
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}`
}
}
}
}
</script>
<style scoped>
.bottom-toolbar {
font-size: large;
}
.score-text-field {
max-width: 50px;
box-sizing: border-box;
border: 1px solid grey;
border-radius: 2px;
padding: 3px;
}
.score-alert {
max-height: 40px;
}
</style>
<template>
<v-toolbar
dense>
<v-toolbar-side-icon @click.stop="helpDialog=true">
<v-icon>help_outline</v-icon>
</v-toolbar-side-icon>
<v-dialog
scrollable
max-width="fit-content"
v-model="helpDialog"
>
<correction-help-card></correction-help-card>
</v-dialog>
<v-spacer></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-tooltip>
</v-toolbar>
</template>
<script>
import CorrectionHelpCard from '@/components/submission_notes/CorrectionHelpCard'
export default {
components: {CorrectionHelpCard},
name: 'annotated-submission-top-toolbar',
props: {
submission: {
type: String,
required: true
}
},
data () {
return {
helpDialog: false
}
}
}
</script>
<style scoped>
</style>
......@@ -5,6 +5,7 @@ import App from './App'
import router from './router'
import store from './store/store'
import Vuetify from 'vuetify'
import Cliboard from 'v-clipboard'
import 'vuetify/dist/vuetify.min.css'
import 'material-design-icons/iconfont/material-icons.css'
......@@ -12,6 +13,7 @@ import 'google-code-prettify/bin/prettify.min'
import 'google-code-prettify/bin/prettify.min.css'
Vue.use(Vuetify)
Vue.use(Cliboard)
Vue.config.productionTip = false
......
......@@ -19,13 +19,13 @@
v-model="credentials.username"
required
autofocus
></v-text-field>
/>
<v-text-field
label="Password"
v-model="credentials.password"
type="password"
required
></v-text-field>
/>
<v-btn :loading="loading" type="submit" color="primary">Access</v-btn>
</v-form>
</v-flex>
......@@ -49,7 +49,8 @@
},
computed: {
...mapState([
'error'
'error',
'userRole'
])
},
methods: {
......@@ -62,10 +63,16 @@
submit () {
this.loading = true
this.getJWTToken(this.credentials).then(() => {
this.getUserRole()
this.getUserRole().then(() => {
switch (this.userRole) {
case 'Student': this.$router.push('/student')
break
case 'Tutor': this.$router.push('/tutor')
break
}
})
this.getJWTTimeDelta()
this.loading = false
this.$router.push('/student/')
}).catch(() => { this.loading = false })
}
}
......
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment