From c2c45ecc83d3fa5d364b3a7196758cace31eed6f Mon Sep 17 00:00:00 2001 From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de> Date: Sun, 30 Sep 2018 18:34:02 +0200 Subject: [PATCH] Student passwords can now be set when exporting The former student data endpoint /export/csv/ has been replaces by /export/json/ . This new endpoint exports the data as normal json. It also allows the client to send setPasswords: true as an option which will results in random passwords beign generated for all students and included in the export data. --- core/models.py | 2 +- core/tests/test_export.py | 38 ++- core/urls.py | 2 +- core/views/__init__.py | 2 +- core/views/export.py | 63 ++--- frontend/src/api.ts | 16 +- frontend/src/components/DataExport.vue | 285 ++++++++++++-------- frontend/src/components/mixins/mixins.js | 15 -- frontend/src/components/mixins/mixins.ts | 17 ++ frontend/src/store/modules/subscriptions.ts | 4 +- frontend/src/store/mutations.ts | 8 +- frontend/src/store/store.ts | 6 +- requirements.txt | 2 +- 13 files changed, 282 insertions(+), 178 deletions(-) delete mode 100644 frontend/src/components/mixins/mixins.js create mode 100644 frontend/src/components/mixins/mixins.ts diff --git a/core/models.py b/core/models.py index bc33deec..499faf4c 100644 --- a/core/models.py +++ b/core/models.py @@ -301,7 +301,7 @@ class StudentInfo(models.Model): """ TODO: get rid of it and use an annotation. """ if self.submissions.all(): return OrderedDict({ - s.type: s.feedback.score if hasattr(s, 'feedback') else 0 + s.type.name: s.feedback.score if hasattr(s, 'feedback') else 0 for s in self.submissions.order_by('type__name') }) diff --git a/core/tests/test_export.py b/core/tests/test_export.py index c7422190..72609c90 100644 --- a/core/tests/test_export.py +++ b/core/tests/test_export.py @@ -1,10 +1,11 @@ from django.test import Client, TestCase from rest_framework import status +from rest_framework.utils import json from util.factories import make_test_data -class ExportCSVTest(TestCase): +class ExportJSONTest(TestCase): @classmethod def setUpTestData(cls): @@ -78,14 +79,37 @@ class ExportCSVTest(TestCase): def setUp(self): client = Client() client.force_login(user=self.data['reviewers'][0]) - - self.response = client.get('/api/export/csv/') + self.response = client.post('/api/export/json/', content_type='application/json') def test_can_access(self): self.assertEqual(status.HTTP_200_OK, self.response.status_code) def test_data_is_correct(self): - head, student1, student2, _ = self.response.content.split(b'\r\n') - self.assertIn(b'Matrikel;Name;Exam;Sum;01. Sort;02. Shuffle', head) - self.assertIn(b';;Test Exam 01;5;5;0', student1) - self.assertIn(b';;Test Exam 01;0;0;0', student2) + # for some reason the data is not automatically parsed... + student1, student2 = json.loads(self.response.content) + self.assertIn('Matrikel', student1) + self.assertIn('Matrikel', student2) + + self.assertEqual('', student1['Name']) + self.assertEqual('', student2['Name']) + + self.assertEqual('Test Exam 01', student1['Exam']) + self.assertEqual('Test Exam 01', student2['Exam']) + + self.assertEqual(5, student1['Sum']) + self.assertEqual(0, student2['Sum']) + + self.assertEqual('student01', student1['Username']) + self.assertEqual('student02', student2['Username']) + + self.assertEqual('01. Sort', student1['Scores'][0]['type']) + self.assertEqual('01. Sort', student2['Scores'][0]['type']) + + self.assertEqual('02. Shuffle', student1['Scores'][1]['type']) + self.assertEqual('02. Shuffle', student2['Scores'][1]['type']) + + self.assertEqual(5, student1['Scores'][0]['score']) + self.assertEqual(0, student2['Scores'][0]['score']) + + self.assertEqual(0, student1['Scores'][1]['score']) + self.assertEqual(0, student2['Scores'][1]['score']) diff --git a/core/urls.py b/core/urls.py index a042f5f9..2450f5f2 100644 --- a/core/urls.py +++ b/core/urls.py @@ -46,7 +46,7 @@ regular_views_urlpatterns = [ path('jwt-time-delta/', views.get_jwt_expiration_delta, name='jwt-time-delta'), - path('export/csv/', views.StudentCSVExport.as_view(), name='export-csv'), + path('export/json/', views.StudentJSONExport.as_view(), name='export-json'), re_path(r'swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), re_path(r'swagger/$', schema_view.with_ui('swagger', cache_timeout=0), diff --git a/core/views/__init__.py b/core/views/__init__.py index e653604f..da92a342 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -1,4 +1,4 @@ from .feedback import FeedbackApiView, FeedbackCommentApiView # noqa from .subscription import SubscriptionApiViewSet, AssignmentApiViewSet # noqa from .common_views import * # noqa -from .export import StudentCSVExport # noqa +from .export import StudentJSONExport # noqa diff --git a/core/views/export.py b/core/views/export.py index f31e3d4b..1ac84e7f 100644 --- a/core/views/export.py +++ b/core/views/export.py @@ -1,41 +1,44 @@ from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework_csv import renderers -from core.models import StudentInfo, SubmissionType +import xkcdpass.xkcd_password as xp + +from core.models import StudentInfo, UserAccount from core.permissions import IsReviewer +words = xp.generate_wordlist(wordfile=xp.locate_wordfile(), min_length=5, max_length=8) -class SemicolonCSVRenderer(renderers.CSVRenderer): - writer_opts = { - 'delimiter': ';', - } +def _set_student_passwords(): + student_password_dict = {} + for student in UserAccount.get_students(): + password = xp.generate_xkcdpassword(words, numwords=3, delimiter='-') + student.set_password(password) + student.save() + student_password_dict[student.pk] = password -class StudentCSVExport(APIView): - renderer_classes = (SemicolonCSVRenderer, ) - permission_classes = (IsReviewer, ) + return student_password_dict - def get_renderer_context(self): - context = super().get_renderer_context() - context['header'] = ('Matrikel', 'Name', 'Exam', 'Sum', - *SubmissionType.objects.values_list('name', - flat=True)) - context['delimiter'] = ';' - return context - - def finalize_response(self, request, response, *args, **kwargs): - response['Content-Disposition'] = \ - "attachment; filename=%s" % 'results.csv' - return super().finalize_response(request, response, *args, **kwargs) - - def get(self, request, format=None): - content = [{'Matrikel': student.matrikel_no, - 'Name': student.user.fullname, - 'Sum': student.overall_score, - 'Exam': student.exam.module_reference, - **student.score_per_submission() - } for student - in StudentInfo.get_annotated_score_submission_list()] +class StudentJSONExport(APIView): + permission_classes = (IsReviewer, ) + + def post(self, request, format=None): + set_passwords = request.data.get('set_passwords') + passwords = _set_student_passwords() if set_passwords else None + + content = [ + {'Matrikel': student.matrikel_no, + 'Name': student.user.fullname, + 'Username': student.user.username, + 'Sum': student.overall_score, + 'Exam': student.exam.module_reference, + 'Password': passwords[student.user.pk] if set_passwords else '********', + 'Scores': [ + { + 'type': submission_type, + 'score': score + } for submission_type, score in student.score_per_submission().items()] + } for student + in StudentInfo.get_annotated_score_submission_list()] return Response(content) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3c4569d3..ec02ebdd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,7 +15,7 @@ import { function getInstanceBaseUrl (): string { if (process.env.NODE_ENV === 'production') { - return `https://${window.location.host}${window.location.pathname}` + return `https://${window.location.host}${window.location.pathname}`.replace(/\/+$/, '') } else { return 'http://localhost:8000/' } @@ -197,4 +197,18 @@ export async function changeActiveForUser (userPk: string, active: boolean): Pro return (await ax.patch(`/api/user/${userPk}/change_active/`, { 'is_active': active })).data } +export interface StudentExportOptions { setPasswords?: boolean } +export interface StudentExportItem { + Matrikel: string, + Name: string, + Username: string, + Sum: number, + Exam: string, + Password: string, + Scores: { type: string, score: number }[] +} +export async function fetchStudentExportData (options: StudentExportOptions): Promise<StudentExportItem[]> { + return (await ax.post('/api/export/json/', options)).data +} + export default ax diff --git a/frontend/src/components/DataExport.vue b/frontend/src/components/DataExport.vue index cf4b35cc..4e327c37 100644 --- a/frontend/src/components/DataExport.vue +++ b/frontend/src/components/DataExport.vue @@ -3,7 +3,7 @@ <v-tooltip bottom> <v-btn :color="exportColor" slot="activator" - @click="getCSVFileAndProcess" + @click="showDialog" > export <v-icon>file_download</v-icon> @@ -11,28 +11,51 @@ <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="mapFileDialog" max-width="30vw"> + <v-dialog v-model="exportDialog" max-width="30vw"> <v-card> <v-card-title class="title"> - Currently no student mapping file is selected + Student Data Export </v-card-title> <v-card-text> - If you select a mapping file, the anonymized data - will be mapped back automatically and locally on your machine. + <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 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-flex> </v-layout> - <v-card-actions> <v-btn flat color="blue lighten-2" - @click="mapFileDialog = false" + @click="exportDialog = false" >close</v-btn> <v-spacer/> - <v-btn flat outline @click="getCSVFileAndProcess" - >{{mapFile ? 'Download and apply mapping' : 'Download without mapping'}}</v-btn> + <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> @@ -40,123 +63,151 @@ </div> </template> -<script> +<script lang="ts"> +import {Vue, Component, Mixins} from 'vue-property-decorator' import { getters } from '@/store/getters' -import ax from '@/api' -import FileSelect from '@/components/util/FileSelect' +import ax, { StudentExportItem, fetchStudentExportData } from '@/api' +import FileSelect from '@/components/util/FileSelect.vue' import { mutations as mut } from '@/store/mutations' import { parseCSVMapMixin } from '@/components/mixins/mixins' -export default { - components: { FileSelect }, - name: 'data-export', - mixins: [parseCSVMapMixin], - data () { - return { - fileReader: new FileReader(), - mapFile: null, - mapFileDialog: false - } - }, - computed: { - corrected () { return getters.corrected }, - exportColor () { - return this.corrected ? 'green darken-1' : 'red lighten-1' - }, - downloadUrl () { - let url = '' - if (process.env.NODE_ENV === 'production') { - const baseUrl = `https://${window.location.host}${window.location.pathname}`.replace(/\/+$/, '') - url = `${baseUrl}/api/export/csv` - } else { - url = 'http://localhost:8000/api/export/csv/' - } - return url - }, - studentMap () { - return this.$store.state.studentMap - }, - mapFileLoaded () { - return Object.keys(this.studentMap).length > 0 - } - }, - methods: { - readMapFileAndCommit (callback) { - this.fileReader.onload = event => { + +enum ExportType { + JSON = 'JSON', + CSV = 'CSV' +} + + +@Component({ + components: { FileSelect } +}) +export default class DataExport extends Mixins(parseCSVMapMixin) { + exportDialog = false + 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 exportColor () { + return this.corrected ? 'green darken-1' : 'red lighten-1' + } + get availableExportTypes (): ExportType[] { + return Object.values(ExportType) + } + + 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) - callback() + resolve() + }, + fileReader.onerror = () => { + fileReader.abort(); + reject(new DOMException("Problem parsing input file.")); } - this.fileReader.readAsText(this.mapFile) - }, - async download () { - const response = await ax.get(this.downloadUrl, { responseType: 'blob' }) - return new Blob([response.data], { type: 'text/csv' }) - }, - CSVToJson (csvString) { - const lines = csvString.split('\n') - const headers = lines.shift().split(';') - return lines - .filter(line => !!line) // remove empty strings - .map(line => { - const lineItems = line.split(';') - return headers.reduce((acc, curr, i) => { - acc[headers[i]] = lineItems[i] - return acc - }, {}) - }) - }, - jsonToCSV (data) { - const headerLine = Object.keys(data[0]).reduce((acc, curr) => { - return acc ? `${acc};${curr}` : `${curr}` - }, '') - const lines = data.map(studentData => { - return Object.values(studentData).reduce((acc, curr) => { - return acc ? `${acc};${curr}` : `${curr}` - }, '') - }) - return headerLine + lines.reduce((acc, curr) => { - return `${acc}\n${curr}` - }, '') + '\n' // add trailing newline - }, - mapStudentData (students) { - return students.map(studentData => { - return { - ...studentData, - Matrikel: this.$store.state.studentMap[studentData.Matrikel].matrikelNo, - Name: this.$store.state.studentMap[studentData.Matrikel].name - } - }) - }, - getMappedCSV () { - this.download().then(blobData => { - if (this.mapFileLoaded) { - this.fileReader.onload = event => { - const jsonData = this.CSVToJson(event.target.result) - const mappedData = this.mapStudentData(jsonData) - const csvData = this.jsonToCSV(mappedData) - const mappedBlobData = new Blob([csvData], { type: 'text/csv' }) - window.open(window.URL.createObjectURL(mappedBlobData)) - } - this.fileReader.readAsText(blobData) - } else { - window.open(window.URL.createObjectURL(blobData)) - } - }) - }, - getCSVFileAndProcess () { - if (!this.mapFileLoaded && !this.mapFileDialog) { - this.mapFileDialog = true + + if (!this.mapFile) { + reject(new Error("Can only call" + + " readMapFileAndCommit when mapFile is not undefined")) } else { - if (this.mapFile) { - this.readMapFileAndCommit(this.getMappedCSV) - } else { - this.getMappedCSV() - } + fileReader.readAsText(this.mapFile) + } + }) + } + + applyMapping (studentExport: StudentExportItem[]) { + return studentExport.map(student => { + return { + ...student, + Matrikel: this.studentMap[student.Matrikel].matrikelNo, + Name: this.studentMap[student.Matrikel].name + } + }) + } + + jsonToCSV (studentExport: StudentExportItem[], delimeter = ';') { + let headerLine = Object.keys(studentExport[0]).reduce((acc: string, curr) => { + if (curr === 'Scores') { + return acc } + return acc ? `${acc};${curr}` : `${curr}` + }, '') + headerLine += Object.values(studentExport[0].Scores) + .reduce((acc: string, curr) => { + return `${acc};${curr.type}` + }, '') + + const lines = studentExport.map(student => { + const normalFields = Object.values(student).reduce((acc: string, curr): string => { + // skip the Scores field + if (typeof curr === 'object') { + return acc + } + return acc ? `${acc};${curr}` : `${curr}` + }, '') + + const scoreFields = Object.values(student.Scores).reduce((acc: string, curr) => { + return `${acc};${curr.score}` + }, '') + return normalFields + scoreFields + }) + + return headerLine + lines.reduce((acc, curr) => { + return `${acc}\n${curr}` + }, '') + '\n' // add trailing newline + } + + createDownloadPopup (content: string | StudentExportItem[], fileType: ExportType) { + const blobProperties: BlobPropertyBag = {} + if (fileType === ExportType.JSON) { + blobProperties.type = 'application/json' + content = JSON.stringify(content) + } else { + blobProperties.type = 'text/csv' } + const blobData = new Blob([<string> content], blobProperties) + window.open(window.URL.createObjectURL(blobData)) + } + + optionalConvertAndCreatePopup (studentData: StudentExportItem[]) { + const convertedData = this.exportType === ExportType.CSV ? + this.jsonToCSV(studentData) : studentData + + this.createDownloadPopup(convertedData, this.exportType) } + async getMappedExportFile (studentData: StudentExportItem[]) { + 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() + } + const mappedData = this.applyMapping(studentData) + this.optionalConvertAndCreatePopup(mappedData) + } + + async getExportFile () { + const studentData = await fetchStudentExportData({setPasswords: this.setPasswords}) + + if (this.mapFile || this.mapFileLoaded) { + this.getMappedExportFile(studentData) + } else { + this.optionalConvertAndCreatePopup(studentData) + } + } } </script> diff --git a/frontend/src/components/mixins/mixins.js b/frontend/src/components/mixins/mixins.js deleted file mode 100644 index 4018b4aa..00000000 --- a/frontend/src/components/mixins/mixins.js +++ /dev/null @@ -1,15 +0,0 @@ -export var parseCSVMapMixin = { - methods: { - parseCSVMap (csvMap) { - let lines = csvMap.split('\n') - lines.shift() // drop the first line since it contains only headings - return lines.reduce((acc, curr) => { - if (curr) { - let [key, matrikelNo, name] = curr.split(';') - acc[key] = { matrikelNo: matrikelNo, name } - } - return acc - }, {}) - } - } -} diff --git a/frontend/src/components/mixins/mixins.ts b/frontend/src/components/mixins/mixins.ts new file mode 100644 index 00000000..ba6ab61f --- /dev/null +++ b/frontend/src/components/mixins/mixins.ts @@ -0,0 +1,17 @@ +import {Vue, Component} from 'vue-property-decorator' + +@Component +export class parseCSVMapMixin extends Vue { + parseCSVMap (csvMap: string) { + let lines = csvMap.split('\n') + lines.shift() // drop the first line since it contains only headings + // TODO remove any type + return lines.reduce((acc: any, curr) => { + if (curr) { + let [key, matrikelNo, name] = curr.split(';') + acc[key] = { matrikelNo: matrikelNo, name } + } + return acc + }, {}) + } +} diff --git a/frontend/src/store/modules/subscriptions.ts b/frontend/src/store/modules/subscriptions.ts index bb399b1a..e7f181e5 100644 --- a/frontend/src/store/modules/subscriptions.ts +++ b/frontend/src/store/modules/subscriptions.ts @@ -44,9 +44,9 @@ const availableStagesGetter = mb.read(function availableStages (state, getters) return stages }) const availableStagesReadableGetter = mb.read(function availableStagesReadable (state, getters) { - let stages = ['create', 'validate'] + let stages = ['initial', 'validate'] if (Authentication.isReviewer) { - stages.push('resolve') + stages.push('conflict') } return stages }) diff --git a/frontend/src/store/mutations.ts b/frontend/src/store/mutations.ts index 7dacc5de..c2a79d5f 100644 --- a/frontend/src/store/mutations.ts +++ b/frontend/src/store/mutations.ts @@ -25,7 +25,13 @@ function SET_STUDENT (state: RootState, student: StudentInfoForListView) { }, state.studentMap)) } // TODO proper types for student map -function SET_STUDENT_MAP (state: RootState, map: object) { +export interface StudentMap { + [pseudoMatrikelNo: string]: { + matrikelNo: string, + name: string + } +} +function SET_STUDENT_MAP (state: RootState, map: StudentMap) { state.studentMap = map } function SET_TUTORS (state: RootState, tutors: Array<Tutor>) { diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index b27d7a96..6689ec63 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -42,7 +42,11 @@ export interface RootInitialState { submissionTypes: {[pk: string]: SubmissionType} submissions: {[pk: string]: SubmissionNoType} students: {[pk: string]: StudentInfoForListView} - studentMap: {} // is used to map obfuscated student data back to the original + studentMap: { + [matrikel: string]: { + matrikelNo: string, name: string + } + } // is used to map obfuscated student data back to the original statistics: Statistics tutors: Array<Tutor> } diff --git a/requirements.txt b/requirements.txt index 3a910671..5f6f7175 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ django-cors-headers~=2.1.0 django-extensions~=2.1 -djangorestframework-csv~=2.0.0 djangorestframework-jwt~=1.11.0 djangorestframework~=3.8 git+https://github.com/robinhundt/djangorestframework-camel-case @@ -14,3 +13,4 @@ python-json-logger~=0.1.9 tqdm~=4.19.5 whitenoise~=3.3.1 xlrd~=1.0.0 +xkcdpass==1.16.5 -- GitLab