diff --git a/aleksis/apps/kolego/frontend/components/AbsenceReasonTags.vue b/aleksis/apps/kolego/frontend/components/AbsenceReasonTags.vue new file mode 100644 index 0000000000000000000000000000000000000000..38a3999362347cc3dab490d29c4bad77360e2b36 --- /dev/null +++ b/aleksis/apps/kolego/frontend/components/AbsenceReasonTags.vue @@ -0,0 +1,80 @@ +<script setup> +import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +</script> + +<template> + <v-container> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="kolego.absence_reason_tag.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> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #shortName.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> + </div> + </template> + </inline-c-r-u-d-list> + </v-container> +</template> + +<script> +import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js"; +import { + absenceReasonTags, + createAbsenceReasonTags, + deleteAbsenceReasonTags, + updateAbsenceReasonTags, +} from "./absenceReasonTags.graphql"; + +export default { + name: "AbsenceReasonTags", + mixins: [formRulesMixin], + data() { + return { + headers: [ + { + text: this.$t("kolego.absence_reason_tag.short_name"), + value: "shortName", + }, + { + text: this.$t("kolego.absence_reason_tag.name"), + value: "name", + }, + ], + i18nKey: "kolego.absence_reason_tag", + gqlQuery: absenceReasonTags, + gqlCreateMutation: createAbsenceReasonTags, + gqlPatchMutation: updateAbsenceReasonTags, + gqlDeleteMutation: deleteAbsenceReasonTags, + defaultItem: { + shortName: "", + name: "", + }, + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/kolego/frontend/components/AbsenceReasonTagsField.vue b/aleksis/apps/kolego/frontend/components/AbsenceReasonTagsField.vue new file mode 100644 index 0000000000000000000000000000000000000000..07740e2fa818f76d770dacb4d1a3384e7b01909d --- /dev/null +++ b/aleksis/apps/kolego/frontend/components/AbsenceReasonTagsField.vue @@ -0,0 +1,76 @@ +<script setup> +import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue"; +</script> + +<template> + <foreign-key-field + v-bind="$attrs" + v-on="$listeners" + :fields="headers" + create-item-i18n-key="kolego.absence_reason_tag.create" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="{}" + :default-item="defaultItem" + multiple + chips + > + <!-- 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> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #shortName.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> + </div> + </template> + </foreign-key-field> +</template> + +<script> +import { + allAbsenceReasonTags, + createAbsenceReasonTags, +} from "./absenceReasonTags.graphql"; +import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js"; + +export default { + name: "AbsenceReasonTagsField", + mixins: [formRulesMixin], + data() { + return { + headers: [ + { + text: this.$t("kolego.absence_reason_tag.short_name"), + value: "shortName", + }, + { + text: this.$t("kolego.absence_reason_tag.name"), + value: "name", + }, + ], + i18nKey: "kolego.absence_reason_tag", + gqlQuery: allAbsenceReasonTags, + gqlCreateMutation: createAbsenceReasonTags, + defaultItem: { + name: "", + shortName: "", + }, + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue b/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue index 3af13e90396c3a4ed40df8e8a6f6296068ffa4dd..3774a427c26c33a0874640a66776d79ca98cd1e8 100644 --- a/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue +++ b/aleksis/apps/kolego/frontend/components/AbsenceReasons.vue @@ -1,6 +1,7 @@ <script setup> import ColorField from "aleksis.core/components/generic/forms/ColorField.vue"; import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +import AbsenceReasonTagsField from "./AbsenceReasonTagsField.vue"; </script> <template> @@ -69,6 +70,15 @@ import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; persistent-hint /> </template> + + <template #tags="{ item }"> + <span v-if="item.tags.length == 0">–</span> + <v-chip v-for="tag in item.tags" :key="tag.id">{{ tag.name }}</v-chip> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #tags.field="{ attrs, on }"> + <absence-reason-tags-field v-bind="attrs" v-on="on" /> + </template> </inline-c-r-u-d-list> </v-container> </template> @@ -104,6 +114,10 @@ export default { text: this.$t("kolego.absence_reason.default"), value: "default", }, + { + text: this.$t("kolego.absence_reason.tags"), + value: "tags", + }, ], i18nKey: "kolego.absence_reason", gqlQuery: absenceReasons, diff --git a/aleksis/apps/kolego/frontend/components/absenceReasonTags.graphql b/aleksis/apps/kolego/frontend/components/absenceReasonTags.graphql new file mode 100644 index 0000000000000000000000000000000000000000..34309da27040f904578a9987dfffd273d4e45a61 --- /dev/null +++ b/aleksis/apps/kolego/frontend/components/absenceReasonTags.graphql @@ -0,0 +1,49 @@ +query absenceReasonTags($orderBy: [String], $filters: JSONString) { + items: absenceReasonTags(orderBy: $orderBy, filters: $filters) { + id + shortName + name + canEdit + canDelete + } +} + +query allAbsenceReasonTags($orderBy: [String], $filters: JSONString) { + items: allAbsenceReasonTags(orderBy: $orderBy, filters: $filters) { + id + shortName + name + canEdit + canDelete + } +} + +mutation createAbsenceReasonTags($input: [BatchCreateAbsenceReasonTagInput]!) { + createAbsenceReasonTags(input: $input) { + items: absenceReasonTags { + id + shortName + name + canEdit + canDelete + } + } +} + +mutation deleteAbsenceReasonTags($ids: [ID]!) { + deleteAbsenceReasonTags(ids: $ids) { + deletionCount + } +} + +mutation updateAbsenceReasonTags($input: [BatchPatchAbsenceReasonTagInput]!) { + updateAbsenceReasonTags(input: $input) { + items: absenceReasonTags { + id + shortName + name + canEdit + canDelete + } + } +} diff --git a/aleksis/apps/kolego/frontend/components/absenceReasons.graphql b/aleksis/apps/kolego/frontend/components/absenceReasons.graphql index fa713b354a9bb42173bd39b3c1039c29e0518fee..f12be47606f7850ab2b9150c079e3847ca0f5012 100644 --- a/aleksis/apps/kolego/frontend/components/absenceReasons.graphql +++ b/aleksis/apps/kolego/frontend/components/absenceReasons.graphql @@ -7,6 +7,11 @@ query absenceReasons($orderBy: [String], $filters: JSONString) { default canEdit canDelete + tags { + id + name + shortName + } } } @@ -20,6 +25,11 @@ mutation createAbsenceReasons($input: [BatchCreateAbsenceReasonInput]!) { default canEdit canDelete + tags { + id + name + shortName + } } } } @@ -40,6 +50,11 @@ mutation updateAbsenceReasons($input: [BatchPatchAbsenceReasonInput]!) { default canEdit canDelete + tags { + id + name + shortName + } } } } diff --git a/aleksis/apps/kolego/frontend/index.js b/aleksis/apps/kolego/frontend/index.js index e1369d87d5a697e104a6d454fd015f6781d8f39c..260b3d9cc754548bb730d2bad05cc2de2d19d546 100644 --- a/aleksis/apps/kolego/frontend/index.js +++ b/aleksis/apps/kolego/frontend/index.js @@ -31,5 +31,17 @@ export default { permission: "kolego.view_absencereasons_rule", }, }, + { + path: "absence_reason_tags", + component: () => import("./components/AbsenceReasonTags.vue"), + name: "kolego.absence_reason_tags", + meta: { + inMenu: true, + titleKey: "kolego.absence_reason_tag.menu_title", + icon: "mdi-tag-multiple-outline", + iconActive: "mdi-tag-multiple", + permission: "kolego.view_absencereasontags_rule", + }, + }, ], }; diff --git a/aleksis/apps/kolego/frontend/messages/en.json b/aleksis/apps/kolego/frontend/messages/en.json index acc168b7b6f115d634742ea12eb38ff5ac2167de..2546390d2bea10c2830408cdd4e1c17ee3eb5bae 100644 --- a/aleksis/apps/kolego/frontend/messages/en.json +++ b/aleksis/apps/kolego/frontend/messages/en.json @@ -17,7 +17,15 @@ "colour": "Colour", "default": "Default Absence Reason", "default_helptext": "Will disable previous default when enabled", - "present": "Present" + "present": "Present", + "tags": "Tags" + }, + "absence_reason_tag": { + "menu_title": "Absence Reason Tags", + "title_plural": "Absence Reason Tags", + "create": "Create absence reason tag", + "short_name": "Short name", + "name": "Name" } } } diff --git a/aleksis/apps/kolego/migrations/0004_absencereasontag_absencereason_tags.py b/aleksis/apps/kolego/migrations/0004_absencereasontag_absencereason_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..17da82844887d7e072bfeaf560f2dfb3dd5b6b7f --- /dev/null +++ b/aleksis/apps/kolego/migrations/0004_absencereasontag_absencereason_tags.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.13 on 2024-07-16 11:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kolego', '0003_refactor_absence'), + ] + + operations = [ + migrations.CreateModel( + name='AbsenceReasonTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('short_name', models.CharField(max_length=255, unique=True, verbose_name='Short name')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ], + options={ + 'verbose_name': 'Absence reason tag', + 'verbose_name_plural': 'Absence reason tags', + }, + ), + migrations.AddField( + model_name='absencereason', + name='tags', + field=models.ManyToManyField(blank=True, to='kolego.absencereasontag', verbose_name='Tags'), + ), + ] diff --git a/aleksis/apps/kolego/models/__init__.py b/aleksis/apps/kolego/models/__init__.py index 1abd1e0c1f5f4c21ca9c4bd697eddf5b7d3177e4..1d41f22634f84a1e4fe97a250c47e1340406b85b 100644 --- a/aleksis/apps/kolego/models/__init__.py +++ b/aleksis/apps/kolego/models/__init__.py @@ -1 +1 @@ -from .absence import Absence, AbsenceReason # noqa: F401 +from .absence import Absence, AbsenceReason, AbsenceReasonTag # noqa: F401 diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py index b43ff9a6f2c0187abb3519a3daa2ab14a67d93cf..1421b69dc5e144a91f91b9f6d22c9f2f5d36f7c1 100644 --- a/aleksis/apps/kolego/models/absence.py +++ b/aleksis/apps/kolego/models/absence.py @@ -12,6 +12,21 @@ from aleksis.core.models import FreeBusy from ..managers import AbsenceQuerySet +class AbsenceReasonTag(ExtensibleModel): + short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True) + name = models.CharField(verbose_name=_("Name"), max_length=255) + + def __str__(self): + if self.name: + return f"{self.short_name} ({self.name})" + else: + return self.short_name + + class Meta: + verbose_name = _("Absence reason tag") + verbose_name_plural = _("Absence reason tags") + + class AbsenceReason(ExtensibleModel): short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True) name = models.CharField(verbose_name=_("Name"), max_length=255) @@ -29,6 +44,10 @@ class AbsenceReason(ExtensibleModel): default = models.BooleanField(verbose_name=_("Default Reason"), default=False) + tags = models.ManyToManyField( + AbsenceReasonTag, blank=True, verbose_name=_("Tags"), related_name="absence_reasons" + ) + def __str__(self): if self.name: return f"{self.short_name} ({self.name})" diff --git a/aleksis/apps/kolego/rules.py b/aleksis/apps/kolego/rules.py index 893d04d3784495e2c523ee10ef451a25d72f3348..535b0f35d5ad7a8668a5ec5aa62cb1dbe4bd7abc 100644 --- a/aleksis/apps/kolego/rules.py +++ b/aleksis/apps/kolego/rules.py @@ -1,6 +1,6 @@ import rules -from aleksis.apps.kolego.models.absence import Absence, AbsenceReason +from aleksis.apps.kolego.models.absence import Absence, AbsenceReason, AbsenceReasonTag from aleksis.core.util.predicates import ( has_any_object, has_global_perm, @@ -58,5 +58,34 @@ delete_absencereason_predicate = has_person & ( ) rules.add_perm("kolego.delete_absencereason_rule", delete_absencereason_predicate) -view_menu_predicate = has_person & (view_absences_predicate | view_absencereasons_predicate) +view_absencereasontags_predicate = has_person & ( + has_global_perm("kolego.view_absencereasontag") + | has_any_object("kolego.view_absencereasontag", AbsenceReasonTag) +) +rules.add_perm("kolego.view_absencereasontags_rule", view_absencereasontags_predicate) + +view_absencereasontag_predicate = has_person & ( + has_global_perm("kolego.view_absencereasontag") + | has_object_perm("kolego.view_absencereasontag") +) +rules.add_perm("kolego.view_absencereasontag_rule", view_absencereasontag_predicate) + +create_absencereasontag_predicate = has_person & (has_global_perm("kolego.add_absencereasontag")) +rules.add_perm("kolego.create_absencereasontag_rule", create_absencereasontag_predicate) + +edit_absencereasontag_predicate = has_person & ( + has_global_perm("kolego.change_absencereasontag") + | has_object_perm("kolego.change_absencereasontag") +) +rules.add_perm("kolego.edit_absencereasontag_rule", edit_absencereasontag_predicate) + +delete_absencereasontag_predicate = has_person & ( + has_global_perm("kolego.delete_absencereasontag") + | has_object_perm("kolego.delete_absencereasontag") +) +rules.add_perm("kolego.delete_absencereasontag_rule", delete_absencereasontag_predicate) + +view_menu_predicate = has_person & ( + view_absences_predicate | view_absencereasons_predicate | view_absencereasontags_predicate +) rules.add_perm("kolego.view_menu_rule", view_menu_predicate) diff --git a/aleksis/apps/kolego/schema/__init__.py b/aleksis/apps/kolego/schema/__init__.py index 4fd07d96cd16188d60c035d0a46c247fb492d091..3b7f58ce62fe6cec550f907ea87fa56929c348dc 100644 --- a/aleksis/apps/kolego/schema/__init__.py +++ b/aleksis/apps/kolego/schema/__init__.py @@ -2,6 +2,7 @@ from django.apps import apps import graphene +from aleksis.apps.kolego.models.absence import AbsenceReasonTag from aleksis.core.schema.base import FilterOrderList from .absence import ( @@ -11,6 +12,10 @@ from .absence import ( AbsenceReasonBatchCreateMutation, AbsenceReasonBatchDeleteMutation, AbsenceReasonBatchPatchMutation, + AbsenceReasonTagBatchCreateMutation, + AbsenceReasonTagBatchDeleteMutation, + AbsenceReasonTagBatchPatchMutation, + AbsenceReasonTagType, AbsenceReasonType, AbsenceType, ) @@ -20,10 +25,15 @@ class Query(graphene.ObjectType): app_name = graphene.String() absences = FilterOrderList(AbsenceType) absence_reasons = FilterOrderList(AbsenceReasonType) + absence_reason_tags = FilterOrderList(AbsenceReasonTagType) + all_absence_reason_tags = FilterOrderList(AbsenceReasonTagType) def resolve_app_name(root, info, **kwargs) -> str: return apps.get_app_config("kolego").verbose_name + def resolve_all_absence_reason_tags(root, info, **kwargs): + return AbsenceReasonTag.objects.managed_and_unmanaged() + class Mutation(graphene.ObjectType): create_absences = AbsenceBatchCreateMutation.Field() @@ -33,3 +43,7 @@ class Mutation(graphene.ObjectType): create_absence_reasons = AbsenceReasonBatchCreateMutation.Field() delete_absence_reasons = AbsenceReasonBatchDeleteMutation.Field() update_absence_reasons = AbsenceReasonBatchPatchMutation.Field() + + create_absence_reason_tags = AbsenceReasonTagBatchCreateMutation.Field() + delete_absence_reason_tags = AbsenceReasonTagBatchDeleteMutation.Field() + update_absence_reason_tags = AbsenceReasonTagBatchPatchMutation.Field() diff --git a/aleksis/apps/kolego/schema/absence.py b/aleksis/apps/kolego/schema/absence.py index eb8f98654ec6834a7e206f87c0adc98f58f5cafe..6eb5f8015bdc4f263042eaa9a844e396001fbfb0 100644 --- a/aleksis/apps/kolego/schema/absence.py +++ b/aleksis/apps/kolego/schema/absence.py @@ -1,4 +1,5 @@ from datetime import timezone +from typing import Iterable, Union from django.conf import settings @@ -14,18 +15,35 @@ from aleksis.core.schema.base import ( PermissionsTypeMixin, ) -from ..models import Absence, AbsenceReason +from ..models import Absence, AbsenceReason, AbsenceReasonTag + + +class AbsenceReasonTagType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + class Meta: + model = AbsenceReasonTag + fields = ("id", "short_name", "name") + filter_fields = { + "short_name": ["icontains", "exact"], + "name": ["icontains", "exact"], + } + + @classmethod + def get_queryset(cls, queryset, info): + return get_objects_for_user(info.context.user, "kolego.view_absencereasontag", queryset) class AbsenceReasonType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = AbsenceReason - fields = ("id", "short_name", "name", "colour", "default") + fields = ("id", "short_name", "name", "colour", "default", "tags") filter_fields = { "short_name": ["icontains", "exact"], "name": ["icontains", "exact"], } + def resolve_tags(root, info, **kwargs): + return root.tags.managed_and_unmanaged().filter(absence_reasons=root) + @classmethod def get_queryset(cls, queryset, info): if not info.context.user.has_perm("kolego.fetch_absencereasons_rule"): @@ -129,10 +147,18 @@ class AbsenceBatchPatchMutation(BaseBatchPatchMutation): class AbsenceReasonBatchCreateMutation(BaseBatchCreateMutation): class Meta: model = AbsenceReason - fields = ("short_name", "name", "colour", "default") + fields = ("short_name", "name", "colour", "default", "tags") optional_fields = ("name",) permissions = ("kolego.create_absencereason_rule",) + @classmethod + def get_all_objs(cls, Model, ids: Iterable[Union[str, int]]): + return list( + Model.objects.managed_and_unmanaged().filter( + pk__in=[cls.resolve_id(id_) for id_ in ids] + ) + ) + class AbsenceReasonBatchDeleteMutation(BaseBatchDeleteMutation): class Meta: @@ -143,5 +169,34 @@ class AbsenceReasonBatchDeleteMutation(BaseBatchDeleteMutation): class AbsenceReasonBatchPatchMutation(BaseBatchPatchMutation): class Meta: model = AbsenceReason - fields = ("id", "short_name", "name", "colour", "default") + fields = ("id", "short_name", "name", "colour", "default", "tags") permissions = ("kolego.edit_absencereason_rule",) + + @classmethod + def get_all_objs(cls, Model, ids: Iterable[Union[str, int]]): + return list( + Model.objects.managed_and_unmanaged().filter( + pk__in=[cls.resolve_id(id_) for id_ in ids] + ) + ) + + +class AbsenceReasonTagBatchCreateMutation(BaseBatchCreateMutation): + class Meta: + model = AbsenceReasonTag + fields = ("short_name", "name") + optional_fields = ("name",) + permissions = ("kolego.create_absencereasontag_rule",) + + +class AbsenceReasonTagBatchDeleteMutation(BaseBatchDeleteMutation): + class Meta: + model = AbsenceReasonTag + permissions = ("kolego.delete_absencereasontag_rule",) + + +class AbsenceReasonTagBatchPatchMutation(BaseBatchPatchMutation): + class Meta: + model = AbsenceReasonTag + fields = ("id", "short_name", "name") + permissions = ("kolego.edit_absencereasontag_rule",)