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
No related branches found
No related tags found
1 merge request!125Resolve "Export Instance Data"
Pipeline #86240 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.
Finish editing this message first!
Please register or to comment