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