diff --git a/.gitignore b/.gitignore index ed444c0ce60790240ee8ec62f24b6b8475010158..391b49d59820a17e3c99b2315449c5e0d86dbd12 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.pot *.py[co] .tox/ +*.ipynb +.ipynb_checkpoints/ __pycache__ MANIFEST .coverage diff --git a/core/urls.py b/core/urls.py index ecdca6469797922d942ac56662e7cae62901f6c8..980b803769fe1cf7ae497d738fd44908048b09eb 100644 --- a/core/urls.py +++ b/core/urls.py @@ -23,6 +23,7 @@ router.register('assignment', views.AssignmentApiViewSet) router.register('statistics', views.StatisticsEndpoint, basename='statistics') router.register('user', views.UserAccountViewSet, basename='user') router.register('label', views.LabelApiViewSet, basename='label') +router.register('label-statistics', views.LabelStatistics, basename='label-statistics') schema_view = get_schema_view( openapi.Info( diff --git a/core/views/__init__.py b/core/views/__init__.py index 96c0465ab8f4b28b174100e09cbed5c0a8a43ab2..64f568df3ea9115cdb646beec0cefb017d6d9855 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -2,4 +2,4 @@ from .feedback import FeedbackApiView, FeedbackCommentApiView # noqa from .subscription import SubscriptionApiViewSet, AssignmentApiViewSet # noqa from .common_views import * # noqa from .export import StudentJSONExport, InstanceExport # noqa -from .label import LabelApiViewSet # noqa +from .label import LabelApiViewSet, LabelStatistics # noqa diff --git a/core/views/common_views.py b/core/views/common_views.py index c4a84262962a9f32813a6e20ce7886f3fc738f9b..3c945d76c3790b81bcdd282483d3c454f4343f82 100644 --- a/core/views/common_views.py +++ b/core/views/common_views.py @@ -127,9 +127,11 @@ class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet): """ Gets a list or a detail view of a single SubmissionType """ queryset = SubmissionType.objects.all() serializer_class = SubmissionTypeSerializer + permission_classes = (IsTutorOrReviewer, ) class StatisticsEndpoint(viewsets.ViewSet): + permission_classes = (IsTutorOrReviewer, ) def list(self, request, *args, **kwargs): first_sub_type = models.SubmissionType.objects.first() diff --git a/core/views/label.py b/core/views/label.py index c1b4a02a6266a4056d6234bf3b9997b5afbe98e5..e7ffa3928d5931fd28d5660e5093dc66dcee944b 100644 --- a/core/views/label.py +++ b/core/views/label.py @@ -1,8 +1,12 @@ import logging +from django.db.models import Case, When, IntegerField, Sum, Q + from rest_framework import mixins, viewsets +from rest_framework.response import Response from core import models, permissions, serializers +from core.models import SubmissionType, FeedbackLabel log = logging.getLogger(__name__) @@ -14,3 +18,32 @@ class LabelApiViewSet(viewsets.GenericViewSet, permission_classes = (permissions.IsTutorOrReviewer, ) queryset = models.FeedbackLabel.objects.all() serializer_class = serializers.LabelSerializer + + +class LabelStatistics(viewsets.ViewSet): + + permission_classes = (permissions.IsTutorOrReviewer, ) + + def list(self, *args, **kwargs): + # TODO This is horribly ugly and should be killed with fire + # however, i'm unsure whether there is a better way to retrieve the + # information that hits the database less often + labels = FeedbackLabel.objects.all() + + counts = list(SubmissionType.objects.annotate( + **{str(label.pk): Sum( + Case( + # if the feedback has a label or there is a visible comment with that + # label add 1 to the count + When( + Q(submissions__feedback__labels=label) | + Q(submissions__feedback__feedback_lines__labels=label) & + Q(submissions__feedback__feedback_lines__visible_to_student=True), + then=1), + output_field=IntegerField(), + default=0 + ) + ) for label in labels} + ).values('pk', *[str(label.pk) for label in labels])) + + return Response(list(counts)) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a96d779f563aa4eec09f9a14733999aaeb3a7985..e34d47a3196f4b44edef05ac91be395ae492ac03 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,7 +11,8 @@ import { Submission, SubmissionNoType, SubmissionType, Subscription, - Tutor, UserAccount, FeedbackLabel + Tutor, UserAccount, LabelStatisticsForSubType, + FeedbackLabel } from '@/models' function getInstanceBaseUrl (): string { @@ -113,6 +114,11 @@ export async function fetchStatistics (): Promise<Statistics> { return (await ax.get(url)).data } +export async function fetchLabelStatistics (): Promise<LabelStatisticsForSubType []> { + const url = '/api/label-statistics' + return (await ax.get(url)).data +} + interface SubscriptionCreatePayload { queryType: Subscription.QueryTypeEnum queryKey?: string diff --git a/frontend/src/components/CorrectionStatistics.vue b/frontend/src/components/CorrectionStatistics.vue index b720c8525dfda37fb90940199d3964f89fd893a7..fe55e9fa8483cf674d6dcc068d127b74d7d854a4 100644 --- a/frontend/src/components/CorrectionStatistics.vue +++ b/frontend/src/components/CorrectionStatistics.vue @@ -1,34 +1,34 @@ <template> - <v-card class="py-2" id="correction-statistics"> - <v-card-title> - <span class="title">Statistics</span> - </v-card-title> - <div v-if="loaded"> - <ul class="inline-list mx-3"> - <li>Submissions per participant: <span>{{statistics.submissionsPerStudent}}</span></li> - <li>Submissions per type: <span>{{statistics.submissionsPerType}}</span></li> - <li>Curr. mean score: - <span> - {{statistics.currentMeanScore === null ? 'N.A.' : statistics.currentMeanScore.toFixed(2)}} - </span> - </li> - </ul> - <v-divider class="mx-2 my-2"></v-divider> - <div v-for="(progress, index) in statistics.submissionTypeProgress" :key="index"> - <v-card-title class="py-0"> - {{progress.name}} - </v-card-title> - <div class="mx-3"> - <v-progress-linear - :value="progress.feedbackFinal / progress.submissionCount * 100" - buffer - :buffer-value="(progress.feedbackInValidation + progress.feedbackFinal) * 100 / progress.submissionCount" - :color="progress.feedbackFinal === progress.submissionCount ? 'green' : 'blue'" - /> - </div> + <v-card class="py-2" id="correction-statistics"> + <v-card-title> + <span class="title">Statistics</span> + </v-card-title> + <div v-if="loaded"> + <ul class="inline-list mx-3"> + <li>Submissions per participant: <span>{{statistics.submissionsPerStudent}}</span></li> + <li>Submissions per type: <span>{{statistics.submissionsPerType}}</span></li> + <li>Curr. mean score: + <span> + {{statistics.currentMeanScore === null ? 'N.A.' : statistics.currentMeanScore.toFixed(2)}} + </span> + </li> + </ul> + <v-divider class="mx-2 my-2"></v-divider> + <div v-for="(progress, index) in statistics.submissionTypeProgress" :key="index"> + <v-card-title class="py-0"> + {{progress.name}} + </v-card-title> + <div class="mx-3"> + <v-progress-linear + :value="progress.feedbackFinal / progress.submissionCount * 100" + buffer + :buffer-value="(progress.feedbackInValidation + progress.feedbackFinal) * 100 / progress.submissionCount" + :color="progress.feedbackFinal === progress.submissionCount ? 'green' : 'blue'" + /> </div> </div> - </v-card> + </div> + </v-card> </template> <script> diff --git a/frontend/src/components/LabelStatistics.vue b/frontend/src/components/LabelStatistics.vue new file mode 100644 index 0000000000000000000000000000000000000000..d7c4a23205f3a4a77ac2feebb48499e2d7cc1250 --- /dev/null +++ b/frontend/src/components/LabelStatistics.vue @@ -0,0 +1,131 @@ +<template> + <v-card> + <v-card-title class="title">Accumulated Label Statistics</v-card-title> + <v-data-table + :headers=headers + :pagination="pagination" + :loading="loading" + :items="summedLabelCounts" + > + <template slot="items" slot-scope="props"> + <td>{{ props.item[0] }}</td> + <td class="text-xs-center">{{ props.item[1] }}</td> + </template> + </v-data-table> + + <div v-for="([subType, labelCounts]) in mappedLabelCounts" :key="subType"> + <v-card-title class="title"> + Statistics for: {{ subType }} + </v-card-title> + <v-data-table + :headers=headers + :pagination="pagination" + :loading="loading" + :items="labelCounts" + > + <template slot="items" slot-scope="props"> + <td>{{ props.item[0] }}</td> + <td class="text-xs-center">{{ props.item[1] }}</td> + </template> + </v-data-table> + + </div> + </v-card> +</template> + + +<script lang="ts"> +import Vue from 'vue' +import Component from 'vue-class-component' +import * as api from '@/api' +import { LabelStatisticsForSubType } from '../models'; +import { getters } from '../store/getters'; +import { FeedbackLabels } from '../store/modules/feedback-labels'; + + +@Component +export default class LabelStatistics extends Vue{ + labelStatistics: LabelStatisticsForSubType[] = [] + timer = 0 + + pagination = { + descending: true, + sortBy: '[1]', + rowsPerPage: -1 + } + + headers = [ + { + text: 'Label', + align: 'left', + sortable: true, + value: '[0]' + }, + { + text: 'Count', + align: 'center', + sortable: true, + value: '[1]' + } + ] + + get loading(): boolean { + return this.labelStatistics.length == 0 + } + + get summedLabelCounts () { + const summedLabelCounts = this.labelStatistics + .reduce((acc: {[labelPk: string]: number}, curr) => { + Object.entries(curr) + .filter(([key, val]) => key !== 'pk') + .forEach(([labelPk, count]: [string, number]) => { + if (!acc[labelPk]) { + acc[labelPk] = 0 + } + acc[labelPk] += count + }) + return acc + }, {}) + // TODO map label pks to names + const mappedLabelCounts = this.mapLabelList(Object + .entries(summedLabelCounts)) + return mappedLabelCounts + } + + get mappedLabelCounts () { + return this.labelStatistics.map(labelStatistics => { + const labelValues = Object + .entries(labelStatistics) + .filter(([key, val]) => key !== 'pk') + const subTypeName = getters.submissionType(labelStatistics.pk).name + return [subTypeName, this.mapLabelList(labelValues)] + }) + } + + mapLabelList (labelList: [string, number][]) { + return labelList.map(entry => { + const label = FeedbackLabels.state.labels.find(label => { + return String(label.pk) === entry[0] + }) + const labelName = label ? label.name : 'Unknown label' + return [labelName, entry[1]] + }) + } + + async loadLabelStatistics () { + this.labelStatistics = await api.fetchLabelStatistics() + } + + created () { + this.timer = setInterval(() => { + this.loadLabelStatistics() + }, 10 * 1e3) + this.loadLabelStatistics() + } + + beforeDestroy () { + clearInterval(this.timer) + } + +} +</script> diff --git a/frontend/src/components/student_list/StudentList.vue b/frontend/src/components/student_list/StudentList.vue index 3e7d1c055d5434aa28c3e4a312944fe59b58d2fb..7a119300874abf9d7f7e8164bfdd24e30f94076f 100644 --- a/frontend/src/components/student_list/StudentList.vue +++ b/frontend/src/components/student_list/StudentList.vue @@ -123,7 +123,7 @@ export default { search: '', pagination: { sortBy: 'name', - rowsPerPage: Infinity + rowsPerPage: -1 }, staticHeaders: [ { diff --git a/frontend/src/components/subscriptions/SubscriptionList.vue b/frontend/src/components/subscriptions/SubscriptionList.vue index ebe70f12da4de1e0498dd2aad94922c0a1710bcc..07dc0daee503539481dde18330fb0271f0dfe738 100644 --- a/frontend/src/components/subscriptions/SubscriptionList.vue +++ b/frontend/src/components/subscriptions/SubscriptionList.vue @@ -70,12 +70,6 @@ export default class SubscriptionList extends Vue { return subscriptions } - mounted() { - this.timer = setInterval(() => { - this.getSubscriptions(true) - }, 30 * 1e3) - } - beforeDestroy() { clearInterval(this.timer) } @@ -83,6 +77,11 @@ export default class SubscriptionList extends Vue { created() { const submissionTypes = actions.updateSubmissionTypes() const subscriptions = Subscriptions.getSubscriptions() + + this.timer = setInterval(() => { + this.getSubscriptions(true) + }, 30 * 1e3) + Promise.all([submissionTypes, subscriptions]).then(() => { Subscriptions.subscribeToAll() Subscriptions.cleanAssignmentsFromSubscriptions(true) diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 6186106764718d559b974315ffe6aaf09bcc0b8e..302089d313ff93ad633dc27f50a2c0572effc8a6 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -230,6 +230,13 @@ export interface Statistics { submissionTypeProgress: Array<SubmissionTypeProgress> } +export interface LabelStatisticsForSubType { + /** Contains the count of the different labels under their pk */ + [label_pk: number]: number, + /** The pk of the corresponding SubmissionType */ + pk: string +} + /** * * @export diff --git a/frontend/src/pages/Statistics.vue b/frontend/src/pages/Statistics.vue new file mode 100644 index 0000000000000000000000000000000000000000..92a69ce16e905e971a9588935b5e298154c9bc0c --- /dev/null +++ b/frontend/src/pages/Statistics.vue @@ -0,0 +1,29 @@ +<template> + <div> + <v-layout row class="ma-2"> + <v-flex xs5> + <label-statistics/> + </v-flex> + <v-flex offset-xs1 xs5> + <correction-statistics/> + </v-flex> + </v-layout> + </div> +</template> + +<script lang="ts"> +import Vue from 'vue' +import Component from 'vue-class-component' + +import CorrectionStatistics from '@/components/CorrectionStatistics.vue' +import LabelStatistics from '@/components/LabelStatistics.vue' + + +@Component({ + components: {CorrectionStatistics, LabelStatistics} +}) +export default class Statistics extends Vue { + + +} +</script> diff --git a/frontend/src/pages/base/TutorReviewerBaseLayout.vue b/frontend/src/pages/base/TutorReviewerBaseLayout.vue index 5f4899ffab4b2f98314c7ac5cd69133ba446d5eb..68ceaf80e679c7250cf30cc5626a33db86668cc6 100644 --- a/frontend/src/pages/base/TutorReviewerBaseLayout.vue +++ b/frontend/src/pages/base/TutorReviewerBaseLayout.vue @@ -51,6 +51,11 @@ export default { name: 'Feedback History', icon: 'feedback', route: '/feedback' + }, + { + name: 'Statistics', + icon: 'bar_chart', + route: 'statistics' } ] } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a93b5d8068c08f9d8b5b133d5444b25be6849c84..a7e8d4fbc18889e70be0307253194f5b0c4b69bf 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,7 @@ import SubscriptionWorkPage from '@/pages/SubscriptionWorkPage.vue' import SubscriptionEnded from '@/components/subscriptions/SubscriptionEnded.vue' import PageNotFound from '@/pages/PageNotFound.vue' import StartPageSelector from '@/pages/StartPageSelector.vue' +import Statistics from '@/pages/Statistics.vue' import LayoutSelector from '@/pages/LayoutSelector.vue' import StudentSubmissionSideView from '@/pages/StudentSubmissionSideView.vue' import StudentListHelpCard from '@/components/student_list/StudentListHelpCard.vue' @@ -91,6 +92,11 @@ const router = new Router({ name: 'subscription-ended', component: SubscriptionEnded }, + { + path: 'statistics', + name: 'statistics', + component: Statistics + }, { path: 'feedback', beforeEnter: tutorOrReviewerOnly, diff --git a/frontend/yarn.lock b/frontend/yarn.lock index dc391a2453ff7cb2e473d8c468e24f711190be98..57fa3fd913d56cd8c7421ca7bb8f3bd1cff2c17a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7772,11 +7772,6 @@ vue-style-loader@^4.1.0: hash-sum "^1.0.2" loader-utils "^1.0.2" -vue-swatches@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/vue-swatches/-/vue-swatches-1.0.3.tgz#84eb23ba99bbbb0d56698f3a8bdb1b340203d33b" - integrity sha512-3J+Nc3bisvhhp0BW0pfTbQvdl3i+dhwoPjoM+2D6R6hW65KNpXOA+sJwcSg2j1Xd6fLcOV7LMb2sz6s4vKSWRg== - vue-template-compiler@^2.5.16: version "2.5.22" resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.22.tgz#c3d3c02c65f1908205c4fbd3b0ef579e51239955"