diff --git a/aleksis/apps/alsijil/checks.py b/aleksis/apps/alsijil/checks.py index 310e5d2dbc17cbe95d89f81a4aa83fb0ef7f25b0..a440f52da0888a3a97f752affd038c0fff583244 100644 --- a/aleksis/apps/alsijil/checks.py +++ b/aleksis/apps/alsijil/checks.py @@ -98,7 +98,7 @@ class DocumentationOnHolidaysDataCheck(DataCheck): @classmethod def check_data(cls): - from aleksis.apps.chronos.models import Holiday + from aleksis.core.models import Holiday from .models import Documentation @@ -132,9 +132,9 @@ class ParticipationStatusPersonalNoteOnHolidaysDataCheck(DataCheck): @classmethod def check_data(cls): - from aleksis.apps.chronos.models import Holiday + from aleksis.core.models import Holiday - from .models import NewPersonalNote, ParticipationStatus + from .models import ParticipationStatus holidays = Holiday.objects.all() @@ -146,12 +146,7 @@ class ParticipationStatusPersonalNoteOnHolidaysDataCheck(DataCheck): ) participation_statuses = ParticipationStatus.objects.filter(q) - personal_notes = NewPersonalNote.objects.filter(q) for status in participation_statuses: logging.info(f"Participation status {status} is on holidays") cls.register_result(status) - - for note in personal_notes: - logging.info(f"Personal note {note} is on holidays") - cls.register_result(note) diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue index 3248becfa4ddeaaaf998794f5398d5845f38d21f..2c1479f73a0de45c1c687fea455514e3ba5af93a 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue @@ -288,6 +288,25 @@ export default { <span v-t="'actions.back_to_overview'" /> </v-tooltip> {{ item.person.fullName }} + <v-spacer /> + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <v-btn + v-bind="attrs" + v-on="on" + icon + :to="{ + name: 'core.personById', + params: { + id: item.person.id, + }, + }" + > + <v-icon>mdi-open-in-new</v-icon> + </v-btn> + </template> + {{ $t("actions.open_person_page", item.person) }} + </v-tooltip> </v-card-title> <v-card-text> <absence-reason-group-select @@ -301,7 +320,7 @@ export default { v-bind="documentationPartProps" :loading="loading" :disabled="loading" - :participation="item" + :participations="[item]" :value="item.tardiness" @input="sendToServer([item], 'tardiness', $event)" /> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForGroupTab.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForGroupTab.vue index e5eb12c62ccadcde90e51fa9b3f24865660bb830..f97e7b7f62b4ccee537ccb11a73bfb976c8c48d3 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForGroupTab.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForGroupTab.vue @@ -10,11 +10,6 @@ :show-select="false" @items="items = $event" > - <!-- <template #header.person="header">--> - <!-- Hello world--> - <!-- {{ header }}--> - <!-- </template>--> - <template v-for="(extraMark, index) in extraMarks" #[`extraMarks.${index}.count`]="{ item }" @@ -55,11 +50,13 @@ <template #tardinessCount="{ item }"> <v-chip dense outlined class="me-2"> <v-icon left>mdi-chart-line-variant</v-icon> - {{ item.tardinessCount }}× + {{ $tc("alsijil.personal_notes.times_late", item.tardinessCount) }} </v-chip> - <v-chip dense outlined> + <v-chip dense outlined v-if="item.tardinessSum"> <v-icon left>mdi-sigma</v-icon> - {{ $tc("time.minutes_n", item.tardinessSum, { n: $n(item.tardinessSum) }) }} + {{ + $tc("time.minutes_n", item.tardinessSum, { n: $n(item.tardinessSum) }) + }} </v-chip> </template> @@ -67,6 +64,13 @@ <secondary-action-button i18n-key="alsijil.personal_notes.statistics.person_view_details" icon-text="mdi-open-in-new" + :to="{ + name: 'alsijil.coursebook_statistics', + params: { + personId: item.person.id, + schoolTermId: schoolTerm.id, + }, + }" /> </template> </c-r-u-d-list> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue index 751e6b90d15599ac113544b3698a30ac736e80ce..b857a1ba59b7a61263bf2cfc6f2c7ac6e8af102a 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue @@ -1,6 +1,9 @@ <template> <v-card> - <v-skeleton-loader v-if="$apollo.loading" type="card-heading" /> + <v-skeleton-loader + v-if="$apollo.queries.statistics.loading" + type="card-heading" + /> <v-card-title v-else-if="compact"> {{ $t("alsijil.coursebook.statistics.person_compact.title") }} <v-spacer /> @@ -8,11 +11,17 @@ :icon="true" icon-text="mdi-open-in-new" i18n-key="" - @click="switchToOwnPage" + :to="{ + name: 'alsijil.coursebook_statistics', + params: { + personId: person.id, + schoolTermId: schoolTerm.id, + }, + }" /> </v-card-title> <v-card-title v-else> - {{ $t("alsijil.coursebook.statistics.person_page.title") }} + {{ $t("alsijil.coursebook.statistics.title_plural") }} </v-card-title> <v-card-text> @@ -26,17 +35,12 @@ style="grid-area: tardinesses" :tardiness-sum="statistics.tardinessSum" :tardiness-count="statistics.tardinessCount" - :loading="$apollo.loading" + :loading="$apollo.queries.statistics.loading" /> <statistics-extra-marks-card style="grid-area: extra_marks" :extra-marks="statistics.extraMarks" - :loading="$apollo.loading" - /> - <statistics-personal-notes-list - v-if="compact" - style="grid-area: personal_notes" - :loading="$apollo.loading" + :loading="$apollo.queries.statistics.loading" /> </div> </v-card-text> @@ -49,7 +53,6 @@ import BaseButton from "aleksis.core/components/generic/buttons/BaseButton.vue"; import StatisticsAbsencesCard from "./StatisticsAbsencesCard.vue"; import StatisticsTardinessCard from "./StatisticsTardinessCard.vue"; import StatisticsExtraMarksCard from "./StatisticsExtraMarksCard.vue"; -import StatisticsPersonalNotesList from "./StatisticsPersonalNotesList.vue"; import { statisticsByPerson } from "./statistics.graphql"; @@ -61,7 +64,6 @@ export default { StatisticsAbsencesCard, StatisticsTardinessCard, StatisticsExtraMarksCard, - StatisticsPersonalNotesList, }, props: { compact: { @@ -96,28 +98,14 @@ export default { }, }, }, - methods: { - switchToOwnPage() { - this.$router.push({ - name: "alsijil.coursebook_statistics", - params: { - personId: this.person.id, - schoolTermId: this.schoolTerm.id, - }, - // TODO: Add where we came from as get parameter if - // meeting decided that own page. - }); - }, - }, computed: { gridTemplateAreas() { return this.compact - ? `"absences tardinesses extra_marks" - "personal_notes personal_notes personal_notes"` + ? `"absences extra_marks" "tardinesses tardinesses"` : `"absences" "tardinesses" "extra_marks"`; }, gridTemplateColumnsNum() { - return this.compact ? 3 : 1; + return this.compact ? 2 : 1; }, }, }; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue index e2f68e81e54072dc8bf90913b4941cae8dcab99d..7df07c24db4ea90f66268135272a5fe8e634b9e4 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue @@ -1,98 +1,174 @@ <template> - <div class="d-flex" style="gap: 4em"> - <!-- TODO: header (close, title, print) --> - <!-- TODO: flex-grow-1 does a little & flex-shrink-1 does nothing --> - <div class="flex-grow-1"> - <!-- school-term-select --> - <school-term-field v-model="schoolTerm" :enable-create="false" /> - <!-- documentations for person list --> - <c-r-u-d-iterator - i18n-key="TODO" - :gql-query="gqlQuery" - :gql-additional-query-args="gqlQueryArgs" - :enable-create="false" - :enable-edit="false" - :elevated="false" - > - <template #additionalActions> - <v-btn-toggle v-model="mode" mandatory color="secondary" rounded dense> - <v-btn outlined :value="MODE.PARTICIPATIONS"> - {{ $t("alsijil.coursebook.absences.absences") }} + <fullscreen-dialog-page + :fallback-url="{ name: 'core.personById', props: { id: personId } }" + > + <div class="d-flex" style="gap: 4em"> + <!-- TODO: header (close, title, print) --> + <!-- TODO: flex-grow-1 does a little & flex-shrink-1 does nothing --> + <div class="flex-grow-1" style="max-width: 100%"> + <!-- school-term-select --> + <school-term-field v-model="schoolTerm" :enable-create="false" /> + <!-- documentations for person list --> + <c-r-u-d-iterator + i18n-key="alsijil.coursebook.statistics" + :gql-query="gqlQuery" + :gql-additional-query-args="gqlQueryArgs" + :enable-create="false" + :enable-edit="false" + :elevated="false" + > + <template #additionalActions> + <v-btn-toggle + v-model="mode" + mandatory + color="secondary" + rounded + dense + > + <v-btn outlined :value="MODE.PARTICIPATIONS"> + {{ $t("alsijil.coursebook.absences.absences") }} + </v-btn> + <v-btn outlined :value="MODE.PERSONAL_NOTES"> + {{ $t("alsijil.personal_notes.personal_notes") }} + </v-btn> + </v-btn-toggle> + <v-btn + v-if="$vuetify.breakpoint.mobile" + rounded + dense + outlined + text + @click="statisticsBottomSheet = !statisticsBottomSheet" + > + {{ $t("alsijil.personal_notes.statistics.person_page.summary") }} </v-btn> - <v-btn outlined :value="MODE.PERSONAL_NOTES"> - {{ $t("alsijil.personal_notes.personal_notes") }} - </v-btn> - </v-btn-toggle> - </template> - <template #default="{ items }"> - <v-list-item v-for="item in items" :key="item.id" ripple> - <v-list-item-content> - <v-list-item-title> - <!-- date & timeslot --> - <time :datetime="item.relatedDocumentation.datetimeStart" class="text-no-wrap"> - {{ $d($parseISODate(item.relatedDocumentation.datetimeStart), "short") }} - </time> + </template> + <template #default="{ items }"> + <v-list> + <v-list-item v-for="item in items" :key="item.id" ripple> + <v-list-item-content> + <v-list-item-title> + <!-- date & timeslot --> + <time + :datetime="item.relatedDocumentation.datetimeStart" + class="text-no-wrap" + > + {{ + $d( + $parseISODate( + item.relatedDocumentation.datetimeStart, + ), + "short", + ) + }} + </time> - <time :datetime="item.relatedDocumentation.datetimeStart" class="text-no-wrap"> - {{ $d($parseISODate(item.relatedDocumentation.datetimeStart), "shortTime") }} - </time> - <span>-</span> - <time :datetime="item.relatedDocumentation.datetimeEnd" class="text-no-wrap"> - {{ $d($parseISODate(item.relatedDocumentation.datetimeEnd), "shortTime") }} - </time> - <!-- teacher --> - <person-chip - v-for="teacher in item.relatedDocumentation.teachers" - :person="teacher" - no-link - small - /> - <!-- group --> - <span> - {{ item.groupShortName }} - </span> - <!-- subject --> - <subject-chip :subject="item.relatedDocumentation.subject" small /> - </v-list-item-title> - <v-list-item-subtitle> - {{ item.note }} - </v-list-item-subtitle> - </v-list-item-content> - <v-list-item-action> - <!-- chips: absences & extraMarks --> - <absence-reason-chip - v-if="item.absenceReason" - :absenceReason="item.absenceReason" - /> - <extra-mark-chip - v-if="item.extraMark" - :extra-mark="item.extraMark" - /> - </v-list-item-action> - </v-list-item> - <v-divider></v-divider> - </template> - </c-r-u-d-iterator> + <time + :datetime="item.relatedDocumentation.datetimeStart" + class="text-no-wrap" + > + {{ + $d( + $parseISODate( + item.relatedDocumentation.datetimeStart, + ), + "shortTime", + ) + }} + </time> + <span>-</span> + <time + :datetime="item.relatedDocumentation.datetimeEnd" + class="text-no-wrap" + > + {{ + $d( + $parseISODate(item.relatedDocumentation.datetimeEnd), + "shortTime", + ) + }} + </time> + </v-list-item-title> + <v-list-item-subtitle class="overflow-scroll"> + <!-- teacher --> + <person-chip + v-for="teacher in item.relatedDocumentation.teachers" + :key="teacher.id" + :person="teacher" + no-link + small + /> + <!-- group --> + <span> + {{ item.groupShortName }} + </span> + <!-- subject --> + <subject-chip + :subject="item.relatedDocumentation.subject" + small + /> + </v-list-item-subtitle> + </v-list-item-content> + <v-list-item-action> + <!-- chips: absences & extraMarks --> + <absence-reason-chip + v-if="item.absenceReason" + :absence-reason="item.absenceReason" + /> + <extra-mark-chip + v-if="item.extraMark" + :extra-mark="item.extraMark" + /> + <div v-if="item.note"> + {{ item.note }} + </div> + </v-list-item-action> + </v-list-item> + </v-list> + <v-divider></v-divider> + </template> + </c-r-u-d-iterator> + </div> + <statistics-for-person-card + v-if="!$vuetify.breakpoint.mobile" + class="flex-shrink-1" + :compact="false" + :person="{ id: personId }" + :school-term="{ id: schoolTermId }" + /> + <v-bottom-sheet v-model="statisticsBottomSheet" v-else> + <statistics-for-person-card + :compact="false" + :person="{ id: personId }" + :school-term="{ id: schoolTermId }" + /> + </v-bottom-sheet> </div> - <statistics-for-person-card - class="flex-shrink-1" - :compact="false" - :person="{ id: personId }" - :school-term="{ id: schoolTermId }" - /> - </div> + <template #actions="{ toolbar }"> + <!-- TODO: add functionality --> + <v-btn v-if="toolbar" icon color="primary" disabled> + <v-icon>$print</v-icon> + </v-btn> + <FabButton v-else icon-text="$print" i18n-key="actions.print" disabled /> + </template> + </fullscreen-dialog-page> </template> <script> import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue"; import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField.vue"; import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; +import FabButton from "aleksis.core/components/generic/buttons/FabButton.vue"; +import FullscreenDialogPage from "aleksis.core/components/generic/dialogs/FullscreenDialogPage.vue"; import PersonChip from "aleksis.core/components/person/PersonChip.vue"; import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; import StatisticsForPersonCard from "./StatisticsForPersonCard.vue"; -import { participationsOfPerson, personalNotesForPerson } from "./statistics.graphql"; -import { DateTime } from "luxon"; +import { + participationsOfPerson, + personalNotesForPerson, + personName, +} from "./statistics.graphql"; import ExtraMarkChip from "../../extra_marks/ExtraMarkChip.vue"; const MODE = { @@ -107,6 +183,8 @@ export default { AbsenceReasonChip, SchoolTermField, CRUDIterator, + FabButton, + FullscreenDialogPage, PersonChip, SubjectChip, StatisticsForPersonCard, @@ -122,9 +200,27 @@ export default { required: true, }, }, + apollo: { + personName: { + query: personName, + variables() { + return { + person: this.personId, + }; + }, + result({ data }) { + this.$setToolBarTitle( + this.$t("alsijil.coursebook.statistics.person_page.title", { + fullName: data.personName.fullName || "???", + }), + ); + }, + }, + }, data() { return { mode: MODE.PARTICIPATIONS, + statisticsBottomSheet: false, }; }, computed: { @@ -143,12 +239,14 @@ export default { }, set(value) { console.log("New SchoolTerm:", value); - } + }, }, }, methods: { gqlQuery() { - return this.mode === MODE.PERSONAL_NOTES ? personalNotesForPerson : participationsOfPerson; + return this.mode === MODE.PERSONAL_NOTES + ? personalNotesForPerson + : participationsOfPerson; }, }, }; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue deleted file mode 100644 index 5bb820d8c0a7d2b24d6ce7c7754cf51c1c1bb310..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> - <v-skeleton-loader v-if="loading" type="card" /> - <v-card v-else> - <v-virtual-scroll :items="personalNotes" height="150" item-height="75"> - <template v-slot:default="{ item }"> - <v-list-item :key="item"> - <v-list-item-content> - <v-list-item-title class="d-flex"> - <!-- new_personal_note.documentation.course.groups.FORALL.shortName --> - <div>5a</div> - <!-- new_personal_note.documentation.subject/amends.subject --> - <!-- TODO: In subject-chip --> - <div>Ma</div> - <v-spacer /> - <!-- new_personal_note.documentation.datetimeStart.toDate() --> - <div> - <v-list-item-subtitle> 01.01.2031 </v-list-item-subtitle> - </div> - </v-list-item-title> - <v-list-item-subtitle> - <!-- new_personal_note.note --> - Hier hat der Lehrer was notiert. - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - <v-divider></v-divider> - </template> - </v-virtual-scroll> - </v-card> -</template> - -<script> -export default { - name: "StatisticsPersonalNotesList", - props: { - loading: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - personalNotes: [1, 2, 3, 4], - }; - }, - // Mock for personalNotesQuery - // apollo: { - // personalNotes: { - // query: XXXX, - // }, - // }, -}; -</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue index 275515f0f1b822d9b1da1dfe5fcbef264e242e6f..3103208fb39190ccb61096bfc4940a53d7271be7 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue @@ -1,28 +1,36 @@ <template> <v-skeleton-loader v-if="loading" type="card" /> - <v-card v-else class="text-center"> - <v-card-text - class="d-flex flex-column align-center justify-center fill-height" - > - <div class="text-h2"> - {{ $n(tardinessCount) }} - </div> - <div class="text-subtitle-2"> - {{ $tc("alsijil.personal_notes.tardiness_n", tardinessCount) }} - </div> - <div class="text-caption"> - <!-- TODO: Show average tardiness instead of sum like mock-up? --> - <div> - <v-icon small>mdi-sigma</v-icon> - {{ $tc("time.minutes_n", tardinessSum, { n: $n(tardinessSum) }) }} - </div> - <div> - <v-icon small>mdi-diameter-variant</v-icon> - {{ - $tc("time.minutes_n", tardinessSum / tardinessCount, { n: $n(tardinessSum/tardinessCount) }) - }} - </div> - </div> + <v-card v-else> + <v-card-text> + <v-row> + <v-col class="text-center"> + <div class="text-h2"> + {{ $n(tardinessCount) }} + </div> + <div class="text-subtitle-2"> + {{ $tc("alsijil.personal_notes.tardiness_n", tardinessCount) }} + </div> + </v-col> + + <v-col + class="text-caption d-flex flex-column justify-center align-center" + > + <div> + <div> + <v-icon small>mdi-sigma</v-icon> + {{ $tc("time.minutes_n", tardinessSum, { n: $n(tardinessSum) }) }} + </div> + <div> + <v-icon small>mdi-diameter-variant</v-icon> + {{ + $tc("time.minutes_n", tardinessSum / tardinessCount, { + n: $n(tardinessSum / tardinessCount), + }) + }} + </div> + </div> + </v-col> + </v-row> </v-card-text> </v-card> </template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql index 38d8817b83e1a673f38d26352a682a407e5b3aa3..68e0a08ca3804bf841fc25c57c99d4e4dc3f66ee 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql @@ -110,3 +110,10 @@ query statisticsByGroup($group: ID!, $term: ID) { # } } } + +query personName($person: ID!) { + personName: personById(id: $person) { + id + fullName + } +} diff --git a/aleksis/apps/alsijil/frontend/components/injectables/group_actions/OpenCoursebook.vue b/aleksis/apps/alsijil/frontend/components/injectables/group_actions/OpenCoursebook.vue new file mode 100644 index 0000000000000000000000000000000000000000..c52dedfc1f94497ba9a82dbd342a5b47dead2821 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/injectables/group_actions/OpenCoursebook.vue @@ -0,0 +1,30 @@ +<script> +import groupActionsMixin from "aleksis.core/components/group/actions/groupActionsMixin.js"; +export default { + name: "OpenCoursebook", + mixins: [groupActionsMixin], +}; +</script> + +<template> + <v-list-item + :to="{ + name: 'alsijil.coursebook', + params: { + filterType: 'all', + pageType: 'documentations', + objType: 'group', + objId: group.id, + }, + }" + > + <v-list-item-icon> + <v-icon>mdi-book-education-outline</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + {{ $t("actions.open_in_coursebook") }} + </v-list-item-title> + </v-list-item-content> + </v-list-item> +</template> diff --git a/aleksis/apps/alsijil/frontend/components/injectables/group_actions/ShowAbsences.vue b/aleksis/apps/alsijil/frontend/components/injectables/group_actions/ShowAbsences.vue new file mode 100644 index 0000000000000000000000000000000000000000..e582968e3c84d2e5afbd334233ee93138ef98540 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/injectables/group_actions/ShowAbsences.vue @@ -0,0 +1,30 @@ +<script> +import groupActionsMixin from "aleksis.core/components/group/actions/groupActionsMixin.js"; +export default { + name: "ShowAbsences", + mixins: [groupActionsMixin], +}; +</script> + +<template> + <v-list-item + :to="{ + name: 'alsijil.coursebook', + params: { + filterType: 'all', + pageType: 'absences', + objType: 'group', + objId: group.id, + }, + }" + > + <v-list-item-icon> + <v-icon>mdi-account-details-outline</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + {{ $t("actions.show_absences") }} + </v-list-item-title> + </v-list-item-content> + </v-list-item> +</template> diff --git a/aleksis/apps/alsijil/frontend/index.js b/aleksis/apps/alsijil/frontend/index.js index 03b8dec4843fa8b3a1f123399b6f3eb86ed5d058..6e16ad0a1873ad9be17ee842283c3283bd74e8b6 100644 --- a/aleksis/apps/alsijil/frontend/index.js +++ b/aleksis/apps/alsijil/frontend/index.js @@ -2,6 +2,20 @@ import { hasPersonValidator } from "aleksis.core/routeValidators"; import { DateTime } from "luxon"; export const collectionItems = { + coreGroupActions: [ + { + key: "alsijil-open-coursebook", + component: () => + import("./components/injectables/group_actions/OpenCoursebook.vue"), + isActive: () => true, + }, + { + key: "alsijil-show-absences", + component: () => + import("./components/injectables/group_actions/ShowAbsences.vue"), + isActive: () => true, + }, + ], coreGroupOverview: [ { tab: { diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index facb3b725571374274276671bdbcc3abbe32ebeb..8464a348cae51618341441d18639e00cbea0a066 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -1,6 +1,7 @@ { "actions": { - "back_to_overview": "Zurück zur Übersicht" + "back_to_overview": "Zurück zur Übersicht", + "open_person_page": "Detailansicht für {fullName} aufrufen" }, "alsijil": { "absence": { @@ -95,7 +96,8 @@ "title": "Kursbuch · Statistiken" }, "person_page": { - "title": "Statistiken" + "title": "Kursbuch · Statistiken · {fullName}", + "summary": "Zusammenfassung" } }, "print": { @@ -152,6 +154,7 @@ "lesson_length_exceeded": "Die Verspätung überschreitet die Stundenlänge.", "minutes_late": "pünktlich | eine Minute verspätet | {n} Minuten zu spät", "minutes_late_current": "pünktlich (basierend auf der aktuellen Uhrzeit) | eine Minute zu spät (basierend auf der aktuellen Uhrzeit) | {n} Minuten zu spät (basierend auf der aktuellen Uhrzeit)", + "times_late": "nie | 1× | {n}×", "note": "Notiz", "tardiness": "Verspätung", "tardiness_plural": "Verspätungen", diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json index f9d685a6fc8d31f5c5047c7668bc34f65b978b73..4b273e3a7374637ae796c7523a270f4aff4fd4bb 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -155,6 +155,7 @@ "late": "Late", "minutes_late": "on time | one minute late | {n} minutes late", "minutes_late_current": "on time (based on current time) | one minute late (based on current time) | {n} minutes late (based on current time)", + "times_late": "never | 1× | {n}×", "lesson_length_exceeded": "The tardiness exceeds the length of the lesson", "confirm_delete": "Delete note?", "confirm_delete_explanation": "The note \"{note}\" for {name} will be removed.", @@ -165,15 +166,20 @@ "title": "Coursebook · Statistics" }, "person_page": { - "title": "Statistics" + "title": "Coursebook · Statistics · {fullName}", + "summary": "Summary" }, - "person_view_details": "Details" + "person_view_details": "Details", + "title_plural": "Statistics" }, "personal_notes": "Personal Notes" } }, "actions": { - "back_to_overview": "Back to overview" + "back_to_overview": "Back to overview", + "open_person_page": "Open detail view for {fullName}", + "open_in_coursebook": "View Coursebook", + "show_absences": "Open absence list" }, "time": { "minutes": "minutes", diff --git a/aleksis/apps/alsijil/model_extensions.py b/aleksis/apps/alsijil/model_extensions.py index a43e54b952f13525b80dd51fe0c6f96bcf5d93de..4ad59b387532598ac392795a143e2acf90717fdb 100644 --- a/aleksis/apps/alsijil/model_extensions.py +++ b/aleksis/apps/alsijil/model_extensions.py @@ -57,7 +57,7 @@ def annotate_person_statistics( filter=Q(filtered_participation_statuses__absence_reason__count_as_absent=True), distinct=True, ), - tardiness_sum=Sum("filtered_participation_statuses__tardiness"), + tardiness_sum=Sum("filtered_participation_statuses__tardiness", distinct=True), tardiness_count=Count( "filtered_participation_statuses", filter=Q(filtered_participation_statuses__tardiness__gt=0), @@ -115,5 +115,13 @@ def annotate_person_statistics_for_school_term( datetime_end__date__lte=school_term.date_end, ) if group: - documentations.filter(Q(course__groups=group) | Q(course__groups__parent_groups=group)) + documentations = documentations.filter( + pk__in=Documentation.objects.filter(course__groups=group) + .values_list("pk", flat=True) + .union( + Documentation.objects.filter(course__groups__parent_groups=group).values_list( + "pk", flat=True + ) + ) + ) return annotate_person_statistics_from_documentations(persons, documentations) diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index b39b0d4b9a8ddadd23bcd7548ae178951d7ed0c5..48c7d1aca9d177fd54f21e844684aad25655f352 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -4,8 +4,7 @@ from typing import Optional, List from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied from django.db import models -from django.db.models import QuerySet -from django.db.models.query_utils import Q +from django.db.models import Q, QuerySet from django.http import HttpRequest from django.urls import reverse from django.utils import timezone @@ -175,22 +174,11 @@ class Documentation(CalendarEvent): doc = next(existing_documentations_event, None) if doc: - if ( - (incomplete and doc.topic) - or ( - not request.user.has_perm( - "alsijil.edit_participation_status_for_documentation_rule", doc - ) - and not doc.participations.filter( - person__pk=request.user.person.pk, absence_reason__isnull=False - ).exists() - ) - or ( - absences_exist - and ( - not doc.participations.all() - or not [d for d in doc.participations.all() if d.absence_reason] - ) + if (incomplete and doc.topic) or ( + absences_exist + and ( + not doc.participations.all() + or not [d for d in doc.participations.all() if d.absence_reason] ) ): continue @@ -441,20 +429,28 @@ class ParticipationStatus(CalendarEvent): @classmethod def get_objects( - cls, request: HttpRequest | None = None, params: dict[str, any] | None = None, **kwargs + cls, + request: HttpRequest | None = None, + params: dict[str, any] | None = None, + additional_filter: Q | None = None, + **kwargs, ) -> QuerySet: - qs = ( - super() - .get_objects(request, params, **kwargs) - .select_related("person", "absence_reason") - ) + q = additional_filter or Q() if params: if params.get("person"): - qs = qs.filter(person=params["person"]) + q = q & Q(person=params["person"]) elif params.get("persons"): - qs = qs.filter(person__in=params["persons"]) + q = q & Q(person__in=params["persons"]) elif params.get("group"): - qs = qs.filter(groups_of_person__in=params.get("group")) + q = q & Q(groups_of_person__in=params.get("group")) + qs = super().get_objects( + request, + params, + additional_filter=q, + select_related=["person", "absence_reason"], + **kwargs, + ) + return qs @classmethod diff --git a/aleksis/apps/alsijil/preferences.py b/aleksis/apps/alsijil/preferences.py index b0d8fbc42c9227e32c227bf3ac10ef9c1635fba6..0f3b25b24b70a5823830574cf724bc7cc8cfc9f6 100644 --- a/aleksis/apps/alsijil/preferences.py +++ b/aleksis/apps/alsijil/preferences.py @@ -14,6 +14,19 @@ from aleksis.core.registries import site_preferences_registry alsijil = Section("alsijil", verbose_name=_("Class register")) +@site_preferences_registry.register +class InheritPrivilegesFromParentGroup(BooleanPreference): + section = alsijil + name = "inherit_privileges_from_parent_group" + default = True + verbose_name = _( + "Grant the owner of a parent group the same privileges " + "as the owners of the respective child groups " + "in regard to group role management and generating " + "full printouts of class registers." + ) + + @site_preferences_registry.register class GroupOwnersCanAssignRolesToParents(BooleanPreference): section = alsijil @@ -64,6 +77,19 @@ class GroupTypesRegisterAbsence(ModelMultipleChoicePreference): ) +@site_preferences_registry.register +class GroupTypesViewPersonStatistics(ModelMultipleChoicePreference): + section = alsijil + name = "group_types_view_person_statistics" + required = False + default = [] + model = GroupType + verbose_name = _( + "User is allowed to view coursebook statistics for members " + "of groups the user is an owner of with these group types" + ) + + @site_preferences_registry.register class GroupTypePriorityCoursebook(ModelChoicePreference): section = alsijil diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py index 602663b519a0680ca88de3813ecbc38df64579a8..05466938856daa44b3d38933894f12583b8ba301 100644 --- a/aleksis/apps/alsijil/rules.py +++ b/aleksis/apps/alsijil/rules.py @@ -16,10 +16,10 @@ from .util.predicates import ( can_edit_personal_note, can_register_absence_for_at_least_one_group, can_register_absence_for_person, - can_view_any_documentation, can_view_documentation, can_view_participation_status, can_view_personal_note, + can_view_statistics_for_person, has_person_group_object_perm, is_course_group_owner, is_course_member, @@ -165,9 +165,7 @@ view_documentations_for_group_predicate = has_person & ( ) add_perm("alsijil.view_documentations_for_group_rule", view_documentations_for_group_predicate) -view_documentations_menu_predicate = has_person & ( - has_global_perm("alsijil.view_documentation") | can_view_any_documentation -) +view_documentations_menu_predicate = has_person add_perm("alsijil.view_documentations_menu_rule", view_documentations_menu_predicate) view_documentations_for_teacher_predicate = has_person & ( @@ -241,6 +239,24 @@ add_perm( edit_personal_note_predicate, ) +view_group_statistics_predicate = has_person & ( + has_global_perm("alsijil.view_participationstatus") | is_group_owner +) +add_perm( + "alsijil.view_group_statistics_rule", + view_group_statistics_predicate, +) + +view_person_statistics_predicate = has_person & ( + is_current_person + | has_global_perm("alsijil.view_participationstatus") + | can_view_statistics_for_person +) +add_perm( + "alsijil.view_person_statistics_rule", + view_person_statistics_predicate, +) + # View parent menu entry view_menu_predicate = has_person & (view_documentations_menu_predicate | view_extramarks_predicate) add_perm( diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index ab2df1d2d6a63a116ce1bc53038b0ce049c2f75d..740aa1ff271e9e4af374f89c7b96c0e57c06a842 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -1,6 +1,5 @@ from datetime import datetime -from django.core.exceptions import PermissionDenied from django.db.models import BooleanField, ExpressionWrapper, Q import graphene @@ -17,7 +16,7 @@ from aleksis.core.schema.person import PersonType from aleksis.core.util.core_helpers import get_site_preferences, has_person from ..model_extensions import annotate_person_statistics_for_school_term -from ..models import Documentation, ParticipationStatus, NewPersonalNote +from ..models import Documentation, NewPersonalNote, ParticipationStatus from .absences import ( AbsencesForPersonsCreateMutation, ) @@ -101,7 +100,13 @@ class Query(graphene.ObjectType): def resolve_documentations_by_course_id(root, info, course_id, **kwargs): documentations = Documentation.objects.filter( - Q(course__pk=course_id) | Q(amends__course__pk=course_id) + pk__in=Documentation.objects.filter(course_id=course_id) + .values_list("id", flat=True) + .union( + Documentation.objects.filter(amends__course_id=course_id).values_list( + "id", flat=True + ) + ) ) return documentations @@ -137,7 +142,7 @@ class Query(graphene.ObjectType): ) ) ): - raise PermissionDenied() + return [] # Find all LessonEvents for all Lessons of this Course in this date range event_params = { @@ -175,16 +180,22 @@ class Query(graphene.ObjectType): if person: person = Person.objects.get(pk=person) if not info.context.user.has_perm("core.view_person_rule", person): - raise PermissionDenied() + return [] elif has_person(info.context.user): person = info.context.user.person else: - raise PermissionDenied() + return [] return ( Group.objects.for_current_school_term_or_all() - .filter(Q(members=person) | Q(owners=person) | Q(parent_groups__owners=person)) - .distinct() + .filter( + pk__in=Group.objects.filter(members=person) + .values_list("id", flat=True) + .union(Group.objects.filter(owners=person).values_list("id", flat=True)) + .union( + Group.objects.filter(parent_groups__owners=person).values_list("id", flat=True) + ) + ) .annotate( is_priority=ExpressionWrapper( Q(group_type=get_site_preferences()["alsijil__group_type_priority_coursebook"]), @@ -199,20 +210,24 @@ class Query(graphene.ObjectType): if person: person = Person.objects.get(pk=person) if not info.context.user.has_perm("core.view_person_rule", person): - raise PermissionDenied() + return [] elif has_person(info.context.user): person = info.context.user.person else: - raise PermissionDenied() + return [] return Course.objects.filter( - ( - Q(teachers=person) - | Q(groups__members=person) - | Q(groups__owners=person) - | Q(groups__parent_groups__owners=person) + pk__in=( + Course.objects.filter(teachers=person) + .values_list("id", flat=True) + .union(Course.objects.filter(groups__members=person).values_list("id", flat=True)) + .union(Course.objects.filter(groups__owners=person).values_list("id", flat=True)) + .union( + Course.objects.filter(groups__parent_groups__owners=person).values_list( + "id", flat=True + ) + ) ) - & Q(groups__in=Group.objects.for_current_school_term_or_all()) - ).distinct() + ).filter(groups__in=Group.objects.for_current_school_term_or_all()) @staticmethod def resolve_absence_creation_persons(root, info, **kwargs): @@ -259,28 +274,51 @@ class Query(graphene.ObjectType): @staticmethod def resolve_statistics_by_person(root, info, person, term): + person = Person.objects.get(pk=person) + if not info.context.user.has_perm("alsijil.view_person_statistics_rule", person): + return None school_term = SchoolTerm.objects.get(id=term) return annotate_person_statistics_for_school_term( - Person.objects.filter(id=person), school_term + Person.objects.filter(id=person.id), school_term ).first() @staticmethod def resolve_participations_of_person(root, info, person, term=None): - # TODO: only current term - return ParticipationStatus.objects.filter(person=person, absence_reason__isnull=False) + person = Person.objects.get(pk=person) + if not info.context.user.has_perm("alsijil.view_person_statistics_rule", person): + return [] + school_term = SchoolTerm.objects.get(id=term) + return ParticipationStatus.objects.filter( + person=person, + absence_reason__isnull=False, + datetime_start__date__gte=school_term.date_start, + datetime_end__date__lte=school_term.date_end, + ).order_by("-related_documentation__datetime_start") @staticmethod def resolve_personal_notes_for_person(root, info, person, term=None): - # TODO: only current term - return NewPersonalNote.objects.filter(person=person) + person = Person.objects.get(pk=person) + if not info.context.user.has_perm("alsijil.view_person_statistics_rule", person): + return [] + school_term = SchoolTerm.objects.get(id=term) + return NewPersonalNote.objects.filter( + person=person, + documentation__in=Documentation.objects.filter( + datetime_start__date__gte=school_term.date_start, + datetime_end__date__lte=school_term.date_end, + ), + ).order_by("-documentation__datetime_start") @staticmethod def resolve_statistics_by_group(root, info, group, term=None): + group = Group.objects.get(pk=group) + if not info.context.user.has_perm("alsijil.view_group_statistics_rule", group): + return [] school_term = ( SchoolTerm.objects.get(id=term) if term is not None else SchoolTerm.get_current() ) - members = Group.objects.get(id=group).members.all() + members = group.members.all() return annotate_person_statistics_for_school_term(members, school_term, group=group) diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py index cdb2eaca122330f70bd75023a463f2dc1dc0e8e7..b833de336b2a2add64ac5f0e0cf6dd7fdd8ae0c5 100644 --- a/aleksis/apps/alsijil/schema/documentation.py +++ b/aleksis/apps/alsijil/schema/documentation.py @@ -108,7 +108,9 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp "alsijil.view_participation_status_for_documentation_rule", root ): if has_person(info.context.user): - return root.participations.filter(person=info.context.user.person) + return [ + p for p in root.participations.all() if p.person == info.context.user.person + ] return [] return root.participations.all() diff --git a/aleksis/apps/alsijil/schema/participation_status.py b/aleksis/apps/alsijil/schema/participation_status.py index fcf15df815619fcc808770116347458d8f7daf02..22e5820594994b2d1bb4c81ae4f5a83f615bea0b 100644 --- a/aleksis/apps/alsijil/schema/participation_status.py +++ b/aleksis/apps/alsijil/schema/participation_status.py @@ -119,9 +119,9 @@ class ExtendParticipationStatusToAbsenceBatchMutation(graphene.Mutation): return participation, absence else: - # No base absence, simply create one + # No base absence, simply create one if absence reason is given data = dict( - reason_id=participation.absence_reason.id, + reason_id=participation.absence_reason.id if participation.absence_reason else None, person=participation.person, ) diff --git a/aleksis/apps/alsijil/schema/personal_note.py b/aleksis/apps/alsijil/schema/personal_note.py index dfe36359d87345a0b51992e689627065638fcd4e..e37e5f0580add9255c460a7089d1e877247273eb 100644 --- a/aleksis/apps/alsijil/schema/personal_note.py +++ b/aleksis/apps/alsijil/schema/personal_note.py @@ -23,6 +23,8 @@ class PersonalNoteType( "id", "note", "extra_mark", + # TODO: permissions? + "documentation", ) diff --git a/aleksis/apps/alsijil/schema/statistics.py b/aleksis/apps/alsijil/schema/statistics.py index 976f25ebb32b50c9152fb86d23acf17011d7a89f..091afdae8b705d660413fe53b6b35328459a16cb 100644 --- a/aleksis/apps/alsijil/schema/statistics.py +++ b/aleksis/apps/alsijil/schema/statistics.py @@ -1,9 +1,5 @@ -from django.utils import timezone - import graphene -from aleksis.apps.cursus.models import Subject -from aleksis.apps.cursus.schema import SubjectType from aleksis.apps.kolego.models.absence import AbsenceReason from aleksis.apps.kolego.schema.absence import AbsenceReasonType from aleksis.core.models import Person diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py index 2a27bca7e2260a13d12199027d3501add3494b60..7177034281562b2aae4d9983f70def95f4a1c75a 100644 --- a/aleksis/apps/alsijil/util/predicates.py +++ b/aleksis/apps/alsijil/util/predicates.py @@ -365,3 +365,13 @@ def can_edit_personal_note(user: User, obj: NewPersonalNote): user, obj.documentation.amends ) | is_lesson_event_group_owner(user, obj.documentation.amends) return False + + +@predicate +def can_view_statistics_for_person(user: User, obj: Person) -> bool: + """Predicate for registering absence for person.""" + group_types = get_site_preferences()["alsijil__group_types_view_person_statistics"] + qs = obj.member_of.filter(owners=user.person) + if not group_types.exists(): + return False + return qs.filter(group_type__in=group_types).exists() diff --git a/pyproject.toml b/pyproject.toml index e0880f83bec1e0011e9b79f84567558598f90300..2f82bc3ed9291cd3d7a9b04d725ff6b67216942b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "AlekSIS-App-Alsijil" -version = "4.0.0.dev7" +version = "4.0.0.dev8" packages = [ { include = "aleksis" } ] @@ -51,7 +51,6 @@ priority = "supplemental" python = "^3.10" aleksis-core = "^4.0.0.dev11" aleksis-app-chronos = "^4.0.0.dev7" -aleksis-app-stoelindeling = { version = "^3.0.dev1", optional = true } aleksis-app-kolego = "^0.1.0.dev3" [tool.poetry.extras]