From 8aae4459e612136def836e18ec44407997f7d9dd Mon Sep 17 00:00:00 2001 From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de> Date: Fri, 7 Dec 2018 18:29:05 +0100 Subject: [PATCH] Added Export instance option in frontend --- core/tests/test_export.py | 26 ++-- core/views/export.py | 7 +- frontend/src/api.ts | 14 ++ frontend/src/components/BaseLayout.vue | 2 +- .../components/{ => export}/DataExport.vue | 129 ++++++++--------- .../src/components/export/ExportDialog.vue | 58 ++++++++ .../src/components/export/InstanceExport.vue | 137 ++++++++++++++++++ .../src/pages/reviewer/ReviewerLayout.vue | 6 +- 8 files changed, 290 insertions(+), 89 deletions(-) rename frontend/src/components/{ => export}/DataExport.vue (63%) create mode 100644 frontend/src/components/export/ExportDialog.vue create mode 100644 frontend/src/components/export/InstanceExport.vue diff --git a/core/tests/test_export.py b/core/tests/test_export.py index 30921dd8..6487217e 100644 --- a/core/tests/test_export.py +++ b/core/tests/test_export.py @@ -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) diff --git a/core/views/export.py b/core/views/export.py index 89a90ab5..82d9eebb 100644 --- a/core/views/export.py +++ b/core/views/export.py @@ -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) - diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d0453302..e06a4111 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index 8c5ba64a..fd29f7e2 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -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> diff --git a/frontend/src/components/DataExport.vue b/frontend/src/components/export/DataExport.vue similarity index 63% rename from frontend/src/components/DataExport.vue rename to frontend/src/components/export/DataExport.vue index 4e327c37..59c7ee32 100644 --- a/frontend/src/components/DataExport.vue +++ b/frontend/src/components/export/DataExport.vue @@ -1,66 +1,53 @@ <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> diff --git a/frontend/src/components/export/ExportDialog.vue b/frontend/src/components/export/ExportDialog.vue new file mode 100644 index 00000000..8027a44c --- /dev/null +++ b/frontend/src/components/export/ExportDialog.vue @@ -0,0 +1,58 @@ +<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> diff --git a/frontend/src/components/export/InstanceExport.vue b/frontend/src/components/export/InstanceExport.vue new file mode 100644 index 00000000..8045bd22 --- /dev/null +++ b/frontend/src/components/export/InstanceExport.vue @@ -0,0 +1,137 @@ +<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> diff --git a/frontend/src/pages/reviewer/ReviewerLayout.vue b/frontend/src/pages/reviewer/ReviewerLayout.vue index 64b7aeb0..fd3df712 100644 --- a/frontend/src/pages/reviewer/ReviewerLayout.vue +++ b/frontend/src/pages/reviewer/ReviewerLayout.vue @@ -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 () { -- GitLab