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

Added Export instance option in frontend

parent 5d3a3d6a
Branches
Tags
1 merge request!125Resolve "Export Instance Data"
Pipeline #325324 passed
......@@ -130,21 +130,21 @@ class ExportInstanceTest(APITestCase):
self.assertIn('tests', instance['students'][1]['submissions'][0])
# students[submissions][feedback] nested
self.assertIn('feedback', instance['students'][1]['submissions'][0])
self.assertLess(0, len(instance['students'][1]['submissions'][0]['feedback']))
self.assertEqual(5, instance['students'][1]['submissions'][0]['feedback']['score'])
self.assertEqual(True, instance['students'][1]['submissions'][0]['feedback']['isFinal'])
self.assertIn('created', instance['students'][1]['submissions'][0]['feedback'])
submissions = instance['students'][1]['submissions']
self.assertIn('feedback', submissions[0])
self.assertLess(0, len(submissions[0]['feedback']))
self.assertEqual(5, submissions[0]['feedback']['score'])
self.assertEqual(True, submissions[0]['feedback']['isFinal'])
self.assertIn('created', submissions[0]['feedback'])
# students[submissions][feedback][feedbackLines] nested
self.assertIn('feedbackLines', instance['students'][1]['submissions'][0]['feedback'])
self.assertLess(0, len(instance['students'][1]['submissions'][0]['feedback']['feedbackLines']))
self.assertIn('1', instance['students'][1]['submissions'][0]['feedback']['feedbackLines'])
self.assertIn('pk', instance['students'][1]['submissions'][0]['feedback']['feedbackLines']['1'][0])
self.assertEqual('This is very bad!',
instance['students'][1]['submissions'][0]['feedback']['feedbackLines']['1'][0]['text'])
self.assertEqual('reviewer',
instance['students'][1]['submissions'][0]['feedback']['feedbackLines']['1'][0]['ofTutor'])
feedback = instance['students'][1]['submissions'][0]['feedback']
self.assertIn('feedbackLines', feedback)
self.assertLess(0, len(feedback['feedbackLines']))
self.assertIn('1', feedback['feedbackLines'])
self.assertIn('pk', feedback['feedbackLines']['1'][0])
self.assertEqual('This is very bad!', feedback['feedbackLines']['1'][0]['text'])
self.assertEqual('reviewer', feedback['feedbackLines']['1'][0]['ofTutor'])
# reviewers fields
self.assertIn('reviewers', instance)
......
......@@ -5,7 +5,8 @@ import xkcdpass.xkcd_password as xp
from core.models import StudentInfo, UserAccount, ExamType, SubmissionType
from core.permissions import IsReviewer
from core.serializers.common_serializers import SubmissionTypeSerializer, ExamSerializer, UserAccountSerializer
from core.serializers.common_serializers import SubmissionTypeSerializer, \
ExamSerializer, UserAccountSerializer
from core.serializers.student import StudentExportSerializer
from core.serializers.tutor import TutorSerializer
......@@ -52,7 +53,8 @@ class InstanceExport(APIView):
def get(self, request):
exam_types_serializer = ExamSerializer(ExamType.objects.all(), many=True)
submission_types_serializer = SubmissionTypeSerializer(SubmissionType.objects.all(), many=True)
submission_types_serializer = SubmissionTypeSerializer(
SubmissionType.objects.all(), many=True)
tutors_serializer = TutorSerializer(UserAccount.tutors.with_feedback_count(), many=True)
reviewer_serializer = UserAccountSerializer(UserAccount.get_reviewers(), many=True)
student_serializer = StudentExportSerializer(StudentInfo.objects.all(), many=True)
......@@ -65,4 +67,3 @@ class InstanceExport(APIView):
"reviewers": reviewer_serializer.data
}
return Response(content)
......@@ -221,4 +221,18 @@ export async function fetchStudentExportData (options: StudentExportOptions): Pr
return (await ax.post('/api/export/json/', options)).data
}
// Note, this interface does not represent all of the returned data,
// but only the fields which have to be transformed for deanonymisation
export interface InstanceExportData {
students: {
name: string,
matrikelNo: string
}[]
}
export async function fetchInstanceExportData (): Promise<InstanceExportData> {
return (await ax.get('/api/instance/export')).data
}
export default ax
......@@ -74,7 +74,7 @@
<v-btn slot="activator" color="cyan" style="text-transform: none">
{{ userRole }} | {{ username }} <v-icon>arrow_drop_down</v-icon>
</v-btn>
<user-options/>
<user-options class="mt-1"/>
</v-menu>
</div>
<v-btn color="blue darken-1" @click.native="logout">Logout</v-btn>
......
<template>
<div>
<v-tooltip bottom>
<v-btn :color="exportColor"
slot="activator"
@click="showDialog"
>
export
<v-icon>file_download</v-icon>
</v-btn>
<span v-if="corrected">All submissions have been corrected!</span>
<span v-else>UNCORRECTED submissions left! Export will be incomplete.</span>
</v-tooltip>
<v-dialog v-model="exportDialog" max-width="30vw">
<v-card>
<v-card-title class="title">
Student Data Export
</v-card-title>
<v-card-text>
<div v-if="!mapFileLoaded">
If you select a mapping file, the anonymized data
will be mapped back automatically and locally on your machine.
<v-layout row align-center>
<file-select v-model="mapFile" display-text="Select map file" class="ma-3"/>
<span>Without the mapping, the data will still be obfuscated.</span>
</v-layout>
</div>
<v-layout row>
<v-flex xs4>
<v-tooltip top>
<v-checkbox
label="Set passwords"
v-model="setPasswords"
slot="activator"
/>
<span>Setting this will cause all student passwords
to be reset upon export. The new passwords will be contained in the
export file.
</span>
</v-tooltip>
</v-flex>
<v-flex xs3 offset-xs1>
<v-select
label="Export file format"
:items="availableExportTypes"
v-model="exportType"
<v-dialog v-model="exportDialog" max-width="31vw" @update:returnValue="hide">
<v-card>
<v-card-title class="title">
Student Data Export
</v-card-title>
<v-card-text>
<div v-if="!mapFileLoaded">
If you select a mapping file, the anonymized data
will be mapped back automatically and locally on your machine.
<v-layout row align-center>
<file-select v-model="mapFile" display-text="Select map file" class="ma-3"/>
<span>Without the mapping, the data will still be obfuscated.</span>
</v-layout>
</div>
<v-layout row>
<v-flex xs4>
<v-tooltip top>
<v-checkbox
label="Set passwords"
v-model="setPasswords"
slot="activator"
/>
</v-flex>
</v-layout>
<v-card-actions>
<v-btn
flat color="blue lighten-2"
@click="exportDialog = false"
>close</v-btn>
<v-spacer/>
<v-btn flat outline @click="getExportFile"
>{{mapFile || mapFileLoaded ? 'Download and apply mapping' : 'Download without mapping'}}</v-btn>
</v-card-actions>
</v-card-text>
</v-card>
</v-dialog>
</div>
<span>Setting this will cause all student passwords
to be reset upon export. The new passwords will be contained in the
export file.
</span>
</v-tooltip>
</v-flex>
<v-flex xs3 offset-xs1>
<v-select
label="Export file format"
:items="availableExportTypes"
v-model="exportType"
/>
</v-flex>
</v-layout>
<v-card-actions>
<v-btn
flat color="blue lighten-2"
@click="exportDialog = false"
>close</v-btn>
<v-spacer/>
<v-btn flat outline @click="getExportFile"
>{{mapFile || mapFileLoaded ? 'Download and apply mapping' : 'Download without mapping'}}</v-btn>
</v-card-actions>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
......@@ -82,15 +69,15 @@ enum ExportType {
components: { FileSelect }
})
export default class DataExport extends Mixins(parseCSVMapMixin) {
exportDialog = false
exportDialog = true
mapFile: File | null = null
setPasswords = false
exportType = ExportType.CSV
get corrected () { return getters.corrected }
get studentMap () { return getters.state.studentMap }
get mapFileLoaded () {
return Object.keys(getters.state.studentMap).length > 0
get mapFileLoaded () {
return Object.keys(getters.state.studentMap).length > 0
}
get exportColor () {
return this.corrected ? 'green darken-1' : 'red lighten-1'
......@@ -114,11 +101,11 @@ export default class DataExport extends Mixins(parseCSVMapMixin) {
},
fileReader.onerror = () => {
fileReader.abort();
reject(new DOMException("Problem parsing input file."));
reject(new Error("Problem parsing input file."));
}
if (!this.mapFile) {
reject(new Error("Can only call" +
reject(new Error("Can only call" +
" readMapFileAndCommit when mapFile is not undefined"))
} else {
fileReader.readAsText(this.mapFile)
......@@ -128,7 +115,7 @@ export default class DataExport extends Mixins(parseCSVMapMixin) {
applyMapping (studentExport: StudentExportItem[]) {
return studentExport.map(student => {
return {
return {
...student,
Matrikel: this.studentMap[student.Matrikel].matrikelNo,
Name: this.studentMap[student.Matrikel].name
......@@ -153,7 +140,7 @@ export default class DataExport extends Mixins(parseCSVMapMixin) {
// skip the Scores field
if (typeof curr === 'object') {
return acc
}
}
return acc ? `${acc};${curr}` : `${curr}`
}, '')
......@@ -181,7 +168,7 @@ export default class DataExport extends Mixins(parseCSVMapMixin) {
}
optionalConvertAndCreatePopup (studentData: StudentExportItem[]) {
const convertedData = this.exportType === ExportType.CSV ?
const convertedData = this.exportType === ExportType.CSV ?
this.jsonToCSV(studentData) : studentData
this.createDownloadPopup(convertedData, this.exportType)
......@@ -208,6 +195,10 @@ export default class DataExport extends Mixins(parseCSVMapMixin) {
this.optionalConvertAndCreatePopup(studentData)
}
}
hide () {
this.$emit('hide')
}
}
</script>
......
<template>
<div>
<v-menu>
<v-tooltip bottom slot="activator">
<v-btn :color="exportColor" slot="activator">
export
<v-icon>file_download</v-icon>
</v-btn>
<span v-if="corrected">All submissions have been corrected!</span>
<span v-else>UNCORRECTED submissions left! Export will be incomplete.</span>
</v-tooltip>
<v-list>
<v-list-tile v-for="(item, i) in menuItems" :key="i" @click="item.action">{{item.display}}</v-list-tile>
</v-list>
</v-menu>
<component v-if="displayComponent" :is="displayComponent" @hide="displayComponent = null"/>
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import DataExport from '@/components/export/DataExport.vue'
import InstanceExport from '@/components/export/InstanceExport.vue'
import { getters } from '@/store/getters'
@Component({
components: { DataExport, InstanceExport }
})
export default class ExportDialog extends Vue {
displayComponent: any = null
menuItems = [
{
display: 'Export student scores',
action: () => {
this.setDisplayComponent(DataExport)
}
},
{
display: 'Export whole instance data',
action: () => { this.setDisplayComponent(InstanceExport) }
}
];
get corrected () {
return getters.corrected
}
get exportColor () {
return this.corrected ? 'green darken-1' : 'red lighten-1'
}
// apparently `this` is not the same when used within a
// closure when defining data and within a method
setDisplayComponent(component: any) {
this.displayComponent = component
}
}
</script>
<template>
<v-dialog v-model="exportDialog" max-width="31vw" @update:returnValue="hide">
<v-card>
<v-card-title class="title">
Instance Data Export
</v-card-title>
<v-card-text>
<div v-if="!mapFileLoaded">
If you select a mapping file, the anonymized data
will be mapped back automatically and locally on your machine.
<v-layout row align-center>
<file-select v-model="mapFile" display-text="Select map file" class="ma-3"/>
<span>Without the mapping, the data will still be obfuscated.</span>
</v-layout>
</div>
<v-card-actions>
<v-btn
flat color="blue lighten-2"
@click="exportDialog = false"
>close</v-btn>
<v-spacer/>
<v-btn flat outline @click="getExportFile"
>{{mapFile || mapFileLoaded ? 'Download and apply mapping' : 'Download without mapping'}}</v-btn>
</v-card-actions>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import {Vue, Component, Mixins} from 'vue-property-decorator'
import { getters } from '@/store/getters'
import ax, { StudentExportItem, fetchStudentExportData, fetchInstanceExportData, InstanceExportData } from '@/api'
import FileSelect from '@/components/util/FileSelect.vue'
import { mutations as mut } from '@/store/mutations'
import { parseCSVMapMixin } from '@/components/mixins/mixins'
@Component({
components: { FileSelect }
})
export default class DataExport extends Mixins(parseCSVMapMixin) {
exportDialog = true
mapFile: File | null = null
get corrected () { return getters.corrected }
get studentMap () { return getters.state.studentMap }
get mapFileLoaded () {
return Object.keys(getters.state.studentMap).length > 0
}
get exportColor () {
return this.corrected ? 'green darken-1' : 'red lighten-1'
}
showDialog () {
this.exportDialog = true
}
readMapFileAndCommit () {
const fileReader = new FileReader()
return new Promise((resolve, reject) => {
fileReader.onload = event => {
// @ts-ignore typings of EventTarget seem to be wrong
const studentMap = this.parseCSVMap(event.target.result)
mut.SET_STUDENT_MAP(studentMap)
resolve()
},
fileReader.onerror = () => {
fileReader.abort();
reject(new Error("Problem parsing input file."));
}
if (!this.mapFile) {
reject(new Error("Can only call" +
" readMapFileAndCommit when mapFile is not undefined"))
} else {
fileReader.readAsText(this.mapFile)
}
})
}
applyMapping (instanceExport: InstanceExportData) {
instanceExport.students.forEach(student => {
if (this.studentMap[student.matrikelNo]) {
const anonMatrikelNo = student.matrikelNo
student.name = this.studentMap[anonMatrikelNo].name
student.matrikelNo = this.studentMap[anonMatrikelNo].matrikelNo
} else {
this.$notify({
title: `Unknown student: ${student.name}`,
text: `Student ${student.name} is missing in mapping file`,
type: 'error',
duration: -1
})
}
})
}
createDownloadPopup (content: string | InstanceExportData) {
const blobProperties: BlobPropertyBag = {}
blobProperties.type = 'application/json'
content = JSON.stringify(content)
const blobData = new Blob([<string> content], blobProperties)
window.open(window.URL.createObjectURL(blobData))
}
async getMappedExportFile (studentData: InstanceExportData) {
if (!this.mapFile && !this.mapFileLoaded) {
throw new Error("Either mapFile must be selected or already loaded "+
"to call getMappedExportFile")
}
if (this.mapFile) {
await this.readMapFileAndCommit()
}
this.applyMapping(studentData)
this.createDownloadPopup(studentData)
}
async getExportFile () {
const instanceData = await fetchInstanceExportData()
if (this.mapFile || this.mapFileLoaded) {
this.getMappedExportFile(instanceData)
} else {
this.createDownloadPopup(instanceData)
}
}
hide () {
this.$emit('hide')
}
}
</script>
<style scoped>
</style>
......@@ -13,18 +13,18 @@
</v-list-tile>
</v-list>
<template slot="toolbar-right">
<data-export/>
<export-dialog/>
</template>
</tutor-reviewer-base-layout>
</template>
<script>
import TutorReviewerBaseLayout from '@/pages/base/TutorReviewerBaseLayout'
import DataExport from '@/components/DataExport'
import ExportDialog from '@/components/export/ExportDialog'
export default {
components: {
DataExport,
ExportDialog,
TutorReviewerBaseLayout },
name: 'reviewer-layout',
data () {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment