diff --git a/aleksis/apps/maka/frontend/components/effort_types/EffortTypes.vue b/aleksis/apps/maka/frontend/components/effort_types/EffortTypes.vue index b1845ebcff6649e525017bf3c78f5718d8f3fabd..215f54d8444a5a1a1ea6be8c38c1d0ceecb107f3 100644 --- a/aleksis/apps/maka/frontend/components/effort_types/EffortTypes.vue +++ b/aleksis/apps/maka/frontend/components/effort_types/EffortTypes.vue @@ -1,67 +1,70 @@ <script setup> import ColorField from "aleksis.core/components/generic/forms/ColorField.vue"; import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +import SharedSecretWrapper from "../shared_secret/SharedSecretWrapper.vue"; </script> <template> - <v-container> - <inline-c-r-u-d-list - :headers="headers" - :i18n-key="i18nKey" - create-item-i18n-key="maka.effort_types.create" - :gql-query="gqlQuery" - :gql-create-mutation="gqlCreateMutation" - :gql-patch-mutation="gqlPatchMutation" - :gql-delete-mutation="gqlDeleteMutation" - :default-item="defaultItem" - :get-create-data="transformItem" - :get-patch-data="transformItem" - > - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #name.field="{ attrs, on }"> - <div aria-required="true"> - <v-text-field + <shared-secret-wrapper> + <v-container> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="maka.effort_types.create" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + :get-create-data="transformItem" + :get-patch-data="transformItem" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> + </div> + </template> + + <template #color="{ item }"> + <v-chip :color="item.color" outlined v-if="item.color"> + {{ item.color }} + </v-chip> + <span v-else>–</span> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #color.field="{ attrs, on }"> + <color-field v-bind="attrs" v-on="on" /> + </template> + + <template #default="{ item }"> + <v-switch + :input-value="item.default" + disabled + inset + :false-value="false" + :true-value="true" + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #default.field="{ attrs, on }"> + <v-switch v-bind="attrs" v-on="on" - :rules="$rules().required.build()" + inset + :false-value="false" + :true-value="true" + :hint="$t('maka.effort_types.default_helptext')" + persistent-hint /> - </div> - </template> - - <template #color="{ item }"> - <v-chip :color="item.color" outlined v-if="item.color"> - {{ item.color }} - </v-chip> - <span v-else>–</span> - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #color.field="{ attrs, on }"> - <color-field v-bind="attrs" v-on="on" /> - </template> - - <template #default="{ item }"> - <v-switch - :input-value="item.default" - disabled - inset - :false-value="false" - :true-value="true" - /> - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #default.field="{ attrs, on }"> - <v-switch - v-bind="attrs" - v-on="on" - inset - :false-value="false" - :true-value="true" - :hint="$t('maka.effort_types.default_helptext')" - persistent-hint - /> - </template> - </inline-c-r-u-d-list> - </v-container> + </template> + </inline-c-r-u-d-list> + </v-container> + </shared-secret-wrapper> </template> <script> diff --git a/aleksis/apps/maka/frontend/components/efforts/Efforts.vue b/aleksis/apps/maka/frontend/components/efforts/Efforts.vue index 7decf67ab79838bccfa05baea3343594b5bc4a03..8445d2150e3fafbade413b80b0a42dc70c8b465d 100644 --- a/aleksis/apps/maka/frontend/components/efforts/Efforts.vue +++ b/aleksis/apps/maka/frontend/components/efforts/Efforts.vue @@ -2,63 +2,66 @@ import GroupChip from "aleksis.core/components/group/GroupChip.vue"; import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; import EffortTypeChip from "../effort_types/EffortTypeChip.vue"; +import SharedSecretWrapper from "../shared_secret/SharedSecretWrapper.vue"; </script> <template> - <v-container> - <inline-c-r-u-d-list - :headers="headers" - :i18n-key="i18nKey" - create-item-i18n-key="maka.efforts.create" - :gql-query="gqlQuery" - :gql-create-mutation="gqlCreateMutation" - :gql-patch-mutation="gqlPatchMutation" - :gql-delete-mutation="gqlDeleteMutation" - :default-item="defaultItem" - > - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #name.field="{ attrs, on }"> - <div aria-required="true"> - <v-text-field - v-bind="attrs" - v-on="on" - :rules="$rules().required.build()" - /> - </div> - </template> + <shared-secret-wrapper> + <v-container> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="maka.efforts.create" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> + </div> + </template> - <template #effortType="{ item }"> - <effort-type-chip :effort-type="item.effortType" v-if="item.effortType" /> - <span v-else>–</span> - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #effortType.field="{ attrs, on }"> - <div aria-required="true"> - <v-autocomplete v-bind="attrs" v-on="on" :items="effortTypes" item-text="name" item-value="id" :rules="$rules().required.build()" /> - </div> - </template> + <template #effortType="{ item }"> + <effort-type-chip :effort-type="item.effortType" v-if="item.effortType" /> + <span v-else>–</span> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #effortType.field="{ attrs, on }"> + <div aria-required="true"> + <v-autocomplete v-bind="attrs" v-on="on" :items="effortTypes" item-text="name" item-value="id" :rules="$rules().required.build()" /> + </div> + </template> - <template #group="{ item }"> - <group-chip :group="item.group"/> - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #group.field="{ attrs, on }"> - <div aria-required="true"> - <v-autocomplete v-bind="attrs" v-on="on" :items="groups" item-text="name" item-value="id" :rules="$rules().required.build()" /> - </div> - </template> + <template #group="{ item }"> + <group-chip :group="item.group"/> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #group.field="{ attrs, on }"> + <div aria-required="true"> + <v-autocomplete v-bind="attrs" v-on="on" :items="groups" item-text="name" item-value="id" :rules="$rules().required.build()" /> + </div> + </template> - <template #gradeSet="{ item }"> - {{ item.gradeSet.name }} - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #gradeSet.field="{ attrs, on }"> - <div aria-required="true"> - <v-autocomplete v-bind="attrs" v-on="on" :items="gradeSets" item-text="name" item-value="id" :rules="$rules().required.build()" /> - </div> - </template> - </inline-c-r-u-d-list> - </v-container> + <template #gradeSet="{ item }"> + {{ item.gradeSet.name }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #gradeSet.field="{ attrs, on }"> + <div aria-required="true"> + <v-autocomplete v-bind="attrs" v-on="on" :items="gradeSets" item-text="name" item-value="id" :rules="$rules().required.build()" /> + </div> + </template> + </inline-c-r-u-d-list> + </v-container> + </shared-secret-wrapper> </template> <script> diff --git a/aleksis/apps/maka/frontend/components/grade_management/GradeManagement.vue b/aleksis/apps/maka/frontend/components/grade_management/GradeManagement.vue index d9bb3cc62b4eb2fe56f6d745cd5df341a3a688ac..69fd7dfc64936e83f239456350d5d67d4ba0debe 100644 --- a/aleksis/apps/maka/frontend/components/grade_management/GradeManagement.vue +++ b/aleksis/apps/maka/frontend/components/grade_management/GradeManagement.vue @@ -1,52 +1,55 @@ <script setup> import CRUDList from "aleksis.core/components/generic/CRUDList.vue"; +import SharedSecretWrapper from "../shared_secret/SharedSecretWrapper.vue"; </script> <template> - <v-container> - <c-r-u-d-list - :headers="headers" - :i18n-key="i18nKey" - create-item-i18n-key="maka.grade_set.create" - :gql-query="gqlQuery" - :gql-create-mutation="gqlCreateMutation" - :gql-patch-mutation="gqlPatchMutation" - :gql-delete-mutation="gqlDeleteMutation" - :default-item="defaultItem" - :get-create-data="transformItem(false)" - :get-patch-data="transformItem(true)" - show-expand - :enable-edit="true" - > - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #name.field="{ attrs, on }"> - <div aria-required="true"> - <v-text-field - v-bind="attrs" - v-on="on" - :rules="$rules().required.build()" - /> - </div> - </template> - - <template #expanded-item="{ item }"> - <v-sheet class="my-2"> - <div - v-if="item.gradeChoices && item.gradeChoices.length" - class="d-flex flex-wrap" - style="gap: 0.5em" - > - <v-chip v-for="gradeChoice in item.gradeChoices"> - {{ gradeChoice.name }} - </v-chip> + <shared-secret-wrapper> + <v-container> + <c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="maka.grade_set.create" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + :get-create-data="transformItem(false)" + :get-patch-data="transformItem(true)" + show-expand + :enable-edit="true" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> </div> - <template v-else> - TODO: you need to create choices! - </template> - </v-sheet> - </template> - </c-r-u-d-list> - </v-container> + </template> + + <template #expanded-item="{ item }"> + <v-sheet class="my-2"> + <div + v-if="item.gradeChoices && item.gradeChoices.length" + class="d-flex flex-wrap" + style="gap: 0.5em" + > + <v-chip v-for="gradeChoice in item.gradeChoices"> + {{ gradeChoice.name }} + </v-chip> + </div> + <template v-else> + TODO: you need to create choices! + </template> + </v-sheet> + </template> + </c-r-u-d-list> + </v-container> + </shared-secret-wrapper> </template> <script> diff --git a/aleksis/apps/maka/frontend/components/shared_secret/SharedSecretWrapper.vue b/aleksis/apps/maka/frontend/components/shared_secret/SharedSecretWrapper.vue new file mode 100644 index 0000000000000000000000000000000000000000..ddf72ddceebb7a6dcde09e7ef5b541e26a452d61 --- /dev/null +++ b/aleksis/apps/maka/frontend/components/shared_secret/SharedSecretWrapper.vue @@ -0,0 +1,85 @@ +<template> + <div> + <slot v-if="sharedSecretStatus && !initial" /> + <div class="d-flex justify-center align-center flex-column text-center" v-else-if="$apollo.queries.sharedSecretStatus.loading"> + <h1 class="text-h5">{{ $t("maka.shared_secret.checking") }}</h1> + <v-progress-circular + indeterminate + color="primary" + ></v-progress-circular> + </div> + <div class="d-flex justify-center align-center flex-column text-center" v-else> + <h1 class="text-h5">{{ $t("maka.shared_secret.enter") }}</h1> + <v-text-field v-model="sharedSecret" :loading="loading" type="password" :error-messages="errorMessages"> + <template #append> + <v-btn + :disabled="loading" + icon + @click="submitSharedSecret" + > + <v-icon color="primary"> + mdi-send-outline + </v-icon> + </v-btn> + </template> + </v-text-field> + </div> + </div> +</template> + + <script> + import { gqlSubmitSharedSecret, gqlSharedSecretStatus } from "./sharedSecret.graphql"; + + export default { + name: "SharedSecretWrapper", + data() { + return { + sharedSecret: "", + sharedSecretStatus: false, + loading: false, + showError: false, + initial: true, + }; + }, + methods: { + submitSharedSecret() { + this.showError = false; + this.loading = true; + this.$apollo.mutate({ + mutation: gqlSubmitSharedSecret, + variables: { + sharedSecret: this.sharedSecret, + }, + }).then((data) => { + this.loading = false; + this.$apollo.queries.sharedSecretStatus.refetch(); + }); + }, + }, + computed: { + errorMessages() { + if (!this.loading && !this.sharedSecretStatus && this.showError) { + return [this.$t('maka.shared_secret.error')]; + } else { + return []; + } + }, + }, + apollo: { + sharedSecretStatus: { + query: gqlSharedSecretStatus, + result ({ data, loading, networkStatus }) { + if (!loading && !data?.sharedSecretStatus && !this.initial) { + this.showError = true; + } else if (!loading) { + this.initial = false; + } + }, + }, + }, + }; + </script> + + <style scoped> + </style> + \ No newline at end of file diff --git a/aleksis/apps/maka/frontend/components/shared_secret/sharedSecret.graphql b/aleksis/apps/maka/frontend/components/shared_secret/sharedSecret.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a4b39d102071ea20e5d1a741802cae53568cca54 --- /dev/null +++ b/aleksis/apps/maka/frontend/components/shared_secret/sharedSecret.graphql @@ -0,0 +1,9 @@ +mutation gqlSubmitSharedSecret($sharedSecret: String!) { + submitSharedSecret(sharedSecret: $sharedSecret) { + ok + } +} + +query gqlSharedSecretStatus { + sharedSecretStatus +} diff --git a/aleksis/apps/maka/frontend/index.js b/aleksis/apps/maka/frontend/index.js index ed5f30cb0d232d0a6a0c7ac5da71dcffa7a1ddd2..f72d3a5845875560f1e7599defb725c29544aca7 100644 --- a/aleksis/apps/maka/frontend/index.js +++ b/aleksis/apps/maka/frontend/index.js @@ -32,7 +32,8 @@ export default { name: "maka.gradeManagement", meta: { inMenu: true, - titleKey: "maka.grades.menu_title", + titleKey: "maka.grade_set.menu_title", + toolbarTitle: "maka.grade_set.title_plural", icon: "mdi-star-settings-outline", iconActive: "mdi-star-settings", permission: "", @@ -45,6 +46,7 @@ export default { meta: { inMenu: true, titleKey: "maka.effort_types.menu_title", + toolbarTitle: "maka.effort_types.title_plural", icon: "mdi-folder-star-multiple-outline", iconActive: "mdi-folder-star-multiple", permission: "", @@ -57,6 +59,7 @@ export default { meta: { inMenu: true, titleKey: "maka.efforts.menu_title", + toolbarTitle: "maka.efforts.title_plural", icon: "mdi-playlist-star", iconActive: "mdi-playlist-remove", permission: "", diff --git a/aleksis/apps/maka/frontend/messages/en.json b/aleksis/apps/maka/frontend/messages/en.json index 46361285449e68325883be0a0de9c67bb89d5562..71a955dc1e63df5b15df5ba92ed6bf894982fc8a 100644 --- a/aleksis/apps/maka/frontend/messages/en.json +++ b/aleksis/apps/maka/frontend/messages/en.json @@ -8,6 +8,7 @@ }, "grade_set": { "name": "Name", + "menu_title": "Grade Sets", "title_plural": "Grade Sets", "create": "Create Grade Set" }, @@ -29,6 +30,12 @@ "icon": "Icon", "default": "Default Effort Type", "default_helptext": "Will disable previous default when enabled" + }, + "shared_secret": { + "title": "Shared secret", + "enter": "Shared secret needed for accessing page", + "error": "Wrong shared secret entered. Please try again.", + "checking": "Checking for correctly entered shared secret…" } }, "group": { diff --git a/aleksis/apps/maka/models/__init__.py b/aleksis/apps/maka/models/__init__.py index 9fbbe3f14616cd8901de1e9b511911f49f9d415f..27c4be4731e4a7a5eda2fc052cc986e0d874b0d4 100644 --- a/aleksis/apps/maka/models/__init__.py +++ b/aleksis/apps/maka/models/__init__.py @@ -68,6 +68,17 @@ class EffortType(ExtensibleModel): super().save(*args, **kwargs) + class Meta: + verbose_name = _("Effort type") + verbose_name_plural = _("Effort types") + constraints = [ + models.UniqueConstraint( + fields=["default"], + condition=models.Q(default=True), + name="only_one_default_effort_type", + ) + ] + class Effort(ExtensibleModel): """Concrete occurrence of an effort type.""" diff --git a/aleksis/apps/maka/preferences.py b/aleksis/apps/maka/preferences.py new file mode 100644 index 0000000000000000000000000000000000000000..895defd12161fa2fde9e21beb4e602a21255fac4 --- /dev/null +++ b/aleksis/apps/maka/preferences.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.forms import PasswordInput +from django.forms.widgets import SelectMultiple +from django.utils.translation import gettext_lazy as _ + +from colorfield.widgets import ColorWidget +from dynamic_preferences.preferences import Section +from dynamic_preferences.serializers import StringSerializer +from dynamic_preferences.types import ( + BooleanPreference, + ChoicePreference, + FilePreference, + IntegerPreference, + LongStringPreference, + ModelMultipleChoicePreference, + MultipleChoicePreference, + StringPreference, +) +from oauth2_provider.models import AbstractApplication + +from aleksis.core.registries import person_preferences_registry, site_preferences_registry + +maka = Section("maka", verbose_name=_("Grade management")) + + +class PasswordSerializer(StringSerializer): + """Serializer hashing the given string before saving it.""" + + @classmethod + def to_db(cls, value, **kwargs): + return str(make_password(value)) + + + +@site_preferences_registry.register +class SharedSecret(StringPreference): + """Shared secret for accessing grade management pages.""" + + section = maka + name = "shared_secret" + default = make_password("aleksis") + required = True + widget = PasswordInput + serializer = PasswordSerializer + verbose_name = _("Shared secret") + help_text = _("This is the global shared secret key used to access all grade management pages requiring two factor authentification. It is not possible to see the secret again after setting it; please write it down safely.") diff --git a/aleksis/apps/maka/rules.py b/aleksis/apps/maka/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..0b167c9228112764ca7aae3ebb98de04a26ed697 --- /dev/null +++ b/aleksis/apps/maka/rules.py @@ -0,0 +1,10 @@ +import rules +from rules import is_superuser + +from aleksis.core.util.predicates import has_person +from .util.predicates import has_shared_secret + + +# View 2FA (shared secret) protected pages +protected_page_predicate = has_person & has_shared_secret +rules.add_perm("maka.protected_page_rule", protected_page_predicate) diff --git a/aleksis/apps/maka/schema/__init__.py b/aleksis/apps/maka/schema/__init__.py index 266e40a3cb7306844161b44eaac461c6cd9da9c9..df8cea228bca6392b1a923f41e9992f8d3505154 100644 --- a/aleksis/apps/maka/schema/__init__.py +++ b/aleksis/apps/maka/schema/__init__.py @@ -28,6 +28,7 @@ from .grade import ( GradeBatchPatchMutation, GradeType, ) +from .shared_secret import SubmitSharedSecretMutation class Query(graphene.ObjectType): @@ -37,6 +38,10 @@ class Query(graphene.ObjectType): effort_types = FilterOrderList(EffortTypeType) efforts = FilterOrderList(EffortType) + shared_secret_status = graphene.Boolean() + + def resolve_shared_secret_status(root, info) -> bool: + return info.context.session.get("maka_shared_secret_correct") class Mutation(graphene.ObjectType): create_grade_sets = GradeSetBatchCreateMutation.Field() @@ -55,6 +60,8 @@ class Mutation(graphene.ObjectType): delete_effort_types = EffortTypeBatchDeleteMutation.Field() update_effort_types = EffortTypeBatchPatchMutation.Field() - create_efforts = EffortBatchCreateMutation.Field() - delete_efforts = EffortBatchDeleteMutation.Field() - update_efforts = EffortBatchPatchMutation.Field() + create_rankings = EffortBatchCreateMutation.Field() + delete_rankings = EffortBatchDeleteMutation.Field() + update_rankings = EffortBatchPatchMutation.Field() + + submit_shared_secret = SubmitSharedSecretMutation.Field() diff --git a/aleksis/apps/maka/schema/effort.py b/aleksis/apps/maka/schema/effort.py index 42b23b560e13aebdac9696ea4383001e4381fb93..36cbdd06478279f09122122f6aceea4e85950f95 100644 --- a/aleksis/apps/maka/schema/effort.py +++ b/aleksis/apps/maka/schema/effort.py @@ -9,10 +9,11 @@ from aleksis.core.schema.base import ( RulesObjectType, ) +from .shared_secret import SharedSecretObjectType, SharedSecretBatchCreateMixin, SharedSecretBatchPatchMixin, SharedSecretBatchDeleteMixin from ..models import Effort as EffortModel, EffortType as EffortTypeModel -class EffortTypeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): +class EffortTypeType(SharedSecretObjectType, PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = EffortTypeModel fields = ("id", "name", "color", "icon", "default") @@ -22,7 +23,7 @@ class EffortTypeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): } -class EffortTypeBatchCreateMutation(BaseBatchCreateMutation): +class EffortTypeBatchCreateMutation(SharedSecretBatchCreateMixin, BaseBatchCreateMutation): class Meta: model = EffortTypeModel permissions = ("maka.create_efforttype_rule",) @@ -34,13 +35,13 @@ class EffortTypeBatchCreateMutation(BaseBatchCreateMutation): ) -class EffortTypeBatchDeleteMutation(BaseBatchDeleteMutation): +class EffortTypeBatchDeleteMutation(SharedSecretBatchDeleteMixin, BaseBatchDeleteMutation): class Meta: model = EffortTypeModel permissions = ("maka.delete_efforttype_rule",) -class EffortTypeBatchPatchMutation(BaseBatchPatchMutation): +class EffortTypeBatchPatchMutation(SharedSecretBatchPatchMixin, BaseBatchPatchMutation): class Meta: model = EffortTypeModel permissions = ("maka.edit_efforttype_rule",) @@ -53,7 +54,7 @@ class EffortTypeBatchPatchMutation(BaseBatchPatchMutation): ) -class EffortType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): +class EffortType(SharedSecretObjectType, PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = EffortModel fields = ("id", "name", "weight", "effort_type", "group", "grade_set", "grades") @@ -63,7 +64,7 @@ class EffortType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): } -class EffortBatchCreateMutation(BaseBatchCreateMutation): +class EffortBatchCreateMutation(SharedSecretBatchCreateMixin, BaseBatchCreateMutation): class Meta: model = EffortModel permissions = ("maka.create_effort_rule",) @@ -75,13 +76,13 @@ class EffortBatchCreateMutation(BaseBatchCreateMutation): ) -class EffortBatchDeleteMutation(BaseBatchDeleteMutation): +class EffortBatchDeleteMutation(SharedSecretBatchDeleteMixin, BaseBatchDeleteMutation): class Meta: model = EffortModel permissions = ("maka.delete_effort_rule",) -class EffortBatchPatchMutation(BaseBatchPatchMutation): +class EffortBatchPatchMutation(SharedSecretBatchPatchMixin, BaseBatchPatchMutation): class Meta: model = EffortModel permissions = ("maka.edit_effort_rule",) diff --git a/aleksis/apps/maka/schema/grade.py b/aleksis/apps/maka/schema/grade.py index 56fa66785fb70eaf453414f3cbb18e1c38081d33..f12f24641645fbe7a61f6a7755f0c266a7396605 100644 --- a/aleksis/apps/maka/schema/grade.py +++ b/aleksis/apps/maka/schema/grade.py @@ -9,10 +9,11 @@ from aleksis.core.schema.base import ( RulesObjectType, ) +from .shared_secret import SharedSecretObjectType, SharedSecretBatchCreateMixin, SharedSecretBatchPatchMixin, SharedSecretBatchDeleteMixin from ..models import Grade -class GradeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): +class GradeType(SharedSecretObjectType, PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = Grade fields = ("id", "person", "grade", "effort") @@ -22,7 +23,7 @@ class GradeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): } -class GradeBatchCreateMutation(BaseBatchCreateMutation): +class GradeBatchCreateMutation(SharedSecretBatchCreateMixin, BaseBatchCreateMutation): class Meta: model = Grade permissions = ("maka.create_grade_rule",) @@ -33,13 +34,13 @@ class GradeBatchCreateMutation(BaseBatchCreateMutation): ) -class GradeBatchDeleteMutation(BaseBatchDeleteMutation): +class GradeBatchDeleteMutation(SharedSecretBatchDeleteMixin, BaseBatchDeleteMutation): class Meta: model = Grade permissions = ("maka.delete_grade_rule",) -class GradeBatchPatchMutation(BaseBatchPatchMutation): +class GradeBatchPatchMutation(SharedSecretBatchPatchMixin, BaseBatchPatchMutation): class Meta: model = Grade permissions = ("maka.edit_grade_rule",) diff --git a/aleksis/apps/maka/schema/grade_set.py b/aleksis/apps/maka/schema/grade_set.py index d140da402cb06f79ee5a940f022e0b987bd0ca72..391ef9677a2e3c78c6de1b82ef369e1bade6e166 100644 --- a/aleksis/apps/maka/schema/grade_set.py +++ b/aleksis/apps/maka/schema/grade_set.py @@ -9,10 +9,11 @@ from aleksis.core.schema.base import ( RulesObjectType, ) +from .shared_secret import SharedSecretObjectType, SharedSecretBatchCreateMixin, SharedSecretBatchPatchMixin, SharedSecretBatchDeleteMixin from ..models import GradeChoice, GradeSet -class GradeSetType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): +class GradeSetType(SharedSecretObjectType, PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = GradeSet fields = ("id", "name", "grade_choices") @@ -22,7 +23,7 @@ class GradeSetType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): } -class GradeSetBatchCreateMutation(BaseBatchCreateMutation): +class GradeSetBatchCreateMutation(SharedSecretBatchCreateMixin, BaseBatchCreateMutation): class Meta: model = GradeSet permissions = ("maka.create_gradeset_rule",) @@ -31,13 +32,13 @@ class GradeSetBatchCreateMutation(BaseBatchCreateMutation): ) -class GradeSetBatchDeleteMutation(BaseBatchDeleteMutation): +class GradeSetBatchDeleteMutation(SharedSecretBatchDeleteMixin, BaseBatchDeleteMutation): class Meta: model = GradeSet permissions = ("maka.delete_gradeset_rule",) -class GradeSetBatchPatchMutation(BaseBatchPatchMutation): +class GradeSetBatchPatchMutation(SharedSecretBatchPatchMixin, BaseBatchPatchMutation): class Meta: model = GradeSet permissions = ("maka.edit_gradeset_rule",) diff --git a/aleksis/apps/maka/schema/shared_secret.py b/aleksis/apps/maka/schema/shared_secret.py new file mode 100644 index 0000000000000000000000000000000000000000..50ea68316f9c03467d03afc6e808e50056fb00a5 --- /dev/null +++ b/aleksis/apps/maka/schema/shared_secret.py @@ -0,0 +1,75 @@ +from django.contrib.auth.hashers import check_password +from django.core.exceptions import PermissionDenied + +import graphene +from graphene_django import DjangoObjectType + +from aleksis.core.util.core_helpers import get_site_preferences + + +class SubmitSharedSecretMutation(graphene.Mutation): + class Arguments: + shared_secret = graphene.String(required=True) # noqa + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, shared_secret): # noqa + info.context.session["maka_shared_secret_correct"] = check_password(shared_secret, get_site_preferences()["maka__shared_secret"]) + return cls(ok=True) + + +class SharedSecretObjectType(DjangoObjectType): + class Meta: + abstract = True + + @classmethod + def get_queryset(cls, queryset, info): + if not info.context.session.get("maka_shared_secret_correct"): + raise PermissionDenied() + + return super().get_queryset(queryset, info) + + + +class SharedSecretBatchCreateMixin: + """Mixin for maka shared secret checking during batch create mutations.""" + + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input, *args, **kwargs): # noqa: A002 + if info.context.session.get("maka_shared_secret_correct"): + return + + raise PermissionDenied() + + +class SharedSecretBatchPatchMixin: + """Mixin for maka shared secret checking during batch patch mutations.""" + + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input, *args, **kwargs): # noqa: A002 + if info.context.session.get("maka_shared_secret_correct"): + return + + raise PermissionDenied() + + +class SharedSecretBatchDeleteMixin: + """Mixin for maka shared secret checking during batch delete mutations.""" + + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input, *args, **kwargs): # noqa: A002 + if info.context.session.get("maka_shared_secret_correct"): + return + + raise PermissionDenied() + diff --git a/aleksis/apps/maka/util/predicates.py b/aleksis/apps/maka/util/predicates.py new file mode 100644 index 0000000000000000000000000000000000000000..2305daf2ed290e65a992bf8fc92d7aef8e246421 --- /dev/null +++ b/aleksis/apps/maka/util/predicates.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import User +from django.http import HttpRequest + +from rules import predicate + + +@predicate +def has_shared_secret(user: User, request: HttpRequest) -> bool: + return request.session.get("maka_shared_secret_correct")